diff --git a/.depcheckrc b/.depcheckrc index e71798a1ce0..09ae52061bf 100644 --- a/.depcheckrc +++ b/.depcheckrc @@ -3,6 +3,7 @@ ignores: [ '@graphql-codegen/*', '@commitlint/*', 'i18next', + 'moti', # Dependencies that depcheck thinks are missing but are actually present or never used '@yarnpkg/core', '@yarnpkg/cli', diff --git a/README.md b/README.md index 2b2d52e66e2..eb7520967c3 100644 --- a/README.md +++ b/README.md @@ -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: [wallet.uniswap.org](https://wallet.uniswap.org) +- Wallet (mobile + extension): [wallet.uniswap.org](https://wallet.uniswap.org) ## Socials / Contact @@ -31,6 +31,7 @@ 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 @@ -43,7 +44,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) | -| mobile | [![Crowdin](https://badges.crowdin.net/uniswap-wallet/localized.svg)](https://crowdin.com/project/uniswap-wallet) | +| wallet | [![Crowdin](https://badges.crowdin.net/uniswap-wallet/localized.svg)](https://crowdin.com/project/uniswap-wallet) | ## 🗂 Directory Structure diff --git a/RELEASE b/RELEASE index 6ecce16404b..708484fbe0d 100644 --- a/RELEASE +++ b/RELEASE @@ -1,12 +1,3 @@ -We are back with some new new updates! Here’s the latest: - -UniswapX is live: We’ve integrated the UniswapX protocol, which aggregates liquidity across onchain and offchain sources for better quotes. - -Easy import to Uniswap Extension: Onboard onto our new Chrome extension wallet easily by scanning a QR code with your Uniswap Mobile App. - -Transaction Details: Press anything on the Activity Screen and see more robust details about any of your transactions (swaps, sends, NFTs, etc). - -Other changes: - - Onboarding improvements +- Adds fallback support method for opening the side panel on chrome failure - Various bug fixes and performance improvements diff --git a/VERSION b/VERSION index e87fbad01e2..da438b7e9f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -mobile/1.31.1 \ No newline at end of file +extension/1.2.1 \ No newline at end of file diff --git a/apps/extension/.depcheckrc b/apps/extension/.depcheckrc new file mode 100644 index 00000000000..ed92562bf95 --- /dev/null +++ b/apps/extension/.depcheckrc @@ -0,0 +1,17 @@ +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", + ] diff --git a/apps/extension/.eslintignore b/apps/extension/.eslintignore new file mode 100644 index 00000000000..8e9904e772b --- /dev/null +++ b/apps/extension/.eslintignore @@ -0,0 +1 @@ +jest-setup.js diff --git a/apps/extension/.eslintrc.js b/apps/extension/.eslintrc.js new file mode 100644 index 00000000000..f21f9aa9ba3 --- /dev/null +++ b/apps/extension/.eslintrc.js @@ -0,0 +1,28 @@ +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: {}, +} diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 00000000000..085e25d9614 --- /dev/null +++ b/apps/extension/.gitignore @@ -0,0 +1,30 @@ +# 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 diff --git a/apps/extension/README.md b/apps/extension/README.md new file mode 100644 index 00000000000..371217ca5fd --- /dev/null +++ b/apps/extension/README.md @@ -0,0 +1,53 @@ +# 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. diff --git a/apps/extension/jest-setup.js b/apps/extension/jest-setup.js new file mode 100644 index 00000000000..82ba3fa4ea8 --- /dev/null +++ b/apps/extension/jest-setup.js @@ -0,0 +1,71 @@ +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', + } +}) diff --git a/apps/extension/jest.config.js b/apps/extension/jest.config.js new file mode 100644 index 00000000000..a571f8b3f3f --- /dev/null +++ b/apps/extension/jest.config.js @@ -0,0 +1,58 @@ +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: "/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', + ], +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 00000000000..9b90fa6474f --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,102 @@ +{ + "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", + "concurrently": "^8.0.1", + "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:circular": "concurrently \"../../scripts/check-circular-imports.sh ./src/sidebar/sidebar.tsx 1\" \"../../scripts/check-circular-imports.sh ./src/onboarding/onboarding.tsx 1\"", + "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" + } +} diff --git a/apps/extension/src/app/Global.css b/apps/extension/src/app/Global.css new file mode 100644 index 00000000000..85648681006 --- /dev/null +++ b/apps/extension/src/app/Global.css @@ -0,0 +1,31 @@ +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%; + } +} diff --git a/apps/extension/src/app/OnboardingApp.test.tsx b/apps/extension/src/app/OnboardingApp.test.tsx new file mode 100644 index 00000000000..c016ae7b8f8 --- /dev/null +++ b/apps/extension/src/app/OnboardingApp.test.tsx @@ -0,0 +1,10 @@ +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() + }) +}) diff --git a/apps/extension/src/app/OnboardingApp.tsx b/apps/extension/src/app/OnboardingApp.tsx new file mode 100644 index 00000000000..99ae9d0ba08 --- /dev/null +++ b/apps/extension/src/app/OnboardingApp.tsx @@ -0,0 +1,193 @@ +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 { 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 { 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 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: , +} + +const allRoutes = [ + { + path: '', + element: , + }, + { + path: OnboardingRoutes.UnsupportedBrowser, + element: , + }, + { + path: OnboardingRoutes.Create, + element: ( + , + [CreateOnboardingSteps.ViewMnemonic]: , + [CreateOnboardingSteps.TestMnemonic]: , + [CreateOnboardingSteps.Naming]: , + [CreateOnboardingSteps.Complete]: , + }} + /> + ), + }, + { + path: OnboardingRoutes.Import, + element: ( + , + [ImportOnboardingSteps.Password]: , + [ImportOnboardingSteps.Select]: , + [ImportOnboardingSteps.Complete]: , + }} + /> + ), + }, + { + path: OnboardingRoutes.Scan, + element: , + }, + { + path: OnboardingRoutes.ResetScan, + element: , + }, + { + path: OnboardingRoutes.Reset, + element: ( + , + [ResetSteps.Password]: , + [ResetSteps.Select]: , + [ResetSteps.Complete]: , + }} + /> + ), + }, +] + +const router = sentryCreateHashRouter([ + { + path: `/${TopLevelRoutes.Onboarding}`, + element: , + errorElement: , + children: !supportsSidePanel ? [unsupportedRoute] : allRoutes, + }, +]) + +function ScantasticFlow({ isResetting = false }: { isResetting?: boolean }): JSX.Element { + return ( + , + [ScanOnboardingSteps.OTP]: , + [ScanOnboardingSteps.Password]: , + [ScanOnboardingSteps.Select]: , + [ScanOnboardingSteps.Complete]: isResetting ? ( + + ) : ( + + ), + }} + /> + ) +} + +/** + * 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 { + await initExtensionAnalytics() + sendAnalyticsEvent(ExtensionEventName.OnboardingLoad) + } + initAndLogLoad().catch(() => undefined) + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/PopupApp.tsx b/apps/extension/src/app/PopupApp.tsx new file mode 100644 index 00000000000..cbb527860bf --- /dev/null +++ b/apps/extension/src/app/PopupApp.tsx @@ -0,0 +1,149 @@ +import '@tamagui/core/reset.css' +import 'src/app/Global.css' + +import { useEffect } from 'react' +import { I18nextProvider, useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { RouterProvider } from 'react-router-dom' +import { PersistGate } from 'redux-persist/integration/react' +import { ExtensionStatsigProvider } from 'src/app/StatsigProvider' +import { GraphqlProvider } from 'src/app/apollo' +import { ErrorElement } from 'src/app/components/ErrorElement' +import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties' +import { DappContextProvider } from 'src/app/features/dapp/DappContext' +import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry' +import { initExtensionAnalytics } from 'src/app/utils/analytics' +import { getLocalUserId } from 'src/app/utils/storage' +import { getReduxPersistor, getReduxStore } from 'src/store/store' +import { Button, Flex, Image, Text } from 'ui/src' +import { CHROME_LOGO, UNISWAP_LOGO } from 'ui/src/assets' +import { iconSizes, spacing } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' +import i18n from 'uniswap/src/i18n/i18n' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +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.Popup, userId) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'PopupApp.tsx', function: 'getLocalUserId' }, + }) + }) + +const router = sentryCreateHashRouter([ + { + path: '', + element: , + errorElement: , + }, +]) + +function PopupContent(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + useEffect(() => { + dispatch(syncAppWithDeviceLanguage()) + }, [dispatch]) + + const searchParams = new URLSearchParams(window.location.search) + const tabId = searchParams.get('tabId') + const windowId = searchParams.get('windowId') + + const tabIdNumber = tabId ? Number(tabId) : undefined + const windowIdNumber = windowId ? Number(windowId) : undefined + + return ( + + + + + + + + + + + + + + {t('extension.popup.chrome.title')} + + + {t('extension.popup.chrome.description')} + + + + + + + + + + + ) +} + +// TODO WALL-4313 - Backup for some broken chrome.sidePanel.open functionality +// Consider removing this once the issue is resolved or leaving as fallback +export default function PopupApp(): JSX.Element { + // initialize analytics on load + useEffect(() => { + initExtensionAnalytics().catch(() => undefined) + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/SidebarApp.tsx b/apps/extension/src/app/SidebarApp.tsx new file mode 100644 index 00000000000..7b4757eff40 --- /dev/null +++ b/apps/extension/src/app/SidebarApp.tsx @@ -0,0 +1,264 @@ +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: , + errorElement: , + children: [ + { + path: '', + element: , + }, + { + path: AppRoutes.AccountSwitcher, + element: , + }, + { + path: AppRoutes.Settings, + element: , + children: [ + { + path: '', + element: , + }, + { + path: SettingsRoutes.ChangePassword, + element: , + }, + isDevEnv() + ? { + path: SettingsRoutes.DevMenu, + element: , + } + : {}, + { + path: SettingsRoutes.ViewRecoveryPhrase, + element: , + }, + { + path: SettingsRoutes.RemoveRecoveryPhrase, + children: [ + { + path: RemoveRecoveryPhraseRoutes.Wallets, + element: , + }, + { + path: RemoveRecoveryPhraseRoutes.Verify, + element: , + }, + ], + }, + { + path: SettingsRoutes.Privacy, + element: , + }, + ], + }, + { + path: AppRoutes.Transfer, + element: , + }, + { + path: AppRoutes.Swap, + element: , + }, + { + path: AppRoutes.Receive, + element: , + }, + ], + }, +]) + +const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS +function useDappRequestPortListener(): void { + const dispatch = useDispatch() + const [currentPortChannel, setCurrentPortChannel] = useState() + const [windowId, setWindowId] = useState() + + 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 ( + <> + + + + ) +} + +/** + * 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 ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/StatsigProvider.tsx b/apps/extension/src/app/StatsigProvider.tsx new file mode 100644 index 00000000000..c0102bdb782 --- /dev/null +++ b/apps/extension/src/app/StatsigProvider.tsx @@ -0,0 +1,53 @@ +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 { + 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 ( + + {children} + + ) +} + +export async function initStatSigForBrowserScripts(): Promise { + await Statsig.initialize(DUMMY_STATSIG_SDK_KEY, await getStatsigUser(), { + api: uniswapUrls.statsigProxyUrl, + environment: { + tier: getStatsigEnvironmentTier(), + }, + }) +} diff --git a/apps/extension/src/app/apollo.tsx b/apps/extension/src/app/apollo.tsx new file mode 100644 index 00000000000..450dbf02cb3 --- /dev/null +++ b/apps/extension/src/app/apollo.tsx @@ -0,0 +1,20 @@ +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): JSX.Element { + const apolloClient = usePersistedApolloClient({ + storageWrapper: localStorage, + maxCacheSizeInBytes: MAX_CACHE_SIZE_IN_BYTES, + }) + + if (!apolloClient) { + return <> + } + return {children} +} diff --git a/apps/extension/src/app/components/ComingSoon.tsx b/apps/extension/src/app/components/ComingSoon.tsx new file mode 100644 index 00000000000..682ce848e62 --- /dev/null +++ b/apps/extension/src/app/components/ComingSoon.tsx @@ -0,0 +1,33 @@ +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 ( + + + + {children} + + + + + + {t('settings.setting.beta.tooltip')} + + + + + ) +} diff --git a/apps/extension/src/app/components/ErrorElement.tsx b/apps/extension/src/app/components/ErrorElement.tsx new file mode 100644 index 00000000000..6875a070794 --- /dev/null +++ b/apps/extension/src/app/components/ErrorElement.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react' +import { useRouteError } from 'react-router-dom' + +export function ErrorElement({ children }: PropsWithChildren): JSX.Element { + const error = useRouteError() + + if (!error) { + return <>{children} + } + + // Need to throw here to propagate to the ErrorBoundary + throw error +} diff --git a/apps/extension/src/app/components/Input.tsx b/apps/extension/src/app/components/Input.tsx new file mode 100644 index 00000000000..d7953e88d59 --- /dev/null +++ b/apps/extension/src/app/components/Input.tsx @@ -0,0 +1,38 @@ +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(function _Input( + { large = false, hideInput = false, centered = false, ...rest }: InputProps, + ref, +): JSX.Element { + return ( + + ) +}) diff --git a/apps/extension/src/app/components/MnemonicViewer.tsx b/apps/extension/src/app/components/MnemonicViewer.tsx new file mode 100644 index 00000000000..73ef4ddf95d --- /dev/null +++ b/apps/extension/src/app/components/MnemonicViewer.tsx @@ -0,0 +1,91 @@ +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() + } + return elements + }, [mnemonic]) + + return ( + + {rows} + + + + + ) +} + +function SeedPhraseRow({ words, startIndex }: { words: string[]; startIndex: number }): JSX.Element { + return ( + + {words.map((word, index) => ( + + ))} + + ) +} + +function SeedPhraseWord({ index, word }: { index: number; word: string }): JSX.Element { + const media = useMedia() + const fontVariant = 'body3' + const gap = media.xxs ? '$spacing4' : '$spacing8' + return ( + + + {index} + + {word} + + ) +} diff --git a/apps/extension/src/app/components/OptionalStrictMode.tsx b/apps/extension/src/app/components/OptionalStrictMode.tsx new file mode 100644 index 00000000000..75de22e68ca --- /dev/null +++ b/apps/extension/src/app/components/OptionalStrictMode.tsx @@ -0,0 +1,8 @@ +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 ? {props.children} : <>{props.children} +} diff --git a/apps/extension/src/app/components/PasswordInput.tsx b/apps/extension/src/app/components/PasswordInput.tsx new file mode 100644 index 00000000000..7ba406b366a --- /dev/null +++ b/apps/extension/src/app/components/PasswordInput.tsx @@ -0,0 +1,66 @@ +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(function PasswordInput( + { passwordStrength, hideInput, onToggleHideInput, value, ...inputProps }, + ref, +): JSX.Element { + return ( + + + + {passwordStrength !== undefined ? ( + + ) : ( + onToggleHideInput && ( + + ) + )} + + ) +}) + +function StrengthIndicator({ strength }: { strength: PasswordStrength }): JSX.Element | null { + if (strength === PasswordStrength.NONE) { + return null + } + + const { text, color } = getPasswordStrengthTextAndColor(strength) + + return ( + + + {text} + + + ) +} diff --git a/apps/extension/src/app/components/Trace/TraceUserProperties.tsx b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx new file mode 100644 index 00000000000..0d66f9f6f7f --- /dev/null +++ b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx @@ -0,0 +1,72 @@ +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 +} diff --git a/apps/extension/src/app/components/buttons/CopyButton.tsx b/apps/extension/src/app/components/buttons/CopyButton.tsx new file mode 100644 index 00000000000..ac94f03c820 --- /dev/null +++ b/apps/extension/src/app/components/buttons/CopyButton.tsx @@ -0,0 +1,75 @@ +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 }): JSX.Element { + const { t } = useTranslation() + + const [valueCopied, setValueCopied] = useState(false) + + const onPress = async (): Promise => { + await onCopyPress() + setValueCopied(true) + } + + return ( + + + + + {/* note there's various x/y adjustments here due to visual imbalance of icons/text */} + + {valueCopied ? ( + // check icon is a bit smaller and to the right + + ) : ( + + )} + + {valueCopied ? t('common.button.copied') : t('common.button.copy')} + + + + + + + ) +} diff --git a/apps/extension/src/app/components/layout/ScreenHeader.tsx b/apps/extension/src/app/components/layout/ScreenHeader.tsx new file mode 100644 index 00000000000..980a327454e --- /dev/null +++ b/apps/extension/src/app/components/layout/ScreenHeader.tsx @@ -0,0 +1,34 @@ +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 ( + + + + + + {/* 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. */} + + {/* // Render empty string if no title to account for Text element added padding for consistent size*/} + {title ?? ' '} + + + {rightColumn && {rightColumn}} + + ) +} diff --git a/apps/extension/src/app/components/loading/LoadingSpinner.tsx b/apps/extension/src/app/components/loading/LoadingSpinner.tsx new file mode 100644 index 00000000000..ebf93d8c501 --- /dev/null +++ b/apps/extension/src/app/components/loading/LoadingSpinner.tsx @@ -0,0 +1,28 @@ +import { Flex } from 'ui/src' +import { LoadingSpinnerInner, LoadingSpinnerOuter } from 'ui/src/components/icons' + +const SPINNER_HEIGHT = 80 + +export function LoadingSpinner(): JSX.Element { + return ( + <> + + + + + + + + + + ) +} + +const SPIN_SPEED_MS = 1000 diff --git a/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx new file mode 100644 index 00000000000..1a4cbcb8e55 --- /dev/null +++ b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx @@ -0,0 +1,43 @@ +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 ( + + {new Array(repeat).fill(null).map((_, i, { length }) => ( + + ))} + + ) +} + +function WalletSkeleton({ opacity }: { opacity: number }): JSX.Element { + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.css b/apps/extension/src/app/components/loading/SkeletonBox.css new file mode 100644 index 00000000000..aebf9b3e3e0 --- /dev/null +++ b/apps/extension/src/app/components/loading/SkeletonBox.css @@ -0,0 +1,40 @@ +.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%); + } +} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.tsx b/apps/extension/src/app/components/loading/SkeletonBox.tsx new file mode 100644 index 00000000000..07291bb34ef --- /dev/null +++ b/apps/extension/src/app/components/loading/SkeletonBox.tsx @@ -0,0 +1,16 @@ +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
+} diff --git a/apps/extension/src/app/components/modal/InfoModal.tsx b/apps/extension/src/app/components/modal/InfoModal.tsx new file mode 100644 index 00000000000..e254a9e8a53 --- /dev/null +++ b/apps/extension/src/app/components/modal/InfoModal.tsx @@ -0,0 +1,82 @@ +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): JSX.Element { + const colors = useSporeColors() + + return ( + + {showCloseButton && ( + + + + )} + + {icon} + + + {title} + + + {description} + + + + {linkText && linkUrl && ( + + + {linkText} + + + )} + + + ) +} diff --git a/apps/extension/src/app/components/tabs/ActivityTab.tsx b/apps/extension/src/app/components/tabs/ActivityTab.tsx new file mode 100644 index 00000000000..704717598f6 --- /dev/null +++ b/apps/extension/src/app/components/tabs/ActivityTab.tsx @@ -0,0 +1,20 @@ +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 ( + + {/* `sectionData` will be either an array of transactions or an array of loading skeletons */} + {(sectionData ?? []).map((item, index) => renderActivityItem({ item, index }))} + + ) +}) diff --git a/apps/extension/src/app/components/tabs/NftsTab.tsx b/apps/extension/src/app/components/tabs/NftsTab.tsx new file mode 100644 index 00000000000..9bf383ca2e3 --- /dev/null +++ b/apps/extension/src/app/components/tabs/NftsTab.tsx @@ -0,0 +1,100 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { memo, useCallback } from 'react' +import { useSelector } from 'react-redux' +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' + +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 + }, + [owner], + ) + + return ( + + ) +}) + +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 = useSelector(selectNftsVisibility) + const hidden = getIsNftHidden({ + contractAddress: item.contractAddress, + tokenId: item.tokenId, + isSpam: item.isSpam, + nftVisibility, + }) + + const itemId = `${item.chain}-${item.contractAddress}-${item.tokenId}-${hidden}` + + return ( + + + + + + + + ) +} + +const defaultEmptyStyle = { + minHeight: 100, + paddingVertical: '$spacing12', + width: '100%', +} diff --git a/apps/extension/src/app/constants.ts b/apps/extension/src/app/constants.ts new file mode 100644 index 00000000000..a27c12025f6 --- /dev/null +++ b/apps/extension/src/app/constants.ts @@ -0,0 +1,3 @@ +import { SpaceTokens } from 'ui/src' + +export const SCREEN_ITEM_HORIZONTAL_PAD = '$spacing12' satisfies SpaceTokens diff --git a/apps/extension/src/app/events/constants.ts b/apps/extension/src/app/events/constants.ts new file mode 100644 index 00000000000..7a81e3140fd --- /dev/null +++ b/apps/extension/src/app/events/constants.ts @@ -0,0 +1,3 @@ +export enum GlobalErrorEvent { + ReduxStorageExceeded = 'ReduxStorageExceeded', +} diff --git a/apps/extension/src/app/events/global.ts b/apps/extension/src/app/events/global.ts new file mode 100644 index 00000000000..d631a731e36 --- /dev/null +++ b/apps/extension/src/app/events/global.ts @@ -0,0 +1,5 @@ +import EventEmitter from 'eventemitter3' +import { GlobalErrorEvent } from 'src/app/events/constants' + +class GlobalEventEmitter extends EventEmitter {} +export const globalEventEmitter = new GlobalEventEmitter() diff --git a/apps/extension/src/app/features/accounts/AccountItem.tsx b/apps/extension/src/app/features/accounts/AccountItem.tsx new file mode 100644 index 00000000000..304431e0e7d --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountItem.tsx @@ -0,0 +1,194 @@ +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 { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances' +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 { usePortfolioValueModifiers } 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 valueModifiers = usePortfolioValueModifiers() ?? [] + const { data, loading, error } = usePortfolioTotalValue({ address, valueModifiers }) + 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 && ( + } + modalName={ModalName.RemoveWallet} + severity={WarningSeverity.High} + title={t('account.wallet.remove.title', { name: displayName?.name ?? '' })} + onClose={() => setShowRemoveWalletModal(false)} + onConfirm={onRemoveWallet} + /> + )} + {showEditLabelModal && setShowEditLabelModal(false)} />} + + + + + + {loading || error ? '' : formattedBalance} + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx new file mode 100644 index 00000000000..8d6c2a71e55 --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx @@ -0,0 +1,26 @@ +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(, { preloadedState }) + + expect(tree).toMatchSnapshot() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx new file mode 100644 index 00000000000..c686c4a6c55 --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { ComingSoon } from 'src/app/components/ComingSoon' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { AccountItem } from 'src/app/features/accounts/AccountItem' +import { CreateWalletModal } from 'src/app/features/accounts/CreateWalletModal' +import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { updateDappConnectedAddressFromExtension } from 'src/app/features/dapp/actions' +import { useDappConnectedAccounts } from 'src/app/features/dapp/hooks' +import { isConnectedAccount } from 'src/app/features/dapp/utils' +import { PopupName, openPopup } from 'src/app/features/popups/slice' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, MenuContent, MenuContentItem, Popover, ScrollView, Text, useSporeColors } from 'ui/src' +import { WalletFilled, X } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { ImportType } from 'uniswap/src/types/onboarding' +import { logger } from 'utilities/src/logger/logger' +import { sleep } from 'utilities/src/time/timing' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { PlusCircle } from 'wallet/src/components/icons/PlusCircle' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { createOnboardingAccount } from 'wallet/src/features/onboarding/createOnboardingAccount' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { AccountType, BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { createAccountsActions } from 'wallet/src/features/wallet/create/createAccountsSaga' +import { useActiveAccountWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' +import { DisplayNameType } from 'wallet/src/features/wallet/types' + +const MIN_MENU_WIDTH = 200 + +export function AccountSwitcherScreen(): JSX.Element { + const colors = useSporeColors() + const dispatch = useDispatch() + const { t } = useTranslation() + + const activeAccount = useActiveAccountWithThrow() + const activeAddress = activeAccount.address + const isViewOnly = activeAccount.type === AccountType.Readonly + + const accounts = useSignerAccounts() + const accountAddresses = useMemo( + () => accounts.map((account) => account.address).filter((address) => address !== activeAddress), + [accounts, activeAddress], + ) + const { dappUrl } = useDappContext() + + const connectedAccounts = useDappConnectedAccounts(dappUrl) + + // TODO: EXT-899 https://linear.app/uniswap/issue/EXT-899/enable-unitag-edit-button-is-account-header + const activeAccountDisplayName = useDisplayName(activeAddress) + const activeAccountHasUnitag = activeAccountDisplayName?.type === DisplayNameType.Unitag + + const [showEditLabelModal, setShowEditLabelModal] = useState(false) + + const [showRemoveWalletModal, setShowRemoveWalletModal] = useState(false) + const [showCreateWalletModal, setShowCreateWalletModal] = useState(false) + + const [pendingWallet, setPendingWallet] = useState() + + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) + + useEffect(() => { + const createOnboardingAccountAfterTransitionAnimation = async (): Promise => { + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // Delays computation heavy function invocation to avoid disrupting transition animation + await sleep(400) + setPendingWallet(await createOnboardingAccount(sortedMnemonicAccounts)) + } + createOnboardingAccountAfterTransitionAnimation().catch((e) => { + logger.error(e, { + tags: { file: 'AccountSwitcherScreen', function: 'createOnboardingAccount' }, + }) + }) + }, [sortedMnemonicAccounts]) + + const onNavigateToRemoveWallet = (): void => { + setShowRemoveWalletModal(false) + navigate(`/${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Wallets}`) + } + + const onCancelCreateWallet = (): void => { + setShowCreateWalletModal(false) + } + + const onConfirmCreateWallet = useCallback( + async (walletLabel: string): Promise => { + setShowCreateWalletModal(false) + if (!pendingWallet) { + return + } + + if (walletLabel) { + pendingWallet.name = walletLabel + } + + dispatch( + createAccountsActions.trigger({ + accounts: [pendingWallet], + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletAdded, { + wallet_type: ImportType.CreateAdditional, + accounts_imported_count: 1, + wallets_imported: [pendingWallet.address], + cloud_backup_used: pendingWallet.backups?.includes(BackupType.Cloud) ?? false, + modal: ModalName.AccountSwitcher, + }) + + navigate(-1) + + // Only show connect popup if some account is connected to current dapp + if (connectedAccounts.length > 0) { + dispatch(openPopup(PopupName.Connect)) + } + }, + [connectedAccounts.length, dispatch, pendingWallet], + ) + + const addWalletMenuOptions: MenuContentItem[] = [ + { + label: t('account.wallet.button.create'), + onPress: (): void => setShowCreateWalletModal(true), + }, + { + label: t('account.wallet.button.import'), + onPress: (): void => setShowRemoveWalletModal(true), + }, + ] + + const contentShadowProps = { + shadowColor: colors.shadowColor.val, + shadowRadius: 12, + shadowOpacity: 0.1, + zIndex: 1, + } + + return ( + + {showEditLabelModal && setShowEditLabelModal(false)} />} + {showRemoveWalletModal && ( + } + modalName={ModalName.RemoveWallet} + severity={WarningSeverity.High} + title={t('account.wallet.button.import')} + onClose={() => setShowRemoveWalletModal(false)} + onConfirm={onNavigateToRemoveWallet} + /> + )} + {showCreateWalletModal && ( + + )} + + + + + {activeAccountHasUnitag ? ( + + ) : ( + + )} + + + {accountAddresses.length > 0 && ( + + {t('account.wallet.header.other')} + + )} + + {accountAddresses.map((address: string) => { + return ( + => { + dispatch(setAccountAsActive(address)) + await updateDappConnectedAddressFromExtension(address) + if (connectedAccounts.length > 0 && !isConnectedAccount(connectedAccounts, address)) { + dispatch(openPopup(PopupName.Connect)) + } + navigate(-1) + }} + /> + ) + })} + + + + + + + {t('account.wallet.button.add')} + + + + + + + + + + + ) +} + +const UnitagActionButton = (): JSX.Element => { + const { t } = useTranslation() + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/CreateWalletModal.tsx b/apps/extension/src/app/features/accounts/CreateWalletModal.tsx new file mode 100644 index 00000000000..2ce86ea5e32 --- /dev/null +++ b/apps/extension/src/app/features/accounts/CreateWalletModal.tsx @@ -0,0 +1,91 @@ +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('') + + 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 ( + + + + {onboardingAccountAddress && } + + + + {onboardingAccountAddress && ( + + {shortenAddress(onboardingAccountAddress)} + + )} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/EditLabelModal.tsx b/apps/extension/src/app/features/accounts/EditLabelModal.tsx new file mode 100644 index 00000000000..f88bb47a421 --- /dev/null +++ b/apps/extension/src/app/features/accounts/EditLabelModal.tsx @@ -0,0 +1,74 @@ +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(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 ( + + + + + + setIsFocused(false)} + onChangeText={setInputText} + onFocus={() => setIsFocused(true)} + /> + + + {shortenAddress(address)} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap new file mode 100644 index 00000000000..72eb0c0a081 --- /dev/null +++ b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap @@ -0,0 +1,463 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountSwitcherScreen renders correctly 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ + +
+
+
+ + + +
+
+ + + +
+
+
+
+
+ + + + + + + + +
+
+
+
+ + Jacob Haley + +
+
+
+
+ + 0x​0fc6...be59 + + + + +
+
+
+
+ + + +
+ +
+
+
+ +
+
+ +
+
+ +
+ , + "container":
+ + +
+
+
+ + + +
+
+ + + +
+
+
+
+
+ + + + + + + + +
+
+
+
+ + Jacob Haley + +
+
+
+
+ + 0x​0fc6...be59 + + + + +
+
+
+
+ + + +
+ +
+
+
+ +
+
+ +
+
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "store": { + "@@observable": [Function], + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + }, + "unmount": [Function], +} +`; diff --git a/apps/extension/src/app/features/dapp/DappContext.tsx b/apps/extension/src/app/features/dapp/DappContext.tsx new file mode 100644 index 00000000000..185b8b58786 --- /dev/null +++ b/apps/extension/src/app/features/dapp/DappContext.tsx @@ -0,0 +1,67 @@ +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(undefined) + +export function DappContextProvider({ children }: { children: ReactNode }): JSX.Element { + const [dappUrl, setDappUrl] = useState('') + const [dappIconUrl, setDappIconUrl] = useState(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 {children} +} + +export function useDappContext(): DappContextState { + const context = useContext(DappContext) + + if (context === undefined) { + throw new Error('useDappContext must be used within a DappContextProvider') + } + + return context +} diff --git a/apps/extension/src/app/features/dapp/actions.ts b/apps/extension/src/app/features/dapp/actions.ts new file mode 100644 index 00000000000..a7852020b95 --- /dev/null +++ b/apps/extension/src/app/features/dapp/actions.ts @@ -0,0 +1,71 @@ +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 { + 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 { + dappStore.saveDappActiveAccount(dappUrl, account) + await updateConnectionFromExtension(dappUrl) +} + +export async function removeDappConnection(dappUrl: string, account?: Account): Promise { + dappStore.removeDappConnection(dappUrl, account) + await updateConnectionFromExtension(dappUrl) +} + +async function updateConnectionFromExtension(dappUrl: string): Promise { + 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 { + dappStore.updateDappConnectedAddress(address) + const connectedDapps = dappStore.getConnectedDapps(address) + for (const dappUrl of connectedDapps) { + await updateConnectionFromExtension(dappUrl) + } +} + +export async function removeAllDappConnectionsForAccount(account: Account): Promise { + const connectedDapps = dappStore.getConnectedDapps(account.address) + for (const dappUrl of connectedDapps) { + await removeDappConnection(dappUrl, account) + } +} + +export async function removeAllDappConnectionsFromExtension(): Promise { + const dappUrls = dappStore.getDappUrls() + for (const dappUrl of dappUrls) { + const response: UpdateConnectionRequest = { + type: ExtensionToDappRequestType.UpdateConnections, + addresses: [], + } + await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response) + } + dappStore.removeAllDappConnections() +} diff --git a/apps/extension/src/app/features/dapp/changeChain.test.ts b/apps/extension/src/app/features/dapp/changeChain.test.ts new file mode 100644 index 00000000000..e792b2a9b58 --- /dev/null +++ b/apps/extension/src/app/features/dapp/changeChain.test.ts @@ -0,0 +1,115 @@ +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, + }) + }) +}) diff --git a/apps/extension/src/app/features/dapp/changeChain.ts b/apps/extension/src/app/features/dapp/changeChain.ts new file mode 100644 index 00000000000..a7a14a0408a --- /dev/null +++ b/apps/extension/src/app/features/dapp/changeChain.ts @@ -0,0 +1,69 @@ +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, + } +} diff --git a/apps/extension/src/app/features/dapp/hooks.test.ts b/apps/extension/src/app/features/dapp/hooks.test.ts new file mode 100644 index 00000000000..1fd4394ec3c --- /dev/null +++ b/apps/extension/src/app/features/dapp/hooks.test.ts @@ -0,0 +1,98 @@ +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])) + }) +}) diff --git a/apps/extension/src/app/features/dapp/hooks.ts b/apps/extension/src/app/features/dapp/hooks.ts new file mode 100644 index 00000000000..ae5b5a9530d --- /dev/null +++ b/apps/extension/src/app/features/dapp/hooks.ts @@ -0,0 +1,34 @@ +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() + 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 || [] +} diff --git a/apps/extension/src/app/features/dapp/saga.ts b/apps/extension/src/app/features/dapp/saga.ts new file mode 100644 index 00000000000..b2494f85492 --- /dev/null +++ b/apps/extension/src/app/features/dapp/saga.ts @@ -0,0 +1,9 @@ +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) +} diff --git a/apps/extension/src/app/features/dapp/store.ts b/apps/extension/src/app/features/dapp/store.ts new file mode 100644 index 00000000000..2fafa6a3173 --- /dev/null +++ b/apps/extension/src/app/features/dapp/store.ts @@ -0,0 +1,199 @@ +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 {} +const dappStoreEventEmitter = new DappStoreEventEmitter() + +// Init +let initPromise: Promise | undefined + +async function init(): Promise { + if (!initPromise) { + initPromise = initInternal() + } + + return initPromise +} + +async function initInternal(): Promise { + 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 => { + 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, +} diff --git a/apps/extension/src/app/features/dapp/utils.test.ts b/apps/extension/src/app/features/dapp/utils.test.ts new file mode 100644 index 00000000000..35d4e569039 --- /dev/null +++ b/apps/extension/src/app/features/dapp/utils.test.ts @@ -0,0 +1,66 @@ +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) + }) +}) diff --git a/apps/extension/src/app/features/dapp/utils.ts b/apps/extension/src/app/features/dapp/utils.ts new file mode 100644 index 00000000000..094d617a214 --- /dev/null +++ b/apps/extension/src/app/features/dapp/utils.ts @@ -0,0 +1,21 @@ +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) +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx new file mode 100644 index 00000000000..2a34ff6b4f2 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx @@ -0,0 +1,255 @@ +import { PropsWithChildren, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { NetworksFooter } from 'src/app/features/dappRequests/requestContent/NetworksFooter' +import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice' +import { Anchor, AnimatePresence, Button, Flex, Text, UniversalImage, UniversalImageResizeMode, styled } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { formatDappURL } from 'utilities/src/format/urls' +import { logger } from 'utilities/src/logger/logger' +import { DappIconPlaceholder } from 'wallet/src/components/WalletConnect/DappIconPlaceholder' +import { useUSDValue } from 'wallet/src/features/gas/hooks' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api' +import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' +import { NetworkFeeFooter } from 'wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter' +import { TransactionTypeInfo } from 'wallet/src/features/transactions/types' +import { hasSufficientFundsIncludingGas } from 'wallet/src/features/transactions/utils' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +interface DappRequestHeaderProps { + title: string + headerIcon?: JSX.Element +} + +interface DappRequestFooterProps { + chainId?: WalletChainId + connectedAccountAddress?: string + confirmText: string + maybeCloseOnConfirm?: boolean + onCancel?: (requestToConfirm?: DappRequestStoreItem, transactionTypeInfo?: TransactionTypeInfo) => void + onConfirm?: (requestToCancel?: DappRequestStoreItem) => void + showAllNetworks?: boolean + showNetworkCost?: boolean + transactionGasFeeResult?: GasFeeResult + isUniswapX?: boolean +} + +type DappRequestContentProps = DappRequestHeaderProps & DappRequestFooterProps + +export const AnimatedPane = styled(Flex, { + variants: { + forwards: (dir: boolean) => ({ + enterStyle: { + x: dir ? 10 : -10, + opacity: 0, + }, + }), + increasing: (dir: boolean) => ({ + enterStyle: dir + ? { + y: 10, + opacity: 0, + } + : undefined, + exitStyle: !dir + ? { + y: 10, + opacity: 0, + } + : undefined, + }), + } as const, +}) + +export function DappRequestContent({ + chainId, + title, + headerIcon, + confirmText, + connectedAccountAddress, + maybeCloseOnConfirm, + onCancel, + onConfirm, + showAllNetworks, + showNetworkCost, + transactionGasFeeResult, + children, + isUniswapX, +}: PropsWithChildren): JSX.Element { + const { forwards, currentIndex } = useDappRequestQueueContext() + + return ( + <> + + + + {children} + + + + + ) +} + +function DappRequestHeader({ headerIcon, title }: DappRequestHeaderProps): JSX.Element { + const { dappIconUrl, dappUrl } = useDappRequestQueueContext() + const hostname = new URL(dappUrl).hostname.toUpperCase() + const fallbackIcon = + + return ( + + + + {headerIcon || ( + + )} + + + + {title} + + + + {formatDappURL(dappUrl)} + + + + ) +} + +const WINDOW_CLOSE_DELAY = 10 + +export function DappRequestFooter({ + chainId, + connectedAccountAddress, + confirmText, + maybeCloseOnConfirm, + onCancel, + onConfirm, + showAllNetworks, + showNetworkCost, + transactionGasFeeResult, + isUniswapX, +}: DappRequestFooterProps): JSX.Element { + const { t } = useTranslation() + const activeAccount = useActiveAccountWithThrow() + const { + dappUrl, + currentAccount, + request, + totalRequestCount, + onConfirm: defaultOnConfirm, + onCancel: defaultOnCancel, + } = useDappRequestQueueContext() + + const activeChain = useDappLastChainId(dappUrl) + + if (!request) { + const error = new Error('no request present') + logger.error(error, { tags: { file: 'DappRequestContent', function: 'DappRequestFooter' } }) + throw error + } + + const currentChainId = chainId || activeChain || UniverseChainId.Mainnet + const gasFeeUSD = useUSDValue(currentChainId, transactionGasFeeResult?.value) + const { balance: nativeBalance } = useOnChainNativeCurrencyBalance(currentChainId, currentAccount.address) + + const hasSufficientGas = hasSufficientFundsIncludingGas({ + gasFee: transactionGasFeeResult?.value, + nativeCurrencyBalance: nativeBalance, + }) + + const shouldCloseSidebar = request.isSidebarClosed && totalRequestCount <= 1 + const isConfirmDisabled = transactionGasFeeResult ? !gasFeeUSD || !hasSufficientGas : false + + const handleOnConfirm = useCallback(async () => { + if (onConfirm) { + onConfirm() + } else { + await defaultOnConfirm(request) + } + + if (maybeCloseOnConfirm && shouldCloseSidebar) { + setTimeout(window.close, WINDOW_CLOSE_DELAY) + } + }, [request, maybeCloseOnConfirm, onConfirm, defaultOnConfirm, shouldCloseSidebar]) + + const handleOnCancel = useCallback(async () => { + if (onCancel) { + onCancel() + } else { + await defaultOnCancel(request) + } + + if (shouldCloseSidebar) { + setTimeout(window.close, WINDOW_CLOSE_DELAY) + } + }, [request, onCancel, defaultOnCancel, shouldCloseSidebar]) + + return ( + <> + + {!hasSufficientGas && ( + + + {t('swap.warning.insufficientGas.title', { + currencySymbol: nativeBalance?.currency?.symbol, + })} + + + )} + {showNetworkCost && ( + + )} + {showAllNetworks && } + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx new file mode 100644 index 00000000000..4efb671eeba --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx @@ -0,0 +1,145 @@ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { PropsWithChildren, createContext, useContext, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } 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 { ExtensionState } from 'src/store/extensionReducer' +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 + onCancel: (request: DappRequestStoreItem) => Promise +} + +const DappRequestQueueContext = createContext(undefined) + +export function DappRequestQueueProvider({ children }: PropsWithChildren): JSX.Element { + const dispatch = useDispatch() + const [currentIndex, setCurrentIndex] = useState(0) + + // Show the top most pending request + const pendingRequests = useSelector((state: ExtensionState) => 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 => { + 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 => { + 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 {children} +} + +export function useDappRequestQueueContext(): DappRequestQueueContextValue { + const context = useContext(DappRequestQueueContext) + + if (context === undefined) { + throw new Error('useDappRequestQueueContext must be used within a DappRequestQueueProvider') + } + + return context +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestWrapper.tsx b/apps/extension/src/app/features/dappRequests/DappRequestWrapper.tsx new file mode 100644 index 00000000000..4bdd2560139 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestWrapper.tsx @@ -0,0 +1,189 @@ +import { memo } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { AnimatedPane, DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { ConnectionRequestContent } from 'src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent' +import { EthSendRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/EthSend' +import { PersonalSignRequestContent } from 'src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent' +import { SignTypedDataRequestContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent' +import { rejectAllRequests } from 'src/app/features/dappRequests/saga' +import { isDappRequestStoreItemForEthSendTxn } from 'src/app/features/dappRequests/slice' +import { + isGetAccountRequest, + isRequestAccountRequest, + isRequestPermissionsRequest, + isSignMessageRequest, + isSignTypedDataRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { AnimatePresence, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { ReceiptText, RotatableChevron } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' + +const REJECT_MESSAGE_HEIGHT = 48 + +export function DappRequestWrapper(): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const dispatch = useDispatch() + + const { totalRequestCount, onPressPrevious, onPressNext, currentIndex, increasing } = useDappRequestQueueContext() + + const disabledPrevious = currentIndex <= 0 + const disabledNext = currentIndex >= totalRequestCount - 1 + + const onRejectAll = async (): Promise => { + dispatch(rejectAllRequests()) + } + + return ( + + + + {totalRequestCount > 1 && ( + + + + + + ), + }} + i18nKey="dapp.request.reject.info" + values={{ totalRequestCount }} + /> + + + + + {t('dapp.request.reject.action')} + + + + )} + + 1 ? REJECT_MESSAGE_HEIGHT + 12 : 0} + > + {totalRequestCount > 1 && ( + + + + + + {currentIndex + 1} + + + / + + + + + {totalRequestCount} + + + + + + + + )} + + + + + ) +} + +const DappRequest = memo(function _DappRequest(): JSX.Element { + const { t } = useTranslation() + const { request } = useDappRequestQueueContext() + + if (request) { + if (isSignMessageRequest(request.dappRequest)) { + return + } + if (isSignTypedDataRequest(request.dappRequest)) { + return + } + if (isDappRequestStoreItemForEthSendTxn(request)) { + return + } + if ( + isGetAccountRequest(request.dappRequest) || + isRequestAccountRequest(request.dappRequest) || + isRequestPermissionsRequest(request.dappRequest) + ) { + return + } + } + + return +}) diff --git a/apps/extension/src/app/features/dappRequests/accounts.ts b/apps/extension/src/app/features/dappRequests/accounts.ts new file mode 100644 index 00000000000..04ca8d088fb --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/accounts.ts @@ -0,0 +1,153 @@ +/* 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, select } 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' + +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* select(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, + }) + } +} diff --git a/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts new file mode 100644 index 00000000000..678d8143670 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts @@ -0,0 +1,251 @@ +/* eslint-disable complexity */ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { PayloadAction } from '@reduxjs/toolkit' +import { getAccount, getAccountRequest } from 'src/app/features/dappRequests/accounts' +import { getChainId, getChainIdNoDappInfo } from 'src/app/features/dappRequests/getChainId' +import { + handleGetPermissionsRequest, + handleRequestPermissions, + handleRevokePermissions, +} from 'src/app/features/dappRequests/permissions' +import { + DappRequestNoDappInfo, + DappRequestRejectParams, + DappRequestWithDappInfo, + changeChainSaga, + confirmRequest, + confirmRequestNoDappInfo, + handleSendTransaction, + handleSignMessage, + handleSignTypedData, + handleUniswapOpenSidebarRequest, + rejectAllRequests, + rejectRequest, +} from 'src/app/features/dappRequests/saga' +import { dappRequestActions } from 'src/app/features/dappRequests/slice' +import { + BaseSendTransactionRequest, + BaseSendTransactionRequestSchema, + ChangeChainRequest, + ChangeChainRequestSchema, + DappRequestType, + DappResponseType, + ErrorResponse, + GetAccountRequest, + GetAccountRequestSchema, + GetChainIdRequest, + GetChainIdRequestSchema, + GetPermissionsRequest, + GetPermissionsRequestSchema, + RequestAccountRequest, + RequestAccountRequestSchema, + RequestPermissionsRequest, + RequestPermissionsRequestSchema, + RevokePermissionsRequest, + RevokePermissionsRequestSchema, + SignMessageRequest, + SignMessageRequestSchema, + SignTypedDataRequest, + SignTypedDataRequestSchema, + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { ExtensionState } from 'src/store/extensionReducer' +import { call, put, select, takeEvery } from 'typed-redux-saga' +import { logger } from 'utilities/src/logger/logger' + +function* dappRequestApproval({ + type, + payload: request, +}: PayloadAction) { + if (type === rejectAllRequests.type) { + const pendingRequests = yield* select((state: ExtensionState) => state.dappRequests.pending) + + for (const pendingRequest of pendingRequests) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.userRejectedRequest()), + requestId: pendingRequest.dappRequest.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, pendingRequest.senderTabInfo.id, errorResponse) + } + + yield* put(dappRequestActions.removeAll()) + return + } + + const requestId = + ('dappRequest' in request && request?.dappRequest?.requestId) || + ('errorResponse' in request && request?.errorResponse?.requestId) + const { id: senderTabId } = request.senderTabInfo + + if (!senderTabId) { + throw new Error('senderTabId is required') + } + if (!requestId) { + throw new Error('requestId is required') + } + + try { + if (type === confirmRequest.type) { + const confirmedRequest = request as DappRequestWithDappInfo + logger.debug('dappRequestApprovalWatcher', 'confirmRequest', 'confirm request', request) + + switch (confirmedRequest.dappRequest.type) { + case DappRequestType.RequestPermissions: { + const validatedRequest: RequestPermissionsRequest = RequestPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleRequestPermissions, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + ) + break + } + case DappRequestType.RevokePermissions: { + const validatedRequest: RevokePermissionsRequest = RevokePermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRevokePermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetPermissions: { + const validatedRequest: GetPermissionsRequest = GetPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleGetPermissionsRequest, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + ) + break + } + case DappRequestType.SendTransaction: { + const validatedRequest: BaseSendTransactionRequest = BaseSendTransactionRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleSendTransaction, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + confirmedRequest.transactionTypeInfo, + ) + break + } + case DappRequestType.GetAccount: { + const validatedRequest: GetAccountRequest = GetAccountRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getAccount, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.RequestAccount: { + const validatedRequest: RequestAccountRequest = RequestAccountRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(getAccountRequest, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.GetChainId: { + const validatedRequest: GetChainIdRequest = GetChainIdRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getChainId, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.ChangeChain: { + const validatedRequest: ChangeChainRequest = ChangeChainRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(changeChainSaga, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.SignMessage: { + const validatedRequest: SignMessageRequest = SignMessageRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSignMessage, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.SignTypedData: { + const validatedRequest: SignTypedDataRequest = SignTypedDataRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSignTypedData, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + // Add more request types here + } + } else if (type === confirmRequestNoDappInfo.type) { + const confirmedRequest = request as DappRequestNoDappInfo + switch (confirmedRequest.dappRequest.type) { + case DappRequestType.RequestAccount: { + const validatedRequest = RequestAccountRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getAccountRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.RequestPermissions: { + const validatedRequest: RequestPermissionsRequest = RequestPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRequestPermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.RevokePermissions: { + const validatedRequest: RevokePermissionsRequest = RevokePermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRevokePermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetPermissions: { + const validatedRequest: GetPermissionsRequest = GetPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleGetPermissionsRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetChainId: { + const validatedRequest: GetChainIdRequest = GetChainIdRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getChainIdNoDappInfo, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.UniswapOpenSidebar: { + const validatedRequest: UniswapOpenSidebarRequest = UniswapOpenSidebarRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleUniswapOpenSidebarRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + } + } else if (type === rejectRequest.type) { + const rejectedRequest = request as DappRequestRejectParams + logger.debug('dappRequestApprovalWatcher', 'rejectRequest', 'dapp request rejected', request) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: rejectedRequest.errorResponse.error, + requestId: rejectedRequest.errorResponse.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, rejectedRequest.senderTabInfo.id, errorResponse) + } + } catch (error) { + logger.error(error, { + tags: { file: 'dappRequestApprovalWatcherSaga', function: 'dappRequestApprovalWatcher' }, + }) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + requestId, + error: serializeError(error), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabId, errorResponse) + } finally { + yield* put(dappRequestActions.remove(requestId)) + } +} + +/** + * Watch for pending requests to be confirmed or rejected and dispatch action + */ +export function* dappRequestApprovalWatcher() { + yield* takeEvery([confirmRequestNoDappInfo, confirmRequest, rejectRequest, rejectAllRequests], dappRequestApproval) +} diff --git a/apps/extension/src/app/features/dappRequests/getChainId.ts b/apps/extension/src/app/features/dappRequests/getChainId.ts new file mode 100644 index 00000000000..4cbfc52ccee --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/getChainId.ts @@ -0,0 +1,34 @@ +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) +} diff --git a/apps/extension/src/app/features/dappRequests/permissions.ts b/apps/extension/src/app/features/dappRequests/permissions.ts new file mode 100644 index 00000000000..3ace013c415 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/permissions.ts @@ -0,0 +1,126 @@ +/* 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) + } +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx new file mode 100644 index 00000000000..be130ca36d9 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx @@ -0,0 +1,27 @@ +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 ( + + + + {t('dapp.request.connect.helptext')} + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx new file mode 100644 index 00000000000..9085c17f076 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx @@ -0,0 +1,104 @@ +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 { + 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 + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +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 => onConfirm(transactionTypeInfo) + + return ( + } + title={tokenSymbol ? t('dapp.request.approve.title', { tokenSymbol }) : t('dapp.request.approve.fallbackTitle')} + transactionGasFeeResult={transactionGasFeeResult} + onCancel={onCancel} + onConfirm={onConfirmWithTransactionTypeInfo} + > + + + {t('dapp.request.approve.helptext')} + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx new file mode 100644 index 00000000000..70ebc898f52 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { ApproveRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent' +import { FallbackEthSendRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend' +import { LPRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent' +import { SwapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent' +import { DappRequestStoreItemForEthSendTxn } from 'src/app/features/dappRequests/slice' +import { isApproveRequest, isLPRequest, isSwapRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { logger } from 'utilities/src/logger/logger' +import { formatExternalTxnWithGasEstimates } from 'wallet/src/features/gas/formatExternalTxnWithGasEstimates' +import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' +import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' +import { TransactionTypeInfo } from 'wallet/src/features/transactions/types' + +interface EthSendRequestContentProps { + request: DappRequestStoreItemForEthSendTxn +} + +export function EthSendRequestContent({ request }: EthSendRequestContentProps): JSX.Element { + const { dappRequest } = request + const { dappUrl, onConfirm, onCancel } = useDappRequestQueueContext() + const chainId = useDappLastChainId(dappUrl) + + // Gas service requires a chain id + const formattedTxnForGasQuery = { ...dappRequest.transaction, chainId } + + const transactionGasFeeResult = useTransactionGasFee( + formattedTxnForGasQuery, + /*speed=*/ GasSpeed.Urgent, + /*skip=*/ !formattedTxnForGasQuery, + /*pollingInterval=*/ PollingInterval.LightningMcQueen, + ) + + const isInvalidGasFeeResult = isInvalidGasFeeResultForEthSend(transactionGasFeeResult) + + useEffect(() => { + if (isInvalidGasFeeResult) { + logger.error( + new Error(transactionGasFeeResult.error?.toString() ?? 'Empty gas fee result for dapp txn request.'), + { + tags: { file: 'features/dappRequests/DappRequestContent, ', function: 'DappRequest' }, + extra: { dappRequest }, + }, + ) + } + }, [dappRequest, isInvalidGasFeeResult, transactionGasFeeResult]) + + const requestWithGasValues = useMemo(() => { + const txnWithFormattedGasEstimates = formatExternalTxnWithGasEstimates({ + transaction: dappRequest.transaction, + gasFeeResult: transactionGasFeeResult, + }) + + return { + ...request, + dappRequest: { + ...request.dappRequest, + transaction: txnWithFormattedGasEstimates, + }, + } + }, [dappRequest.transaction, request, transactionGasFeeResult]) + + const onConfirmRequest = useCallback( + async (transactionTypeInfo?: TransactionTypeInfo) => { + await onConfirm(requestWithGasValues, transactionTypeInfo) + }, + [onConfirm, requestWithGasValues], + ) + + const onCancelRequest = useCallback(async () => { + await onCancel(requestWithGasValues) + }, [onCancel, requestWithGasValues]) + + if (isSwapRequest(dappRequest)) { + return ( + + ) + } else if (isLPRequest(dappRequest)) { + return ( + + ) + } else if (isApproveRequest(dappRequest)) { + return ( + + ) + } else { + return ( + + ) + } +} + +function isInvalidGasFeeResultForEthSend(gasFeeResult: GasFeeResult): boolean { + return !!gasFeeResult.error || (!gasFeeResult.loading && (!gasFeeResult.params || !gasFeeResult.value)) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx new file mode 100644 index 00000000000..8a192838cff --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx @@ -0,0 +1,135 @@ +import { BigNumber } from 'ethers' +import { useCallback } from 'react' +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 { SendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { useCopyToClipboard } from 'src/app/hooks/useOnCopyToClipboard' +import { Anchor, Flex, Text, TouchableArea } from 'ui/src' +import { AnimatedCopySheets, ExternalLink } from 'ui/src/components/icons' +import { ellipseMiddle, shortenAddress } from 'utilities/src/addresses' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { CopyNotificationType } from 'wallet/src/features/notifications/types' +import { ContentRow } from 'wallet/src/features/transactions/TransactionRequest/ContentRow' +import { + SpendingDetails, + SpendingEthDetails, +} from 'wallet/src/features/transactions/TransactionRequest/SpendingDetails' +import { ExplorerDataType, getExplorerLink } from 'wallet/src/utils/linking' +import { useNoYoloParser } from 'wallet/src/utils/useNoYoloParser' +import { useTransactionCurrencies } from 'wallet/src/utils/useTransactionCurrencies' + +interface FallbackEthSendRequestProps { + transactionGasFeeResult: GasFeeResult + dappRequest: SendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +export function FallbackEthSendRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: FallbackEthSendRequestProps): JSX.Element | null { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + + const { value: sending, to: toAddress, chainId: transactionChainId } = dappRequest.transaction + const chainId = transactionChainId || activeChain + const recipientLink = chainId && toAddress ? getExplorerLink(chainId, toAddress, ExplorerDataType.ADDRESS) : '' + const contractFunction = dappRequest.transaction.type + const calldata = dappRequest.transaction.data + + const copyToClipboard = useCopyToClipboard() + + const copyCalldata = useCallback( + () => + copyToClipboard({ + textToCopy: calldata, + copyType: CopyNotificationType.Calldata, + }), + [calldata, copyToClipboard], + ) + const { parsedTransactionData } = useNoYoloParser(dappRequest.transaction, chainId) + const transactionCurrencies = useTransactionCurrencies({ chainId, to: toAddress, parsedTransactionData }) + + return ( + + + {sending && !BigNumber.from(sending).eq(0) && chainId && ( + + )} + {transactionCurrencies?.map((currencyInfo, i) => ( + + ))} + {toAddress && ( + + + + + {shortenAddress(toAddress)} + + + + + + )} + + + {parsedTransactionData?.name || contractFunction || t('common.text.unknown')} + + + {calldata && ( + + + + {ellipseMiddle(calldata)} + + + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx new file mode 100644 index 00000000000..ea1615cc2c5 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { LPSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex, Text } from 'ui/src' +import { GasFeeResult } from 'wallet/src/features/gas/types' + +interface LPRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: LPSendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +export function LPRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: LPRequestContentProps): JSX.Element { + const { t } = useTranslation() + + return ( + + + {dappRequest.parsedCalldata.commands.map((command) => ( + + {command.commandName} + + ))} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx new file mode 100644 index 00000000000..e28e2584199 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx @@ -0,0 +1,130 @@ +import { Currency } from '@uniswap/sdk-core' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { Flex, Separator, Text } from 'ui/src' +import { ArrowDown } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { SplitLogo } from 'uniswap/src/components/CurrencyLogo/SplitLogo' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { WalletChainId } from 'uniswap/src/types/chains' +import { NumberType } from 'utilities/src/format/types' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' + +export function SwapDisplay({ + inputAmount, + outputAmount, + inputCurrencyInfo, + outputCurrencyInfo, + chainId, + currencyIn, + currencyOut, + transactionGasFeeResult, + onCancel, + onConfirm, + isUniswapX, +}: { + inputAmount: string + outputAmount: string + inputCurrencyInfo: Maybe + outputCurrencyInfo: Maybe + chainId: WalletChainId | null + currencyIn?: Currency + currencyOut?: Currency + transactionGasFeeResult?: GasFeeResult + onCancel?: () => Promise + onConfirm?: () => Promise + isUniswapX?: boolean +}): JSX.Element { + const { t } = useTranslation() + const { formatCurrencyAmount } = useLocalizationContext() + + const inputCurrencyAmount = getCurrencyAmount({ + value: inputAmount, + valueType: ValueType.Exact, + currency: inputCurrencyInfo?.currency, + }) + const inputValue = useUSDCValue(inputCurrencyAmount) + + const outputCurrencyAmount = getCurrencyAmount({ + value: outputAmount, + valueType: ValueType.Exact, + currency: outputCurrencyInfo?.currency, + }) + const outputValue = useUSDCValue(outputCurrencyAmount) + + const showSplitLogo = Boolean(inputCurrencyInfo?.logoUrl && outputCurrencyInfo?.logoUrl) + const showSwapDetails = Boolean(currencyIn?.symbol && currencyOut?.symbol) + + return ( + + ) : undefined + } + isUniswapX={isUniswapX} + title={ + currencyIn?.symbol && currencyOut?.symbol + ? t('swap.request.title.full', { + inputCurrencySymbol: currencyIn?.symbol, + outputCurrencySymbol: currencyOut?.symbol, + }) + : t('swap.request.title.short') + } + transactionGasFeeResult={transactionGasFeeResult} + onCancel={onCancel} + onConfirm={onConfirm} + > + {showSwapDetails && ( + <> + + + + + + {formatCurrencyAmount({ value: inputCurrencyAmount, type: NumberType.TokenTx })} {currencyIn?.symbol} + + + {formatCurrencyAmount({ value: inputValue, type: NumberType.FiatTokenPrice })} + + + + + + + + + {formatCurrencyAmount({ value: outputCurrencyAmount, type: NumberType.TokenTx })}{' '} + {currencyOut?.symbol} + + + {formatCurrencyAmount({ value: outputValue, type: NumberType.FiatTokenPrice })} + + + + + + + )} + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx new file mode 100644 index 00000000000..53c2bf55d51 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx @@ -0,0 +1,251 @@ +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { SwapDisplay } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay' +import { formatUnits } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/utils' +import { SignTypedDataRequest, SwapSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + AmountInMaxParam, + AmountInParam, + AmountOutMinParam, + AmountOutParam, + Param, + UniversalRouterCommand, + isAmountInMaxParam, + isAmountInParam, + isAmountOutMinParam, + isAmountOutParam, + isURCommandASwap, +} from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { assert } from 'utilities/src/errors' +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 extractPathValues(commands: UniversalRouterCommand[]): { + inputAddress: string | undefined + outputAddress: string | undefined +} { + let inputAddress: string | undefined + let outputAddress: string | undefined + for (const command of commands) { + const param: Param | undefined = command.params.find(({ name }) => name === 'path') + if (!param) { + continue + } + // matches V2SwapExact[In|Out] + if (command.commandName.startsWith('V2SwapExact')) { + const path = param.value as string[] + const first = path[0] + if (first && !inputAddress) { + inputAddress = first + } + const last = path[path.length - 1] + if (last) { + outputAddress = last + } + } + // matches V3SwapExact[In|Out] + if (command.commandName.startsWith('V3SwapExact')) { + const path = param.value as { fee: number; tokenIn: string; tokenOut: string }[] + const first = path[0] + if (first && !inputAddress) { + inputAddress = first.tokenIn + } + const last = path[path.length - 1] + if (last) { + outputAddress = last.tokenOut + } + } + } + return { inputAddress, outputAddress } +} + +function useSwapCurrencyIdentifiers( + request: SwapSendTransactionRequest, + dappUrl: string, +): { inputIdentifier: string | undefined; outputIdentifier: string | undefined } { + const activeChain = useDappLastChainId(dappUrl) + return getSwapCurrencyIdentifiers(request, activeChain) +} + +export function getSwapCurrencyIdentifiers( + request: SwapSendTransactionRequest, + activeChain: WalletChainId | undefined, +): { inputIdentifier: string | undefined; outputIdentifier: string | undefined } { + const { inputAddress, outputAddress } = extractPathValues(request.parsedCalldata.commands) + + const inputIdentifier = activeChain && inputAddress ? buildCurrencyId(activeChain, inputAddress) : undefined + const outputIdentifier = activeChain && outputAddress ? buildCurrencyId(activeChain, outputAddress) : undefined + + return { inputIdentifier, outputIdentifier } +} + +function getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw, + outputAmountRaw, +}: { + inputCurrencyInfo: Maybe + outputCurrencyInfo: Maybe + inputAmountRaw: string + outputAmountRaw: string +}): TransactionTypeInfo | undefined { + return inputCurrencyInfo?.currencyId && outputCurrencyInfo?.currencyId + ? { + type: TransactionType.Swap, + tradeType: 0, // TradeType.EXACT_INPUT, but TradeType doesn't matter for the UI + inputCurrencyId: inputCurrencyInfo?.currencyId, + outputCurrencyId: outputCurrencyInfo?.currencyId, + inputCurrencyAmountRaw: inputAmountRaw, + expectedOutputCurrencyAmountRaw: outputAmountRaw, + minimumOutputCurrencyAmountRaw: outputAmountRaw, + } + : undefined +} + +interface SwapRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: SwapSendTransactionRequest + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function SwapRequestContent({ + transactionGasFeeResult, + dappRequest, + onCancel, + onConfirm, +}: SwapRequestContentProps): JSX.Element { + const { dappUrl } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) || UniverseChainId.Mainnet + + const { inputIdentifier, outputIdentifier } = useSwapCurrencyIdentifiers(dappRequest, dappUrl) + + const inputCurrencyInfo = useCurrencyInfo(inputIdentifier) + const outputCurrencyInfo = useCurrencyInfo(outputIdentifier) + + const isFirstCommandWrappingEth = dappRequest.parsedCalldata.commands[0]?.commandName === 'WrapEth' + const isLastCommandUnwrappingEth = + dappRequest.parsedCalldata.commands[dappRequest.parsedCalldata.commands.length - 1]?.commandName === 'UnwrapWeth' + + const nativeCurrency = NativeCurrency.onChain(activeChain) + + const nativeInput = isFirstCommandWrappingEth && inputCurrencyInfo?.currency.equals(nativeCurrency.wrapped) + const nativeOutput = isLastCommandUnwrappingEth && outputCurrencyInfo?.currency.equals(nativeCurrency.wrapped) + const currency0 = nativeInput ? nativeCurrency : inputCurrencyInfo?.currency + const currency1 = nativeOutput ? nativeCurrency : outputCurrencyInfo?.currency + + const firstSwapCommand = dappRequest.parsedCalldata.commands.find(isURCommandASwap) + const lastSwapCommand = dappRequest.parsedCalldata.commands.findLast(isURCommandASwap) + + assert( + firstSwapCommand && lastSwapCommand, + 'SwapRequestContent: All swaps must have a defined input and output Universal Router command.', + ) + + function isAmountInOrMaxParam(param: Param): param is AmountInParam | AmountInMaxParam { + return isAmountInParam(param) || isAmountInMaxParam(param) + } + + function isAmountOutMinOrOutParam(param: Param): param is AmountOutMinParam | AmountOutParam { + return isAmountOutMinParam(param) || isAmountOutParam(param) + } + + // Ideally we would render some UI that makes it clear when you can expect minAmountOut instead of rendering what might look like a bad deal + const firstAmountInParam = firstSwapCommand?.params.find(isAmountInOrMaxParam) + const lastAmountOutParam = lastSwapCommand?.params.find(isAmountOutMinOrOutParam) + + assert( + firstAmountInParam && lastAmountOutParam, + 'SwapRequestContent: All swaps must have a defined input and output amount parameter.', + ) + + const inputAmount = formatUnits( + firstAmountInParam?.value || '0', // should always be defined--`assert` above catches this case + inputCurrencyInfo?.currency.decimals || 18, + ) + const outputAmount = formatUnits( + lastAmountOutParam?.value || '0', // should always be defined--`assert` above catches this case + outputCurrencyInfo?.currency.decimals || 18, + ) + + // TODO (EXT-1083): add USDC values to SwapTransactionTypeInfo and display on notification toast + // Need the raw uint256 amounts, not the exact floating point amounts + const inputAmountRaw = formatUnits( + firstAmountInParam?.value || '0', // should always be defined--`assert` above catches this case + 0, + ) + const outputAmountRaw = formatUnits( + lastAmountOutParam?.value || '0', // should always be defined--`assert` above catches this case + 0, + ) + const transactionTypeInfo = getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw, + outputAmountRaw, + }) + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + + return ( + + ) +} + +// this is a special cased version of SwapRequestContent used for UniswapX swaps +export function UniswapXSwapRequestContent({ dappRequest }: { dappRequest: SignTypedDataRequest }): JSX.Element { + const parsedTypedData = JSON.parse(dappRequest.typedData) + const { chainId: domainChainId } = parsedTypedData?.domain || {} + const activeChain = toSupportedChainId(domainChainId) || UniverseChainId.Mainnet + + const { token: inputToken, amount: firstAmountInParam } = parsedTypedData?.message?.permitted || {} + const { token: outputToken, startAmount: lastAmountOutParam } = + parsedTypedData?.message?.witness?.baseOutputs[0] || {} + + const inputCurrencyInfo = useCurrencyInfo(buildCurrencyId(activeChain, inputToken)) + const outputCurrencyInfo = useCurrencyInfo(buildCurrencyId(activeChain, outputToken)) + + assert( + firstAmountInParam && lastAmountOutParam, + 'SwapRequestContent: All swaps must have a defined input and output amount parameter.', + ) + + const inputAmount = formatUnits( + firstAmountInParam || '0', // should always be defined--`assert` above catches this case + inputCurrencyInfo?.currency.decimals || 18, + ) + const outputAmount = formatUnits( + lastAmountOutParam || '0', // should always be defined--`assert` above catches this case + outputCurrencyInfo?.currency.decimals || 18, + ) + + return ( + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts new file mode 100644 index 00000000000..9b5861bb32e --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts @@ -0,0 +1,13 @@ +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 diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter.ts new file mode 100644 index 00000000000..d1a19f49f3b --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter.ts @@ -0,0 +1,110 @@ +import { SwapRouter } from '@uniswap/universal-router-sdk' +import { ethers } from 'ethers' +import { + ABI_DEFINITION, + CommandName, + CommandType, + Subparser, + UniversalRouterCall, + UniversalRouterCommand, +} from 'src/app/features/dappRequests/types/UniversalRouterTypes' + +export function parseCalldata(calldata: string): UniversalRouterCall { + const iface = SwapRouter.INTERFACE + const txDescription = iface.parseTransaction({ data: calldata }) + const { commands, inputs } = txDescription.args + // map hex string to bytes + const commandTypes: CommandType[] = [] + + // Start iterating from the third character to skip the "0x" prefix + for (let i = 2; i < commands.length; i += 2) { + // Get two characters from the hexString + const byte = commands.substr(i, 2) + + // Convert it to a number and add it to the values array + commandTypes.push(parseInt(byte, 16) as CommandType) + } + + const parsedCommands = commandTypes.map((commandType: CommandType, i: number): UniversalRouterCommand => { + const abiDef = ABI_DEFINITION[commandType] + const rawParams = ethers.utils.defaultAbiCoder.decode( + abiDef.map((command) => command.type), + inputs[i], + ) + const params = rawParams.map((param, j: number) => { + const fragment = abiDef[j] + if (fragment && fragment.subparser === Subparser.V3PathExactIn) { + return { + name: fragment.name, + value: parseV3PathExactIn(param), + } + } else if (fragment && fragment.subparser === Subparser.V3PathExactOut) { + return { + name: fragment.name, + value: parseV3PathExactOut(param), + } + } else { + return { + name: fragment?.name || '', + value: param, + } + } + }) + return { + commandName: CommandType[commandType] as CommandName, + commandType, + params, + } + }) + return { commands: parsedCommands } +} + +export type V3PathItem = { + readonly tokenIn: string + readonly tokenOut: string + readonly fee: number +} + +export function parseV3PathExactIn(path: string): readonly V3PathItem[] { + const strippedPath = path.replace('0x', '') + let tokenIn = ethers.utils.getAddress(strippedPath.substr(0, 40)) + let loc = 40 + const res = [] + while (loc < strippedPath.length) { + const feeAndTokenOut = strippedPath.substr(loc, 46) + const fee = parseInt(feeAndTokenOut.substr(0, 6), 16) + const tokenOut = ethers.utils.getAddress(feeAndTokenOut.substr(6, 40)) + + res.push({ + tokenIn, + tokenOut, + fee, + }) + tokenIn = tokenOut + loc += 46 + } + + return res +} + +export function parseV3PathExactOut(path: string): readonly V3PathItem[] { + const strippedPath = path.replace('0x', '') + let tokenIn = ethers.utils.getAddress(strippedPath.substr(strippedPath.length - 40, 40)) + let loc = strippedPath.length - 86 // 86 = (20 addr + 3 fee + 20 addr) * 2 (for hex characters) + const res = [] + while (loc >= 0) { + const feeAndTokenOut = strippedPath.substr(loc, 46) + const tokenOut = ethers.utils.getAddress(feeAndTokenOut.substr(0, 40)) + const fee = parseInt(feeAndTokenOut.substr(40, 6), 16) + + res.push({ + tokenIn, + tokenOut, + fee, + }) + tokenIn = tokenOut + loc -= 46 + } + + return res +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts new file mode 100644 index 00000000000..639f1aba029 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts @@ -0,0 +1,70 @@ +import { BigNumber, BigNumberish } from 'ethers' +import { formatUnits as formatUnitsEthers } from 'ethers/lib/utils' +import { + CONTRACT_BALANCE, + MAX_UINT160, + MAX_UINT256, +} from 'src/app/features/dappRequests/requestContent/EthSend/Swap/constants' +import { CommandType, UniversalRouterCall } from 'src/app/features/dappRequests/types/UniversalRouterTypes' + +export type MinimalToken = { + address: string + symbol: string + decimals: number +} +export type TokenDetails = { [address: string]: MinimalToken } + +export type V3TokenInPath = { + tokenIn: string + tokenOut: string + fee: number +} + +export function findErc20TokensToPrepare(urCall: UniversalRouterCall): string[] { + const tokenAddresses: string[] = [] + urCall.commands.forEach((command) => { + switch (command.commandType) { + case CommandType.V2SwapExactIn: + case CommandType.V2SwapExactOut: { + const tokensInPath: string[] | undefined = command.params.find((param) => param.name === 'path')?.value + tokensInPath?.forEach((tokenAddr: string) => tokenAddresses.push(tokenAddr)) + break + } + case CommandType.V3SwapExactIn: + case CommandType.V3SwapExactOut: { + const pools: V3TokenInPath[] | undefined = command.params.find((param) => param.name === 'path')?.value + pools?.forEach(({ tokenIn, tokenOut }) => { + tokenAddresses.push(tokenIn) + tokenAddresses.push(tokenOut) + }) + break + } + case CommandType.PayPortion: + case CommandType.SWEEP: + case CommandType.TRANSFER: { + const tokenAddr = command.params.find((param) => param.name === 'token')?.value + if (tokenAddr) { + tokenAddresses.push(tokenAddr) + } + break + } + } + }) + + return Array.from(new Set(tokenAddresses)) +} + +// Like ethers.formatUnits except it parses specific constants +export function formatUnits(amount: BigNumberish, units: number): string { + if (BigNumber.from(CONTRACT_BALANCE).eq(amount)) { + return 'CONTRACT_BALANCE' + } + if (BigNumber.from(MAX_UINT256).eq(amount)) { + return 'MAX_UINT256' + } + if (BigNumber.from(MAX_UINT160).eq(amount)) { + return 'MAX_UINT160' + } + + return formatUnitsEthers(amount, units) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/NetworkFooter.test.tsx b/apps/extension/src/app/features/dappRequests/requestContent/NetworkFooter.test.tsx new file mode 100644 index 00000000000..582a57f48e7 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/NetworkFooter.test.tsx @@ -0,0 +1,11 @@ +import { NetworksFooter } from 'src/app/features/dappRequests/requestContent/NetworksFooter' +import { cleanup, render } from 'src/test/test-utils' + +describe(NetworksFooter, () => { + it('renders without error', async () => { + const tree = render() + + expect(tree).toMatchSnapshot() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/requestContent/NetworksFooter.tsx b/apps/extension/src/app/features/dappRequests/requestContent/NetworksFooter.tsx new file mode 100644 index 00000000000..70130bc007f --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/NetworksFooter.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { NetworksInSeries } from 'uniswap/src/components/network/NetworkFilter' +import { WALLET_SUPPORTED_CHAIN_IDS } from 'uniswap/src/types/chains' + +export function NetworksFooter(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {t('extension.connection.networks')} + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx new file mode 100644 index 00000000000..5a59a2f7250 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx @@ -0,0 +1,124 @@ +import { ethers } from 'ethers' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { SignMessageRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Button, Flex, Text, Tooltip } from 'ui/src' +import { AlertTriangle, Code, StickyNoteTextSquare } from 'ui/src/components/icons' +import { containsNonPrintableChars } from 'utilities/src/primitives/string' + +enum ViewEncoding { + UTF8, + HEX, +} +interface PersonalSignRequestProps { + dappRequest: SignMessageRequest +} + +export function PersonalSignRequestContent({ dappRequest }: PersonalSignRequestProps): JSX.Element | null { + const { t } = useTranslation() + + const [viewEncoding, setViewEncoding] = useState(ViewEncoding.UTF8) + const toggleViewEncoding = (): void => + setViewEncoding(viewEncoding === ViewEncoding.UTF8 ? ViewEncoding.HEX : ViewEncoding.UTF8) + + const hexMessage = dappRequest.messageHex + const utf8Message = ethers.utils.toUtf8String(hexMessage) // Already validated in schema + + const containsUnrenderableCharacters = containsNonPrintableChars(utf8Message) + + const [isScrollable, setIsScrollable] = useState(false) + const messageRef = useRef(null) + useEffect(() => { + const checkScroll = (): void => { + if (!messageRef.current) { + return + } + setIsScrollable(messageRef.current.scrollHeight > messageRef.current.clientHeight) + } + + checkScroll() + window.addEventListener('resize', checkScroll) + + return () => window.removeEventListener('resize', checkScroll) + }, [setIsScrollable, viewEncoding]) + + return ( + + + + {viewEncoding === ViewEncoding.UTF8 ? utf8Message : hexMessage} + + + + + + + + ) + })} + + + + + + ) +} diff --git a/apps/extension/src/app/features/home/TokenBalanceList.tsx b/apps/extension/src/app/features/home/TokenBalanceList.tsx new file mode 100644 index 00000000000..eeddbb39aa9 --- /dev/null +++ b/apps/extension/src/app/features/home/TokenBalanceList.tsx @@ -0,0 +1,192 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { PropsWithChildren, memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' +import { AppRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { AnimatePresence, ContextMenu, Flex, Loader } from 'ui/src' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { isNonPollingRequestInFlight } from 'wallet/src/data/utils' +import { HiddenTokensRow } from 'wallet/src/features/portfolio/HiddenTokensRow' +import { PortfolioEmptyState } from 'wallet/src/features/portfolio/PortfolioEmptyState' +import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem' +import { + HIDDEN_TOKEN_BALANCES_ROW, + TokenBalanceListContextProvider, + TokenBalanceListRow, + useTokenBalanceListContext, +} from 'wallet/src/features/portfolio/TokenBalanceListContext' +import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu' + +const MIN_CONTEXT_MENU_WIDTH = 200 + +type TokenBalanceListProps = { + owner: Address +} + +export const TokenBalanceList = memo(function _TokenBalanceList({ owner }: TokenBalanceListProps): JSX.Element { + const { navigateToTokenDetails } = useWalletNavigation() + + const onPressToken = (currencyId: string): void => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.TokenItem, + section: SectionName.HomeTokensTab, + }) + navigateToTokenDetails(currencyId) + } + + return ( + + + + + + ) +}) + +export function TokenBalanceListInner(): JSX.Element { + const { t } = useTranslation() + + const { rows, balancesById, networkStatus, refetch, hiddenTokensExpanded } = useTokenBalanceListContext() + const onPressBuy = useInterfaceBuyNavigator(ElementName.EmptyStateBuy) + + const visible: string[] = [] + const hidden: string[] = [] + + let isHidden = false + for (const row of rows) { + const target = isHidden ? hidden : visible + target.push(row) + // do this after pushing so we keep our Hidden header row in the visible section + // so users can see it when closed and re-open it + if (row === HIDDEN_TOKEN_BALANCES_ROW) { + isHidden = true + } + } + + const onPressReceive = (): void => { + navigate(AppRoutes.Receive) + } + + return ( + + {!balancesById ? ( + isNonPollingRequestInFlight(networkStatus) ? ( + + + + ) : ( + + refetch?.()} + /> + + ) + ) : rows.length === 0 ? ( + + ) : ( + <> + + + {hiddenTokensExpanded && } + + + )} + + ) +} + +const TokenBalanceItems = ({ animated, rows }: { animated?: boolean; rows: string[] }): JSX.Element => { + return ( + + {rows?.map((balance: TokenBalanceListRow) => { + return + })} + + ) +} + +const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item: TokenBalanceListRow }) { + const { + balancesById, + hiddenTokensCount, + hiddenTokensExpanded, + isWarmLoading, + onPressToken, + setHiddenTokensExpanded, + } = useTokenBalanceListContext() + + if (item === HIDDEN_TOKEN_BALANCES_ROW) { + return ( + { + setHiddenTokensExpanded(!hiddenTokensExpanded) + }} + /> + ) + } + + const portfolioBalance = balancesById?.[item] + + if (!portfolioBalance) { + // This can happen when the view is out of focus and the user sells/sends 100% of a token's balance. + // In that case, the token is removed from the `balancesById` object, but the FlatList is still using the cached array of IDs until the view comes back into focus. + // As soon as the view comes back into focus, the FlatList will re-render with the latest data, so users won't really see this Skeleton for more than a few milliseconds when this happens. + return ( + + + + ) + } + + return ( + + + + ) +}) + +function TokenContextMenu({ + children, + portfolioBalance, +}: PropsWithChildren<{ + portfolioBalance: PortfolioBalance +}>): JSX.Element { + const contextMenu = useTokenContextMenu({ + currencyId: portfolioBalance.currencyInfo.currencyId, + isBlocked: portfolioBalance.currencyInfo.safetyLevel === SafetyLevel.Blocked, + tokenSymbolForNotification: portfolioBalance?.currencyInfo?.currency?.symbol, + portfolioBalance, + }) + + const menuOptions = contextMenu.menuActions.map((action) => ({ + label: action.title, + onPress: action.onPress, + Icon: action.Icon, + destructive: action.destructive, + disabled: action.disabled, + })) + + const itemId = `${portfolioBalance.currencyInfo.currencyId}-${portfolioBalance.isHidden}` + + return ( + + {children} + + ) +} diff --git a/apps/extension/src/app/features/lockScreen/Locked.tsx b/apps/extension/src/app/features/lockScreen/Locked.tsx new file mode 100644 index 00000000000..cbdf544e654 --- /dev/null +++ b/apps/extension/src/app/features/lockScreen/Locked.tsx @@ -0,0 +1,251 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Input } from 'src/app/components/Input' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { BottomModalProps, InfoModal } from 'src/app/components/modal/InfoModal' +import { useSagaStatus } from 'src/app/hooks/useSagaStatus' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { Button, Flex, InputProps, Text, TouchableArea } from 'ui/src' +import { AlertTriangle, Lock } from 'ui/src/components/icons' +import { spacing, zIndices } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' +import { authActions, authSagaName } from 'wallet/src/features/auth/saga' +import { AuthActionType, AuthSagaError } from 'wallet/src/features/auth/types' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { SagaStatus } from 'wallet/src/utils/saga' + +export function usePasswordInput(defaultValue = ''): Pick & { value: string } { + const [value, setValue] = useState(defaultValue) + + const onChangeText: InputProps['onChangeText'] = (newValue): void => { + setValue(newValue) + } + + return { + value, + disabled: !value, + onChangeText, + } +} + +enum ForgotPasswordModalStep { + Initial, + Speedbump, +} + +const CONTAINER_PADDING_TOP_MIN = 50 +const CONTAINER_PADDING_TOP_MAX = 220 +const BACKGROUND_CIRCLE_INNER_SIZE = 140 +const BACKGROUND_CIRCLE_OUTER_SIZE = 250 + +export function Locked(): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const { value: enteredPassword, onChangeText: onChangePasswordText } = usePasswordInput() + const associatedAccounts = useSignerAccounts() + + const onChangeText = useCallback( + (text: string) => { + if (onChangePasswordText) { + onChangePasswordText?.(text) + } + }, + [onChangePasswordText], + ) + + const { status, error } = useSagaStatus(authSagaName, undefined, false) + + const onPress = async (): Promise => { + await dispatch( + authActions.trigger({ + type: AuthActionType.Unlock, + password: enteredPassword, + }), + ) + } + + const [forgotPasswordModalOpen, setForgotPasswordModalOpen] = useState(false) + const [modalStep, setModalStep] = useState(ForgotPasswordModalStep.Initial) + + const openRecoveryTab = (): Promise => + focusOrCreateOnboardingTab(`${TopLevelRoutes.Onboarding}/${OnboardingRoutes.Reset}`) + + const onStartResettingWallet = async (): Promise => { + const currAccount = associatedAccounts[0] + + if (currAccount?.mnemonicId) { + await Keyring.removeMnemonic(currAccount?.mnemonicId) + } + await Keyring.removePassword() + + // We open the recovery tab before removing the accounts so that the proper reset route is loaded. + // Otherwise, the main onboarding route is automatically loaded when accounts are all removed, and then a duplicate recovery tab is opened. + // The standard onboarding open logic triggers but doesn't update the path because the generic one doesn't have a path specified. + await openRecoveryTab() + + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: associatedAccounts, + }), + ) + } + + const isIncorrectPassword = status === SagaStatus.Failure && error === AuthSagaError.InvalidPassword + + const inputRef = useRef(null) + const [hideInput, setHideInput] = useState(true) + const toggleHideInput = (): void => setHideInput(!hideInput) + + useLayoutEffect(() => { + if (isIncorrectPassword) { + inputRef.current?.focus() + } + }, [isIncorrectPassword]) + + const modalProps: Record = { + [ForgotPasswordModalStep.Initial]: { + buttonText: t('extension.lock.button.reset'), + description: t('extension.lock.password.reset.initial.description'), + linkText: t('extension.lock.password.reset.initial.help'), + linkUrl: uniswapUrls.helpArticleUrls.recoveryPhraseHowToFind, + icon: ( + + + + ), + isOpen: forgotPasswordModalOpen, + name: ModalName.ForgotPassword, + onButtonPress: (): void => setModalStep(ForgotPasswordModalStep.Speedbump), + title: t('extension.lock.password.reset.initial.title'), + }, + [ForgotPasswordModalStep.Speedbump]: { + buttonText: t('common.button.continue'), + description: t('extension.lock.password.reset.speedbump.description'), + linkText: t('extension.lock.password.reset.speedbump.help'), + linkUrl: uniswapUrls.helpArticleUrls.recoveryPhraseForgotten, + icon: ( + + + + ), + isOpen: forgotPasswordModalOpen, + name: ModalName.ForgotPassword, + onButtonPress: onStartResettingWallet, + title: t('extension.lock.password.reset.speedbump.title'), + }, + } + + const [inputHeight, setInputHeight] = useState(0) + const [containerPaddingTop, setContainerPaddingTop] = useState(CONTAINER_PADDING_TOP_MAX) + const [availableHeight, setAvailableHeight] = useState(0) + + useLayoutEffect(() => { + if (availableHeight && inputHeight) { + const containerHeight = inputHeight + spacing.spacing32 + const newPaddingTop = Math.min( + Math.max(CONTAINER_PADDING_TOP_MIN, availableHeight - containerHeight), + CONTAINER_PADDING_TOP_MAX, + ) + + setContainerPaddingTop(newPaddingTop) + } + }, [availableHeight, inputHeight]) + + return ( + <> + + setAvailableHeight(e.nativeEvent.layout.height)}> + + + + setInputHeight(e.nativeEvent.layout.height)} + > + + + {t('extension.lock.title')} + + + + {t('extension.lock.subtitle')} + + + + + + + + + {t('extension.lock.password.error')} + + + + + + + + + + + setForgotPasswordModalOpen(true)} + > + {t('extension.lock.button.forgot')} + + + + + + { + setModalStep(ForgotPasswordModalStep.Initial) + setForgotPasswordModalOpen(false) + }} + /> + + ) +} diff --git a/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx b/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx new file mode 100644 index 00000000000..b30a985493a --- /dev/null +++ b/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx @@ -0,0 +1,36 @@ +import { useSelector } from 'react-redux' +import { DappConnectedNotification } from 'wallet/src/features/notifications/components/DappConnectedNotification' +import { DappDisconnectedNotification } from 'wallet/src/features/notifications/components/DappDisconnectedNotification' +import { NotSupportedNetworkNotification } from 'wallet/src/features/notifications/components/NotSupportedNetworkNotification' +import { PasswordChangedNotification } from 'wallet/src/features/notifications/components/PasswordChangedNotification' +import { SharedNotificationToastRouter } from 'wallet/src/features/notifications/components/SharedNotificationToastRouter' +import { selectActiveAccountNotifications } from 'wallet/src/features/notifications/selectors' +import { AppNotification, AppNotificationType } from 'wallet/src/features/notifications/types' + +export function NotificationToastWrapper(): JSX.Element | null { + const notifications = useSelector(selectActiveAccountNotifications) + const notification = notifications?.[0] + + if (!notification) { + return null + } + + return +} + +function NotificationToastRouter({ notification }: { notification: AppNotification }): JSX.Element | null { + // Insert Extension-only notifications here. + // Shared wallet notifications should go in SharedNotificationToastRouter. + switch (notification.type) { + case AppNotificationType.DappConnected: + return + case AppNotificationType.NotSupportedNetwork: + return + case AppNotificationType.DappDisconnected: + return + case AppNotificationType.PasswordChanged: + return + } + + return +} diff --git a/apps/extension/src/app/features/onboarding/Complete.tsx b/apps/extension/src/app/features/onboarding/Complete.tsx new file mode 100644 index 00000000000..dc13162a5e3 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Complete.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' +import { MainContentWrapper } from 'src/app/features/onboarding/intro/MainContentWrapper' +import { useOpeningKeyboardShortCut } from 'src/app/hooks/useOpeningKeyboardShortCut' +import { getCurrentTabAndWindowId } from 'src/app/navigation/utils' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { openSidePanel } from 'src/background/utils/chromeSidePanelUtils' +import { terminateStoreSynchronization } from 'src/store/storeSynchronization' +import { Button, Flex, Image, Text } from 'ui/src' +import { UNISWAP_LOGO } from 'ui/src/assets' +import { RightArrow } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useFinishOnboarding } from 'wallet/src/features/onboarding/OnboardingContext' + +export function Complete({ flow }: { flow?: ExtensionOnboardingFlow }): JSX.Element { + const { t } = useTranslation() + + const [openedSideBar, setOpenedSideBar] = useState(false) + + // Activates onboarding accounts on component mount + useFinishOnboarding(terminateStoreSynchronization, flow) + + useEffect(() => { + const onSidebarOpenedListener = onboardingMessageChannel.addMessageListener( + OnboardingMessageType.SidebarOpened, + (_message) => { + setOpenedSideBar(true) + }, + ) + return () => { + onboardingMessageChannel.removeMessageListener(OnboardingMessageType.SidebarOpened, onSidebarOpenedListener) + } + }, []) + + const handleOpenWebApp = async (): Promise => { + window.location.href = uniswapUrls.webInterfaceSwapUrl + } + + const handleOpenSidebar = async (): Promise => { + try { + const { tabId, windowId } = await getCurrentTabAndWindowId() + await openSidePanel(tabId, windowId) + } catch (error) { + logger.error(error, { + tags: { file: 'onboarding/Complete.tsx', function: 'handleOpenSidebar' }, + }) + } + } + + const keys = useOpeningKeyboardShortCut(openedSideBar) + + return ( + + + + + + + {t('onboarding.complete.title')} + + + {t('onboarding.complete.description')} + + + + {keys.map((key) => ( + + ))} + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx b/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx new file mode 100644 index 00000000000..04692bdea3a --- /dev/null +++ b/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx @@ -0,0 +1,33 @@ +import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' +import { State } from 'src/app/hooks/useOpeningKeyboardShortCut' +import { cleanup, render, screen } from 'src/test/test-utils' + +describe('KeyboardKey Component', () => { + it('renders correctly with state KeyUp', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('renders correctly with state KeyDown', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('renders correctly with state Highlighted', () => { + const { container } = render() + expect(container).toMatchSnapshot() + cleanup() + }) + + it('displays the command symbol for Meta key on macOS', () => { + render() + expect(screen.getByText('⌘')).toBeDefined() + cleanup() + }) + + it('displays the correct title for other keys', () => { + render() + expect(screen.getByText('U')).toBeDefined() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/onboarding/KeyboardKey.tsx b/apps/extension/src/app/features/onboarding/KeyboardKey.tsx new file mode 100644 index 00000000000..e77deaba5bf --- /dev/null +++ b/apps/extension/src/app/features/onboarding/KeyboardKey.tsx @@ -0,0 +1,40 @@ +import { Flex, Text } from 'ui/src' +const SHADOW_OFFSET = { width: 0, height: 7 } +const MAC_OS_COMMAND_SYMBOL = '⌘' +const KEY_HEIGHT = 70 + +enum State { + KeyUp, + KeyDown, + Highlighted, +} + +export interface KeyboardKeyProps { + title: string + px: React.ComponentProps['px'] + fontSize: React.ComponentProps['fontSize'] + state: State +} + +export function KeyboardKey({ title, px, fontSize, state }: KeyboardKeyProps): JSX.Element { + return ( + + + {title === 'Meta' ? MAC_OS_COMMAND_SYMBOL : title} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx b/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx new file mode 100644 index 00000000000..3f061c69ef0 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx @@ -0,0 +1,34 @@ +import { Flex, styled } from 'ui/src' + +const SINGLE_PANE_DURATION = 200 + +// TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding +// if exitBeforeEnter is set in the AnimatePresence we are +// running two 200ms animations sequentially - first to exit, then enter so we +// double this constant. if we change that, needs to change here +export const ONBOARDING_PANE_TRANSITION_DURATION = SINGLE_PANE_DURATION * 2 +export const ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY = ONBOARDING_PANE_TRANSITION_DURATION + 200 + +export const OnboardingPaneAnimatedContents = styled(Flex, { + animation: `${SINGLE_PANE_DURATION}ms`, + width: '100%', + + zIndex: 1, + x: 0, + opacity: 1, + mx: 'auto', + + variants: { + // note you can use _towards for implementing animations based on the direction! + going: (_towards: 'forward' | 'backward') => ({ + enterStyle: { + opacity: 0, + zIndex: 1, + }, + exitStyle: { + zIndex: 0, + opacity: 0, + }, + }), + } as const, +}) diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx new file mode 100644 index 00000000000..2f012296425 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx @@ -0,0 +1,20 @@ +import { useContext, useLayoutEffect } from 'react' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { OnboardingStepsContext } from 'src/app/features/onboarding/OnboardingStepsContext' + +export function OnboardingScreen(props: OnboardingScreenProps): null { + const context = useContext(OnboardingStepsContext) + + useLayoutEffect(() => { + if (!context) { + return + } + context.setOnboardingScreen(props) + return () => { + context.clearOnboardingScreen(props) + } + }, [context, props]) + + // we hoist it up, see OnboardingSteps + OnboardingScreenFrame + return null +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx new file mode 100644 index 00000000000..e8bbd4a98c1 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx @@ -0,0 +1,84 @@ +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { Button, Flex, Text, TouchableArea } from 'ui/src' +import { BackArrow } from 'ui/src/components/icons' +import i18n from 'uniswap/src/i18n/i18n' + +export function OnboardingScreenFrame({ + Icon, + children, + nextButtonEnabled, + nextButtonText = i18n.t('common.button.next'), + nextButtonTheme = 'primary', + onBack, + onSubmit, + onSkip, + subtitle, + title, + warningSubtitle, +}: Partial): JSX.Element { + if (!title) { + return <>{children} + } + + return ( + <> + + {onBack && ( + + + + )} + {onSkip && ( + + + Skip + + + )} + {Icon} + + + {title} + + + + {subtitle} + + {warningSubtitle && ( + + {warningSubtitle} + + )} + + + + + {children} + + + {Boolean(onSubmit) && nextButtonText && ( + + )} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx new file mode 100644 index 00000000000..af3ad90dfb8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx @@ -0,0 +1,17 @@ +import { ThemeNames } from 'ui/src/theme' + +export type OnboardingScreenProps = { + Icon?: JSX.Element + children?: JSX.Element + nextButtonEnabled?: boolean + nextButtonText?: string + nextButtonTheme?: ThemeNames + onBack?: () => void + onSubmit?: () => void + onSkip?: () => void + subtitle?: string + title: string | JSX.Element + warningSubtitle?: string + outsideContent?: JSX.Element + belowFrameContent?: JSX.Element +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx new file mode 100644 index 00000000000..54b278a108f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx @@ -0,0 +1,304 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSelector } from 'react-redux' +import { OnboardingPaneAnimatedContents } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { OnboardingScreenFrame } from 'src/app/features/onboarding/OnboardingScreenFrame' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { + OnboardingStepsContext, + OnboardingStepsContextState, + Step, +} from 'src/app/features/onboarding/OnboardingStepsContext' +import { ONBOARDING_CONTENT_WIDTH, ONBOARDING_INITIAL_FRAME_HEIGHT } from 'src/app/features/onboarding/utils' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { AnimatePresence, Flex, styled, useWindowDimensions } from 'ui/src' + +export * from './OnboardingStepsContext' + +type ComponentByStep = { [key in Step]?: JSX.Element } +type MaybeOnboardingProps = OnboardingScreenProps | null + +/** + * In this file we're doing some weird stuff because we want to keep a nice API + * for onboarding screens but also allow animating them, while still working + * with react router. + * + * AnimatePresence wants to be able to swap out old for new, but react router + * wants to handle that as well + * + * So we have to hoist the props of up to here. + * + * But doing that could cause a re-render loop if the child component isn't + * careful to memoize things. So, we've implemented a little pattern here to + * avoid that - instead of re-rendering the entire OnboardingStepsProvider + * whenever a child re-renders, we instead have a simple emitter/listener we + * trigger (onboardingScreenListen) and we the re-render the contents in a + * sub-component OnboardingScreenDisplay. This way OnboardingScreenDisplay can + * re-render as much as it wants and it doesn't cause the child to re-render, + * avoiding loops! + */ + +let currentOnboardingScreen: MaybeOnboardingProps = null +const onboardingScreenListen = new Set<(step: Step, val: MaybeOnboardingProps) => void>() + +let clearScreenTimeout: NodeJS.Timeout + +export function OnboardingStepsProvider({ + steps, + isResetting = false, + ContainerComponent = React.Fragment, +}: { + steps: ComponentByStep + isResetting?: boolean + ContainerComponent?: React.ComponentType +}): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + const wasAlreadyOnboardedWhenPageLoaded = useRef(isOnboarded) + + useEffect(() => { + if (!isResetting && wasAlreadyOnboardedWhenPageLoaded.current) { + // Redirect to the intro screen screen if user is already onboarded. + // We only want to redirect when the page is first loaded but not immediately after the user completes onboarding. + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + }, [isOnboarded, isResetting]) + + const initialStep = Object.keys(steps)[0] as Step + + if (!initialStep) { + throw new Error('`steps` must have at least one `step`') + } + + const [{ step, going, onboardingScreen }, setState] = useState<{ + onboardingScreen?: MaybeOnboardingProps + step: Step + going: 'forward' | 'backward' + }>({ + step: initialStep, + going: 'forward', + }) + + const getCurrentStep = useRef(step) + getCurrentStep.current = step + + const setStep = useCallback((nextStep: Step) => { + setState((prev) => ({ ...prev, step: nextStep })) + }, []) + + const setOnboardingScreen = useCallback((next: OnboardingScreenProps) => { + clearTimeout(clearScreenTimeout) + setState((prev) => { + // we are only updating onboardingScreen here once per unique title so + // the state in this component is accurate, but subsequent updates go + // through the emitter + if (onboardingScreenKey(prev?.onboardingScreen) !== onboardingScreenKey(next)) { + return { + ...prev, + onboardingScreen: next, + } + } + return prev + }) + onboardingScreenListen.forEach((cb) => cb(getCurrentStep.current, next)) + currentOnboardingScreen = next + }, []) + + const clearOnboardingScreen = useCallback((next: OnboardingScreenProps) => { + // delay clear so the next screen can beat clearing the old one to avoid flickering + clearScreenTimeout = setTimeout(() => { + setState((prev) => { + if (prev.onboardingScreen && onboardingScreenKey(prev.onboardingScreen) === onboardingScreenKey(next)) { + return { + ...prev, + onboardingScreen: null, + } + } + return prev + }) + }) + }, []) + + const onboardingScreenKey = (props?: MaybeOnboardingProps): string => { + return `${props?.title}${props?.subtitle}${Object.keys(props || {}).join('')}` + } + + const goToNextStep = useCallback(() => { + const stepIndex = Object.keys(steps).indexOf(step) + const nextStep = Object.keys(steps)[stepIndex + 1] as Step + + if (!nextStep) { + throw new Error('No next step') + } + + setState((prev) => ({ + ...prev, + step: nextStep, + going: 'forward', + })) + }, [step, steps]) + + const goToPreviousStep = useCallback(() => { + const stepIndex = Object.keys(steps).indexOf(step) + const previousStep = Object.keys(steps)[stepIndex - 1] as Step + + if (!previousStep) { + throw new Error('No previous step') + } + + setState((prev) => ({ + ...prev, + step: previousStep, + going: 'backward', + })) + }, [step, steps]) + + const state = useMemo((): OnboardingStepsContextState => { + return { + step, + setStep, + goToNextStep, + setOnboardingScreen, + clearOnboardingScreen, + goToPreviousStep, + isResetting, + going, + } + }, [step, setStep, goToNextStep, setOnboardingScreen, clearOnboardingScreen, goToPreviousStep, isResetting, going]) + + const stepContents = steps[step] + const [frameHeight, setFrameHeight] = useState(ONBOARDING_INITIAL_FRAME_HEIGHT) + const windowDimensions = useWindowDimensions() + const modalY = windowDimensions.height / 2 - frameHeight / 2 + const hasBelowFrameContent = Boolean(onboardingScreen?.belowFrameContent) + const [belowFrameHeight, setBelowFrameHeight] = useState(-1) + const y = + modalY + + // ensure vertically centered when belowFrameContent exists + (hasBelowFrameContent + ? -(belowFrameHeight === -1 + ? // estimate the content height before measurement + 63 + : belowFrameHeight) + 30 + : 0) + + if (!stepContents) { + throw new Error(`Unknown step: ${step}`) + } + + return ( + + + {!onboardingScreen && <>{stepContents}} + + {/* render the contents from step here */} + {onboardingScreen && ( + <> + {/* render actual screen contents "offscreen", we use context and put it on onboardingScreen */} +
{stepContents}
+ { + setFrameHeight(e.nativeEvent.layout.height) + }} + > + + + {/** + * animate the inner contents of the onboarding steps modal + * exitBeforeEnter because we are keeping things simpler and having the inner contents + * not be absolutely positioned, which would let us do overlapping animations but we'd have + * to measure dimensions and do some delicate state management around that. + */} + + {/* note: the exitBeforeEnter here affects the constant ONBOARDING_PANE_TRANSITION_DURATION in OnboardingPaneAnimatedContents.tsx */} + + + + + + + + {hasBelowFrameContent && ( + setBelowFrameHeight(e.nativeEvent.layout.height)} + > + {onboardingScreen?.belowFrameContent} + + )} + + + )} + + {onboardingScreen?.outsideContent || null} +
+
+ ) +} + +const OnboardingScreenDisplay = memo(function OnboardingScreenDisplay(props: { step: Step }): JSX.Element { + const [state, setState] = useState(currentOnboardingScreen) + + useEffect(() => { + const handler = (step: Step, next: MaybeOnboardingProps): void => { + if (step === props.step) { + setState(next) + } + } + + onboardingScreenListen.add(handler) + return () => { + onboardingScreenListen.delete(handler) + } + }, [props.step]) + + return +}) + +// containing frame just for positioning +const Frame = styled(Flex, { + position: 'absolute', + top: 0, + left: '50%', + x: -ONBOARDING_CONTENT_WIDTH * 0.5, + alignItems: 'center', + justifyContent: 'center', + width: ONBOARDING_CONTENT_WIDTH, +}) + +// separate frame background so we can animate +const FrameBackground = styled(Flex, { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + backgroundColor: '$surface1', + borderColor: '$surface3', + borderRadius: '$rounded32', + borderWidth: '$spacing1', + shadowRadius: 4, + shadowColor: '$shadowColor', + shadowOffset: { + height: 2, + width: 0, + }, + shadowOpacity: 0.25, +}) + +// inner frame to prevent overflow of outer frame +const FrameInner = styled(Flex, { + height: '100%', + overflow: 'hidden', + width: '100%', + borderRadius: '$rounded32', + gap: '$spacing12', + pb: '$spacing24', + pt: '$spacing24', + px: '$spacing24', +}) diff --git a/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx b/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx new file mode 100644 index 00000000000..e1bba51637f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext } from 'react' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' + +export enum CreateOnboardingSteps { + Password = 'password', + ViewMnemonic = 'mnemonic', + TestMnemonic = 'testMnemonic', + Naming = 'naming', + Complete = 'complete', +} + +export enum ImportOnboardingSteps { + Mnemonic = 'mnemonic', + Password = 'password', + Select = 'select', + Backup = 'backup', + Complete = 'complete', +} + +export enum ResetSteps { + Mnemonic = 'mnemonic', + Password = 'password', + Complete = 'complete', + Select = 'select', +} + +export enum ScanOnboardingSteps { + Password = 'password', + Scan = 'scan', + OTP = 'otp', + Select = 'select', + Complete = 'complete', +} + +export type Step = CreateOnboardingSteps | ImportOnboardingSteps | ResetSteps | ScanOnboardingSteps + +export type OnboardingStepsContextState = { + step: Step + going?: 'forward' | 'backward' + setStep: (step: Step) => void + setOnboardingScreen: (screen: OnboardingScreenProps) => void + clearOnboardingScreen: (screen: OnboardingScreenProps) => void + goToNextStep: () => void + goToPreviousStep: () => void + isResetting: boolean +} + +export const OnboardingStepsContext = createContext(undefined) + +export function useOnboardingSteps(): OnboardingStepsContextState { + const onboardingStepsContext = useContext(OnboardingStepsContext) + + if (onboardingStepsContext === undefined) { + throw new Error('`useOnboardingSteps` must be used inside of `OnboardingStepsProvider`') + } + + return onboardingStepsContext +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx b/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx new file mode 100644 index 00000000000..cb6684f18a6 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import { useDispatch } from 'react-redux' +import { Outlet } from 'react-router-dom' +import { StorageWarningModal } from 'src/app/features/warnings/StorageWarningModal' +import { ONBOARDING_BACKGROUND_DARK, ONBOARDING_BACKGROUND_LIGHT } from 'src/assets' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { Flex, Image, useIsDarkMode } from 'ui/src' +import { syncAppWithDeviceLanguage } from 'wallet/src/features/language/slice' +import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext' + +export function OnboardingWrapper(): JSX.Element { + const isDarkMode = useIsDarkMode() + const [isHighlighted, setIsHighlighted] = useState(false) + const dispatch = useDispatch() + + useEffect(() => { + dispatch(syncAppWithDeviceLanguage()) + }, [dispatch]) + + useEffect(() => { + return onboardingMessageChannel.addMessageListener(OnboardingMessageType.HighlightOnboardingTab, (_message) => { + // When the onboarding tab regains focus, we do a quick background change to bring attention to it. + // Otherwise, the user might not notice that the tab is now active, specially if the tab is on a different monitor. + setIsHighlighted(true) + setTimeout(() => setIsHighlighted(false), 200) + }) + }, []) + + return ( + + + + {/* TODO: Update this to use the new background asset with varying blur level */} + {!isHighlighted && ( + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Password.tsx b/apps/extension/src/app/features/onboarding/Password.tsx new file mode 100644 index 00000000000..9eee86e24b8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Password.tsx @@ -0,0 +1,110 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { PADDING_STRENGTH_INDICATOR, PasswordInput } from 'src/app/components/PasswordInput' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Square, Text } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { usePasswordForm } from 'wallet/src/utils/password' + +export function Password({ + flow, + onComplete, + onBack, +}: { + flow: ExtensionOnboardingFlow + onComplete: (password: string) => Promise + onBack?: () => void +}): JSX.Element { + const { t } = useTranslation() + const { isResetting } = useOnboardingSteps() + const { resetOnboardingContextData } = useOnboardingContext() + + const { + enableNext, + hideInput, + debouncedPasswordStrength, + password, + onPasswordBlur, + onChangePassword, + confirmPassword, + onChangeConfirmPassword, + setHideInput, + errorText, + checkSubmit, + } = usePasswordForm() + + const onSubmit = useCallback(async () => { + if (checkSubmit()) { + await onComplete(password) + } + }, [onComplete, password, checkSubmit]) + + const handleBack = useCallback(() => { + // reset the pending mnemonic when going back from password screen + // to avoid having them in the context when coming back to either screen + resetOnboardingContextData() + if (onBack) { + onBack() + } else { + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + }, [onBack, resetOnboardingContextData]) + + return ( + + + + + } + nextButtonEnabled={enableNext} + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.extension.password.subtitle')} + title={ + isResetting + ? t('onboarding.extension.password.title.reset') + : t('onboarding.extension.password.title.default') + } + onBack={handleBack} + onSubmit={onSubmit} + > + + + + + {errorText || 'Placeholder text'} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/PasswordImport.tsx b/apps/extension/src/app/features/onboarding/PasswordImport.tsx new file mode 100644 index 00000000000..256bd937f57 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/PasswordImport.tsx @@ -0,0 +1,43 @@ +import { useCallback } from 'react' +import { ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Password } from 'src/app/features/onboarding/Password' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { sleep } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { validateMnemonic } from 'wallet/src/utils/mnemonics' + +export function PasswordImport({ + flow, + allowBack = true, +}: { + flow: ExtensionOnboardingFlow + allowBack?: boolean +}): JSX.Element { + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { getOnboardingAccountMnemonicString, generateImportedAccountsByMnemonic } = useOnboardingContext() + const mnemonicString = getOnboardingAccountMnemonicString() + + const onSubmit = useCallback( + async (password: string) => { + const { validMnemonic } = validateMnemonic(mnemonicString) + + if (!validMnemonic) { + throw new Error('Mnemonic are invalid on PasswordImport screen') + } + + goToNextStep() + + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // start running the validation after going to next step since they clog the main thread with work + // plus just a bit of extra leeway since animations can take just a tad extra to finish + await sleep(ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY) + await generateImportedAccountsByMnemonic(validMnemonic, password, BackupType.Manual) + }, + [mnemonicString, goToNextStep, generateImportedAccountsByMnemonic], + ) + + return +} diff --git a/apps/extension/src/app/features/onboarding/PinReminder.tsx b/apps/extension/src/app/features/onboarding/PinReminder.tsx new file mode 100644 index 00000000000..8e54c88d180 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/PinReminder.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { Pin, X } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' + +const POPUP_WIDTH = 240 +const POPUP_OFFSET = 4 +const POPUP_SHADOW_RADIUS = 8 + +export function PinReminder({ + onClose, + style = 'popup', +}: { + onClose?: () => void + style?: 'inline' | 'popup' +}): JSX.Element { + const { t } = useTranslation() + + return ( + + + + + {t('onboarding.complete.pin.title')} + + + {t('onboarding.complete.pin.description')} + + + {onClose && ( + + + + )} + + ) +} + +const styles = { + inline: { + position: 'relative' as const, + width: '100%', + }, + popup: { + position: 'absolute' as const, + right: POPUP_OFFSET, + top: POPUP_OFFSET, + width: POPUP_WIDTH, + zIndex: zIndices.popover, + }, +} diff --git a/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx b/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx new file mode 100644 index 00000000000..a3bdb179fb1 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Text, TouchableArea } from 'ui/src' +import { ScanQr } from 'ui/src/components/icons' + +export function SyncFromPhoneButton({ + isResetting, + fill, +}: { + isResetting?: boolean + fill?: boolean +}): JSX.Element | null { + const { t } = useTranslation() + + return ( + + navigate(`/${TopLevelRoutes.Onboarding}/${isResetting ? OnboardingRoutes.ResetScan : OnboardingRoutes.Scan}`) + } + > + + + + {t('onboarding.intro.mobileScan.button')} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Terms.tsx b/apps/extension/src/app/features/onboarding/Terms.tsx new file mode 100644 index 00000000000..67c1eb12749 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Terms.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren } from 'react' +import { Trans } from 'react-i18next' +import { Link, LinkProps } from 'react-router-dom' +import { Text } from 'ui/src' +import { uniswapUrls } from 'uniswap/src/constants/urls' + +export function Terms(): JSX.Element { + return ( + + , + highlightPrivacy: , + }} + i18nKey="onboarding.termsOfService" + /> + + ) +} + +function LinkWrapper(props: PropsWithChildren): JSX.Element { + const { children, ...rest } = props + return ( + + + {children} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/UniconWithLockIcon.tsx b/apps/extension/src/app/features/onboarding/UniconWithLockIcon.tsx new file mode 100644 index 00000000000..b5784b9f26e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/UniconWithLockIcon.tsx @@ -0,0 +1,21 @@ +import { Flex, Unicon } from 'ui/src' +import { FileListLock } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +export function UniconWithLockIcon({ address }: { address: Address }): JSX.Element { + return ( + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap new file mode 100644 index 00000000000..3a1d15e744f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KeyboardKey Component renders correctly with state Highlighted 1`] = ` +
+ + +
+ + Shift + +
+
+
+
+`; + +exports[`KeyboardKey Component renders correctly with state KeyDown 1`] = ` +
+ + +
+ + Shift + +
+
+
+
+`; + +exports[`KeyboardKey Component renders correctly with state KeyUp 1`] = ` +
+ + +
+ + Shift + +
+
+
+
+`; diff --git a/apps/extension/src/app/features/onboarding/alerts/selectors.ts b/apps/extension/src/app/features/onboarding/alerts/selectors.ts new file mode 100644 index 00000000000..c5ed7e33165 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/alerts/selectors.ts @@ -0,0 +1,6 @@ +import { AlertsState } from 'src/app/features/onboarding/alerts/slice' +import { ExtensionState } from 'src/store/extensionReducer' + +export function selectAlertsState(name: T): (state: ExtensionState) => AlertsState[T] { + return (state) => state.alerts[name] +} diff --git a/apps/extension/src/app/features/onboarding/alerts/slice.ts b/apps/extension/src/app/features/onboarding/alerts/slice.ts new file mode 100644 index 00000000000..7c65df953eb --- /dev/null +++ b/apps/extension/src/app/features/onboarding/alerts/slice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export enum AlertName { + PinToToolbar = 'PinToToolbar', +} + +export interface AlertsState { + [AlertName.PinToToolbar]: { + isOpen: boolean + } +} + +const initialState: AlertsState = { + [AlertName.PinToToolbar]: { + isOpen: true, + }, +} + +const slice = createSlice({ + name: 'alerts', + initialState, + reducers: { + openAlert: (state, action: PayloadAction) => { + state[action.payload].isOpen = true + }, + closeAlert: (state, action: PayloadAction) => { + state[action.payload].isOpen = false + }, + }, +}) + +export const { openAlert, closeAlert } = slice.actions +export const { reducer: alertsReducer } = slice diff --git a/apps/extension/src/app/features/onboarding/create/NameWallet.tsx b/apps/extension/src/app/features/onboarding/create/NameWallet.tsx new file mode 100644 index 00000000000..8fd4496fb18 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/NameWallet.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { Input } from 'src/app/components/Input' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Flex, Text, Unicon } from 'ui/src' +import { fonts, iconSizes } from 'ui/src/theme' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { shortenAddress } from 'uniswap/src/utils/addresses' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function NameWallet(): JSX.Element { + const { getOnboardingAccount, setPendingWalletName } = useOnboardingContext() + const onboardingAccount = getOnboardingAccount() + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + const [walletName, setWalletName] = useState('') + + const onboardingAccountAddress = onboardingAccount?.address + + const onSubmit = async (): Promise => { + if (walletName) { + setPendingWalletName(walletName) + } + + if (onboardingAccount) { + await saveDappConnection(UNISWAP_WEB_URL, onboardingAccount) + } + + goToNextStep() + } + + return ( + + : undefined + } + nextButtonEnabled={true} + nextButtonText="Finish" + subtitle="This nickname is only visible to you" + title="Give your wallet a name" + onBack={goToPreviousStep} + onSubmit={onSubmit} + > + + + + {onboardingAccountAddress && shortenAddress(onboardingAccountAddress)} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx b/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx new file mode 100644 index 00000000000..3114e7ed10e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx @@ -0,0 +1,23 @@ +import { ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Password } from 'src/app/features/onboarding/Password' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { sleep } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function PasswordCreate(): JSX.Element { + const { goToNextStep } = useOnboardingSteps() + const { generateOnboardingAccount } = useOnboardingContext() + + const onComplete = async (password: string): Promise => { + goToNextStep() + + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // start running the validation after going to next step since they clog the main thread with work + // plus just a bit of extra leeway since animations can take just a tad extra to finish + await sleep(ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY) + await generateOnboardingAccount(password) + } + + return +} diff --git a/apps/extension/src/app/features/onboarding/create/TestMnemonic.tsx b/apps/extension/src/app/features/onboarding/create/TestMnemonic.tsx new file mode 100644 index 00000000000..3762ba728a6 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/TestMnemonic.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TextInput } from 'react-native' +import { Input } from 'src/app/components/Input' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Flex, Square, Text } from 'ui/src' +import { Check, FileListCheck } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useDebounce } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { PASSWORD_VALIDATION_DEBOUNCE_MS } from 'wallet/src/utils/password' + +export function TestMnemonic({ numberOfTests = 3 }: { numberOfTests?: number }): JSX.Element { + const { t } = useTranslation() + + const { getOnboardingAccountAddress, getOnboardingAccountMnemonic } = useOnboardingContext() + const onboardingAccountAddress = getOnboardingAccountAddress() + const onboardingAccountMnemonic = getOnboardingAccountMnemonic() + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const [completedTests, markTestCompleted] = useReducer((v: number) => v + 1, 0) + const [userWordInput, setUserWordInput] = useState('') + const [hasError, setHasError] = useState(false) + + const isLastTest = completedTests === numberOfTests - 1 + + // Pick NUMBER_OF_TESTS random words + const testingWordIndexes = useMemo( + () => + onboardingAccountMnemonic ? selectRandomNumbers(onboardingAccountMnemonic.length, numberOfTests) : undefined, + [onboardingAccountMnemonic, numberOfTests], + ) + + // Save the next word index for reuse, ensuring it's not undefined + const nextWordIndex = useMemo(() => testingWordIndexes?.[completedTests] ?? 0, [completedTests, testingWordIndexes]) + const nextWordNumber = nextWordIndex + 1 + const validWord = userWordInput === onboardingAccountMnemonic?.[nextWordIndex] + const isComplete = validWord && isLastTest + + useEffect(() => { + if (validWord) { + setTimeout(() => { + if (!isLastTest) { + markTestCompleted() + setUserWordInput('') + } else { + goToNextStep() + } + }, 200) + } + }, [validWord, goToNextStep, isLastTest]) + + const debouncedWord = useDebounce(userWordInput, PASSWORD_VALIDATION_DEBOUNCE_MS) + useEffect(() => { + setHasError(!!debouncedWord && debouncedWord !== onboardingAccountMnemonic?.[nextWordIndex]) + }, [debouncedWord, onboardingAccountMnemonic, nextWordIndex]) + + const onNext = useCallback((): void => { + if (!onboardingAccountMnemonic || !onboardingAccountAddress) { + return + } + + goToNextStep() + }, [onboardingAccountMnemonic, goToNextStep, onboardingAccountAddress]) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={t('onboarding.backup.manual.progress', { + completedStepsCount: isComplete ? numberOfTests : completedTests, + totalStepsCount: numberOfTests, + })} + nextButtonTheme="secondary" + subtitle={t('onboarding.backup.manual.subtitle', { count: nextWordNumber, ordinal: true })} + title={t('onboarding.backup.manual.title')} + onBack={goToPreviousStep} + onSkip={onNext} + onSubmit={onNext} + > + + { + setUserWordInput(value) + if (hasError) { + setHasError(false) + } + }} + /> + + {t('onboarding.backup.manual.error')} + + + + + ) +} + +type InputStackBaseProps = { + value?: string + onChangeText: (word: string) => void +} + +function RecoveryPhraseInputStack({ + nextWordNumber, + numInputsBelow, + numTotalSteps, + isInputValid, + value, + onChangeText, +}: InputStackBaseProps & { + numInputsBelow: number + numTotalSteps: number + nextWordNumber: number + isInputValid: boolean +}): JSX.Element { + return ( + + + {isInputValid ? ( + + + + ) : null} + + + + ) +} + +type InputStackProps = InputStackBaseProps & { + total: number + current: number + prefixText: string +} + +export function InputStack({ onChangeText, total, value, current, prefixText }: InputStackProps): JSX.Element { + const { t } = useTranslation() + const refs = useRef([]) + const prefixTexts = useRef([]) + + // this is weird because we only get the new word as it renders + // but avoiding a bit of a refactor before beta release, should be safe: + prefixTexts.current[current] ||= prefixText + + useEffect(() => { + // Wait until the next tick to focus the input, otherwise the state update interferes with the focus event. + setTimeout(() => { + refs.current?.[current]?.focus() + }, 1) + }, [current]) + + return ( + + {new Array(total).fill(0).map((_, i) => { + const isHidden = i < current + const isCurrentlyActive = i === current + const isBelow = i > current + const belowOffset = i - current + + return ( + + + {prefixTexts.current[i] || ''} + + { + if (inputNode) { + refs.current[i] = inputNode + } + }} + centered + large + borderColor="$surface3" + borderRadius="$rounded20" + flex={1} + placeholder={t('onboarding.backup.manual.placeholder')} + shadowColor="$shadowColor" + shadowOffset={{ width: 0, height: 4 }} + shadowOpacity={0.4} + shadowRadius={10} + value={value} + zIndex={zIndices.sticky} + onChangeText={onChangeText} + /> + + ) + })} + + ) +} + +function selectRandomNumbers(maxNumber: number, numberOfNumbers: number): number[] { + const shuffledIndexes = [...Array(maxNumber).keys()].sort(() => 0.5 - Math.random()) + const selectedIndexes = shuffledIndexes.slice(0, numberOfNumbers) + selectedIndexes.sort((a, b) => a - b) + return selectedIndexes +} diff --git a/apps/extension/src/app/features/onboarding/create/ViewMnemonic.tsx b/apps/extension/src/app/features/onboarding/create/ViewMnemonic.tsx new file mode 100644 index 00000000000..dc1a48af1a8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/ViewMnemonic.tsx @@ -0,0 +1,148 @@ +import { FunctionComponent, useEffect, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { MnemonicViewer } from 'src/app/components/MnemonicViewer' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { CheckBox, Circle, Flex, IconProps, Square, Text } from 'ui/src' +import { AlertTriangle, EyeOff, FileListLock, Key, PencilDetailed } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +enum ViewStep { + Info, + View, +} + +export function ViewMnemonic(): JSX.Element { + const { t } = useTranslation() + + const [viewStep, setViewStep] = useState(ViewStep.Info) + + const { goToNextStep } = useOnboardingSteps() + + const [disclaimerChecked, setDisclaimerChecked] = useState(false) + + const { getOnboardingAccountAddress, getOnboardingAccountMnemonic, retrieveOnboardingAccountMnemonic } = + useOnboardingContext() + const onboardingAccountAddress = getOnboardingAccountAddress() + const onboardingAccountMnemonic = getOnboardingAccountMnemonic() + + useEffect(() => { + if (!onboardingAccountMnemonic) { + retrieveOnboardingAccountMnemonic().catch((e) => { + logger.error(e, { + tags: { file: 'ViewMnemonic', function: 'retrieveOnboardingAccountMnemonic' }, + }) + }) + } + }, [onboardingAccountMnemonic, retrieveOnboardingAccountMnemonic]) + + const onSubmit = (): void => { + if (viewStep === ViewStep.Info) { + setViewStep(ViewStep.View) + return + } + + if (onboardingAccountAddress && disclaimerChecked) { + goToNextStep() + } + } + + // On view step, next button should be enabled if mnemonic has been created. + // On disclaimer step, next button should be enabled if disclaimer is checked and mnemonic has been created. + const shouldEnableNextButton = + viewStep === ViewStep.View ? !!onboardingAccountAddress && disclaimerChecked : !!onboardingAccountAddress + + return ( + + + {viewStep === ViewStep.View ? ( + + ) : ( + + )} + + } + nextButtonEnabled={shouldEnableNextButton} + nextButtonText={t('common.button.continue')} + subtitle={ + viewStep === ViewStep.View + ? t('onboarding.backup.view.subtitle.message2') + : t('onboarding.backup.view.subtitle.message1') + } + title={t('onboarding.backup.view.title')} + onBack={(): void => + navigate(`/${TopLevelRoutes.Onboarding}`, { + replace: true, + }) + } + onSubmit={onSubmit} + > + {viewStep === ViewStep.Info ? ( + + + + {t('onboarding.backup.view.warning.message1')} + + + + {t('onboarding.backup.view.warning.message2')} + + + + + }} + i18nKey="onboarding.backup.view.warning.message3" + /> + + + + ) : ( + + + + {t('onboarding.backup.view.disclaimer')}} + onCheckPressed={(currentValue: boolean): void => setDisclaimerChecked(!currentValue)} + /> + + + )} + + + ) +} + +function WarningIcon({ Icon }: { Icon: FunctionComponent }): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx new file mode 100644 index 00000000000..d24c3e56805 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx @@ -0,0 +1,336 @@ +import { wordlists } from 'ethers' +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + NativeSyntheticEvent, + TextInputChangeEventData, + TextInputFocusEventData, + TextInputKeyPressEventData, +} from 'react-native' +import { useDispatch } from 'react-redux' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { SyncFromPhoneButton } from 'src/app/features/onboarding/SyncFromPhoneButton' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, FlexProps, Input, Square, Text, inputStyles } from 'ui/src' +import { FileListLock, RotatableChevron } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useDebounce } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { isValidMnemonicWord, validateMnemonic } from 'wallet/src/utils/mnemonics' + +export function ImportMnemonic(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const [mnemonic, setMnemonic] = useState(new Array(24).fill('')) + const { addOnboardingAccountMnemonic } = useOnboardingContext() + const [expanded, setExpanded] = useState(false) + const [errors, setErrors] = useState>({}) + const isEmptyMnemonic = useMemo(() => !mnemonic.join(' ').toLocaleLowerCase().trim(), [mnemonic]) + + const inputRefs = useRef>(Array(24).fill(null)) + + const accounts = useSignerAccounts() + + const { isResetting, goToNextStep } = useOnboardingSteps() + + useEffect(() => { + const handlePaste = (event: ClipboardEvent): void | (() => void) => { + if (!event.clipboardData) { + return + } + const pastedText = event.clipboardData.getData('text').toLowerCase().trim() + if (!pastedText) { + return + } + const { validMnemonic, error } = validateMnemonic(pastedText) + if (error || !validMnemonic) { + return + } + // We conditionally prevent default here because we want paste to work as expected in all other cases. + event.preventDefault() + const words = validMnemonic.replaceAll(/\s+/g, ' ').split(' ') + setExpanded(words.length > 12) + + const newMnemonic = Array(24) + .fill('') + .map((_, i) => words[i] || '') + + setMnemonic(newMnemonic) + setErrors({}) + + // We focus the last input on the next tick after the state has been updated. + setTimeout(() => inputRefs.current[words.length - 1]?.focus(), 0) + + // Clear clipboard after paste + navigator.clipboard.writeText('').catch(() => {}) + } + + window.document.addEventListener('paste', handlePaste) + + return () => { + window.document.removeEventListener('paste', handlePaste) + } + }, [setMnemonic]) + + const handleChange = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + const newMnemonic = [...mnemonic] + const word = event.nativeEvent.text + + // Focus next input when the space key is pressed. + if (word.length > 1 && word.endsWith(' ')) { + inputRefs.current[index + 1]?.focus() + } + + newMnemonic[index] = word.trim() + setMnemonic(newMnemonic) + }, + [mnemonic, setMnemonic], + ) + + const handleKeyPress = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + // Focus previous input when the backspace key is pressed. + if (event.nativeEvent.key === 'Backspace' && !mnemonic[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + }, + [mnemonic], + ) + + const handleBlur = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + const word = event.nativeEvent.text + + if (!word && errors[index] !== undefined) { + setErrors({ ...errors, [index]: undefined }) + } + if (!word) { + return + } + const wordInList = wordlists.en?.getWordIndex(word) !== -1 + setErrors({ ...errors, [index]: !wordInList }) + }, + [errors], + ) + + const onSubmit = useCallback(async () => { + if (isEmptyMnemonic) { + return + } + + if (isResetting) { + // Remove all accounts before importing mnemonic. + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts, + }), + ) + } + + addOnboardingAccountMnemonic(mnemonic) + goToNextStep() + }, [accounts, dispatch, goToNextStep, isResetting, mnemonic, addOnboardingAccountMnemonic, isEmptyMnemonic]) + + const debouncedMnemonic = useDebounce(mnemonic, 500) + + const { error: mnemonicValidationError, invalidWordCount } = useMemo(() => { + const mnemonicString = debouncedMnemonic.join(' ').toLowerCase() + + if (!mnemonicString.trim()) { + return { error: undefined, invalidWordCount: undefined } + } + + return validateMnemonic(mnemonicString) + }, [debouncedMnemonic]) + + const errorMessageToDisplay = useMemo(() => { + // If all cells are filled, but there is an error, display the invalid phrase error + const trimmedMnemonic = expanded ? mnemonic : mnemonic.slice(0, 12) + const allCellsFilled = trimmedMnemonic.every((word) => word.length > 0) + + if (allCellsFilled && mnemonicValidationError) { + return t('onboarding.importMnemonic.error.invalidPhrase') + } + + if (mnemonicValidationError && invalidWordCount && invalidWordCount >= 1) { + return t('onboarding.import.error.invalidWords', { count: invalidWordCount }) + } + + return undefined + }, [expanded, mnemonic, mnemonicValidationError, t, invalidWordCount]) + + return ( + + + + + + } + belowFrameContent={ + isResetting ? ( + + + + ) : undefined + } + nextButtonEnabled={!isEmptyMnemonic && !mnemonicValidationError && !errorMessageToDisplay} + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.importMnemonic.subtitle')} + title={t('onboarding.importMnemonic.title')} + onBack={isResetting ? undefined : (): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })} + onSubmit={onSubmit} + > + <> + + {errorMessageToDisplay ?? DUMMY_TEXT} {/* To prevent layout shift */} + + + + {mnemonic.map( + (word, index) => + Boolean(expanded || (!expanded && index < 12)) && ( + + (inputRefs.current[index] = ref)} + handleBlur={handleBlur} + handleChange={handleChange} + handleKeyPress={handleKeyPress} + index={index} + word={word} + /> + + ), + )} + + + + + + + + ) +} + +const RecoveryPhraseWord = forwardRef< + Input, + { + word: string + index: number + handleBlur: (index: number) => (event: NativeSyntheticEvent) => void + handleChange: (index: number) => (event: NativeSyntheticEvent) => void + handleKeyPress: (index: number) => (e: NativeSyntheticEvent) => void + } +>(function _RecoveryPhraseWord({ word, index, handleBlur, handleChange, handleKeyPress }, ref): JSX.Element { + const debouncedWord = useDebounce(word, 500) + const showError = isValidMnemonicWord(debouncedWord) + + return ( + + + {(index + 1).toString()} + + + + + ) +}) + +const styles = { + inputFocus: { + backgroundColor: '$surface1', + borderWidth: 1, + borderColor: '$surface3', + outlineWidth: 0, + }, + recoveryPhraseWord: { + width: 'calc(calc(100% - 32px) / 3)', // 3 columns with 16px gap + }, +} as const + +const DUMMY_TEXT = 'DUMMY TEXT' diff --git a/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx new file mode 100644 index 00000000000..b7944bd1477 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx @@ -0,0 +1,214 @@ +import { useApolloClient } from '@apollo/client' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SelectWalletsSkeleton } from 'src/app/components/loading/SelectWalletSkeleton' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Flex, ScrollView, Square, Text } from 'ui/src' +import { WalletFilled } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { + SelectWalletScreenDocument, + SelectWalletScreenQuery, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +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 { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useAsyncData } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import WalletPreviewCard from 'wallet/src/components/WalletPreviewCard/WalletPreviewCard' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { NUMBER_OF_WALLETS_TO_IMPORT } from 'wallet/src/features/onboarding/createImportedAccounts' +import { useSelectAccounts } from 'wallet/src/features/onboarding/hooks/useSelectAccounts' +import { fetchUnitagByAddresses } from 'wallet/src/features/unitags/api' + +const FORCED_LOADING_DURATION = 3 * ONE_SECOND_MS // 3s + +interface ImportableAccount { + ownerAddress: string + balance: number | undefined +} + +function isImportableAccount(account: { + ownerAddress: string | undefined + balance: Maybe +}): account is ImportableAccount { + return (account as ImportableAccount).ownerAddress !== undefined +} + +export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.Element { + const { t } = useTranslation() + const shouldAutoConnect = useFeatureFlag(FeatureFlags.ExtensionAutoConnect) + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { getImportedAccountsAddresses, selectImportedAccounts } = useOnboardingContext() + const importedAccountsAddresses = getImportedAccountsAddresses() + + const isImportedAccountsReady = importedAccountsAddresses?.length === NUMBER_OF_WALLETS_TO_IMPORT + + const { + data: initialShownAccounts, + isLoading: loading, + error, + refetch, + } = useImportableAccounts(isImportedAccountsReady ? importedAccountsAddresses : undefined) + + const onRetry = useCallback(async () => { + setIsForcedLoading(true) + refetch() + }, [refetch]) + + const showError = error && !initialShownAccounts?.length + + const { selectedAddresses, toggleAddressSelection } = useSelectAccounts(initialShownAccounts) + + const onSubmit = useCallback(async () => { + const importedAccounts = await selectImportedAccounts(selectedAddresses) + + // TODO(EXT-1375): figure out how to better auto connect existing wallets that may have connected via WC or some other method. + // Once that's solved the feature flag can be turned on/removed. + if (shouldAutoConnect && importedAccounts[0]) { + await saveDappConnection(UNISWAP_WEB_URL, importedAccounts[0]) + } + + goToNextStep() + }, [selectImportedAccounts, selectedAddresses, goToNextStep, shouldAutoConnect]) + + // Force a fixed duration loading state for smoother transition (as we show different UI for 1 vs multiple wallets) + const [isForcedLoading, setIsForcedLoading] = useState(true) + useTimeout(() => setIsForcedLoading(false), FORCED_LOADING_DURATION) + + const isLoading = loading || isForcedLoading || !isImportedAccountsReady + + const title = showError ? t('onboarding.selectWallets.title.error') : t('onboarding.selectWallets.title.default') + + return ( + + + + + } + nextButtonEnabled={showError || (isImportedAccountsReady && selectedAddresses.length > 0 && !isLoading)} + nextButtonText={showError ? t('common.button.retry') : t('common.button.continue')} + nextButtonTheme={showError ? 'secondary' : 'primary'} + title={title} + onBack={goToPreviousStep} + onSubmit={showError ? onRetry : onSubmit} + > + + + {showError ? ( + + {t('onboarding.selectWallets.error')} + + ) : isLoading ? ( + + + + ) : ( + initialShownAccounts?.map((account) => { + const { ownerAddress, balance } = account + return ( + + ) + }) + )} + + + + + ) +} + +function useImportableAccounts(addresses?: string[]): { + isLoading: boolean + data?: ImportableAccount[] + error?: Error + refetch: () => void +} { + const [refetchCount, setRefetchCount] = useState(0) + const apolloClient = useApolloClient() + + const refetch = useCallback(() => setRefetchCount((count) => count + 1), []) + + const fetch = useCallback(async (): Promise => { + if (!addresses) { + return + } + + const fetchBalances = apolloClient.query({ + query: SelectWalletScreenDocument, + variables: { ownerAddresses: addresses }, + }) + + const fetchUnitags = fetchUnitagByAddresses(addresses) + + const [balancesResponse, unitagsResponse] = await Promise.all([fetchBalances, fetchUnitags]) + + const unitagsByAddress = unitagsResponse?.data + + const allAddressBalances = balancesResponse.data.portfolios + + const importableAccounts = allAddressBalances + ?.map((address) => ({ + ownerAddress: address?.ownerAddress, + balance: address?.tokensTotalDenominatedValue?.value, + })) + .filter(isImportableAccount) + + const accountsWithBalanceOrUnitag: ImportableAccount[] | undefined = importableAccounts?.filter((address) => { + const hasBalance = Boolean(address.balance && address.balance > 0) + const hasUnitag = unitagsByAddress?.[address.ownerAddress] !== undefined + return hasBalance || hasUnitag + }) + + if (accountsWithBalanceOrUnitag?.length) { + return accountsWithBalanceOrUnitag + } + + // If all addresses have 0 total token value and no unitags are associated with any of them, show the first address. + const firstImportableAccount: ImportableAccount | undefined = importableAccounts?.[0] + if (firstImportableAccount) { + return [firstImportableAccount] + } + + // If query for address balances returned no results, show the first address. + const firstPendingAddress = addresses[0] + if (firstPendingAddress) { + return [{ ownerAddress: firstPendingAddress, balance: undefined }] + } + + throw new Error('No importable accounts found') + // We use `refetchCount` as a dependency to manually trigger a refetch when calling the `refetch` function. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [addresses, apolloClient, refetchCount]) + + const response = useAsyncData(fetch) + + return useMemo( + () => ({ + ...response, + refetch, + }), + [refetch, response], + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx b/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx new file mode 100644 index 00000000000..85e69a13c7b --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Complete } from 'src/app/features/onboarding/Complete' +import { SyncFromPhoneButton } from 'src/app/features/onboarding/SyncFromPhoneButton' +import { Terms } from 'src/app/features/onboarding/Terms' +import { MainIntroWrapper } from 'src/app/features/onboarding/intro/MainIntroWrapper' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { checksIfSupportsSidePanel } from 'src/app/utils/chrome' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { Button, Flex, Text } from 'ui/src' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useTimeout } from 'utilities/src/time/timing' + +export function IntroScreen(): JSX.Element { + const { t } = useTranslation() + + const isOnboarded = useSelector(isOnboardedSelector) + + // Detections for some unsupported browsers may not work until stylesheet is loaded + useTimeout(() => { + if (!checksIfSupportsSidePanel()) { + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.UnsupportedBrowser}`) + } + }, 0) + + if (isOnboarded) { + return + } + + return ( + + + + + + } + > + + + + + + + + + + {t('onboarding.intro.mobileScan.title')} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx b/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx new file mode 100644 index 00000000000..e6109e1f6b3 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren } from 'react' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { Flex } from 'ui/src' + +export function MainContentWrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx b/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx new file mode 100644 index 00000000000..c76615a7a06 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren, ReactNode } from 'react' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { Flex } from 'ui/src' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' + +// Fixed padding value to align content with a certain point on the background +const CONTAINER_PADDING_TOP = 340 +const LANDING_BACKGROUND_SIZE = 400 + +export function MainIntroWrapper({ + children, + belowFrameContent, +}: PropsWithChildren<{ belowFrameContent?: ReactNode }>): JSX.Element { + return ( + + + + + + + + + {children} + + + {belowFrameContent && ( + + {belowFrameContent} + + )} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx b/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx new file mode 100644 index 00000000000..a2d707b5d03 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next' +import { MainIntroWrapper } from 'src/app/features/onboarding/intro/MainIntroWrapper' +import { isAndroid } from 'src/app/utils/chrome' +import { Flex, Text } from 'ui/src' +import { AlertTriangle } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' + +export function UnsupportedBrowserScreen(): JSX.Element { + const { t } = useTranslation() + + const title = isAndroid() + ? t('onboarding.extension.unsupported.android.title') + : t('onboarding.extension.unsupported.title') + + const description = isAndroid() + ? t('onboarding.extension.unsupported.android.description') + : t('onboarding.extension.unsupported.description') + + return ( + + + + + + + + + + + {title} + + + {description} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx new file mode 100644 index 00000000000..0e64b2b9976 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next' +import { terminateStoreSynchronization } from 'src/store/storeSynchronization' +import { Flex, Text } from 'ui/src' +import { Check, GraduationCap } from 'ui/src/components/icons' +import { useFinishOnboarding } from 'wallet/src/features/onboarding/OnboardingContext' + +export function ResetComplete(): JSX.Element { + const { t } = useTranslation() + + // Activates onboarding accounts on component mount + useFinishOnboarding(terminateStoreSynchronization) + + return ( + <> + + + + + + {t('onboarding.resetPassword.complete.title')} + + {t('onboarding.resetPassword.complete.subtitle')} + + + + + + {t('onboarding.resetPassword.complete.safety')} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx b/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx new file mode 100644 index 00000000000..f835447c708 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx @@ -0,0 +1,232 @@ +import { createRef, RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputKeyPressEventData } from 'react-native' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { useScantasticContext } from 'src/app/features/onboarding/scan/ScantasticContextProvider' +import { decryptMessage } from 'src/app/features/onboarding/scan/utils' +import { Flex, Input, inputStyles, Square, Text } from 'ui/src' +import { Mobile } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { arraysAreEqual } from 'utilities/src/primitives/array' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useInterval, useTimeout } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { getOtpDurationString } from 'wallet/src/utils/duration' + +const MAX_FAILED_OTP_ATTEMPTS = 3 + +type CharacterSequence = [string, string, string, string, string, string] +const INITIAL_CHARACTER_SEQUENCE: CharacterSequence = ['', '', '', '', '', ''] + +export function OTPInput(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { addOnboardingAccountMnemonic } = useOnboardingContext() + const { privateKey, resetScantastic, sessionUUID, expirationTimestamp } = useScantasticContext() + const resetFlowAndNavBack = useCallback((): void => { + resetScantastic() + goToPreviousStep() + }, [goToPreviousStep, resetScantastic]) + + const [expiryText, setExpiryText] = useState(getOtpDurationString(expirationTimestamp)) + + const setExpirationText = useCallback(() => { + const expirationString = getOtpDurationString(expirationTimestamp) + setExpiryText(expirationString) + }, [expirationTimestamp]) + useInterval(setExpirationText, ONE_SECOND_MS) + + if (!sessionUUID || !privateKey) { + resetFlowAndNavBack() + } + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const [failedAttemptCount, setFailedAttemptCount] = useState(0) + const [characterSequence, setCharacterSequence] = useState(INITIAL_CHARACTER_SEQUENCE) + + const inputRefs = useRef[]>([]) + inputRefs.current = new Array(6).fill(null).map((_, i) => inputRefs.current[i] || createRef()) + + // Add all accounts from mnemonic. + const onSubmit = useCallback( + async (mnemonic: string[]) => { + addOnboardingAccountMnemonic(mnemonic) + goToNextStep() + }, + [goToNextStep, addOnboardingAccountMnemonic], + ) + + useEffect(() => { + if (error && !arraysAreEqual(characterSequence, INITIAL_CHARACTER_SEQUENCE)) { + setCharacterSequence(INITIAL_CHARACTER_SEQUENCE) + } + }, [error, characterSequence]) + + const submitOTP = useCallback(async (): Promise => { + if (!privateKey || !sessionUUID) { + return + } + setError(false) + setLoading(true) + // submit OTP to receive blob + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + uuid: sessionUUID, + otp: characterSequence.join(''), + }), + }) + + if (!response.ok) { + setCharacterSequence(INITIAL_CHARACTER_SEQUENCE) + throw new Error(`Failed to submit OTP: ${await response.text()}`) + } + + const data = (await response.json()) as { encryptedSeed?: string; OTPFailedAttempts?: number } + if (!data.encryptedSeed) { + if (data.OTPFailedAttempts) { + if (Number(data.OTPFailedAttempts) === MAX_FAILED_OTP_ATTEMPTS) { + resetFlowAndNavBack() + return + } else { + setFailedAttemptCount(data.OTPFailedAttempts) + return + } + } + throw new Error(`fetch(${uniswapUrls.scantasticApiUrl}/otp failed to include an encrypted seed`) + } + const preImage = await decryptMessage(privateKey, data.encryptedSeed) + const words = preImage.split(' ') + + const newMnemonic = Array(24) + .fill('') + .map((_, i) => (words[i] || '') as string) + .filter((word) => !!word) + + await onSubmit(newMnemonic) + }, [privateKey, sessionUUID, characterSequence, onSubmit, resetFlowAndNavBack]) + + const handleChange = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + setError(false) + const newCharacters: CharacterSequence = [...characterSequence] + newCharacters[index] = event.nativeEvent.text + setCharacterSequence(newCharacters) + + if (newCharacters[index]?.length === 1 && inputRefs.current[index + 1]?.current) { + inputRefs.current[index + 1]?.current?.focus() + } + }, + [characterSequence, setCharacterSequence], + ) + + const handleKeyPress = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + if (index !== 0 && event.nativeEvent.key === 'Backspace') { + inputRefs.current[index - 1]?.current?.focus() + } + }, + [], + ) + + useEffect(() => { + const allCharactersFilled = characterSequence.every((element) => element !== '') + if (allCharactersFilled && !loading && !error) { + submitOTP() + .catch((e) => { + inputRefs.current[0]?.current?.focus() + logger.error(e, { + tags: { file: 'OTPInput.tsx', function: 'submitOTP' }, + extra: { uuid: sessionUUID }, + }) + setError(true) + }) + .finally(() => { + setLoading(false) + }) + } + }, [characterSequence, loading, error, sessionUUID, submitOTP]) + + useTimeout(resetFlowAndNavBack, expirationTimestamp - Date.now()) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={expiryText} + nextButtonTheme="secondary" + subtitle={t('onboarding.scan.otp.subtitle')} + title={t('onboarding.scan.otp.title')} + onBack={resetFlowAndNavBack} + onSubmit={(): void => undefined} + > + + + {characterSequence.map((character, index) => ( + + ))} + + + {error && ( + + {t('onboarding.scan.otp.error')} + + )} + {failedAttemptCount > 0 && ( + + {t('onboarding.scan.otp.failed', { number: failedAttemptCount })} + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx new file mode 100644 index 00000000000..8a467ca1648 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withSpring, +} from 'react-native-reanimated' +import { SpringConfig } from 'react-native-reanimated/lib/typescript/reanimated2/animation/springUtils' +import QRCode from 'react-qr-code' //TODO(EXT-476): Replace with custom QR code designs +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { useScantasticContext } from 'src/app/features/onboarding/scan/ScantasticContextProvider' +import { getScantasticUrl } from 'src/app/features/onboarding/scan/utils' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import UAParser from 'ua-parser-js' +import { Flex, Image, Square, Text, useSporeColors } from 'ui/src' +import { DOT_GRID, UNISWAP_LOGO } from 'ui/src/assets' +import { Mobile, Wifi } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes, zIndices } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import { ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +const UNISWAP_LOGO_SIZE = 52 +const UNISWAP_LOGO_SCALE_LOADING = 1.2 +const UNISWAP_LOGO_SCALE_DEFAULT = 1 +const QR_CODE_SIZE = 212 + +function useDocumentVisibility(): boolean { + const [isDocumentVisible, setIsDocumentVisible] = useState(!document.hidden) + + const handleVisibilityChange = (): void => { + setIsDocumentVisible(!document.hidden) + } + + useEffect(() => { + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + + return isDocumentVisible +} + +export function ScanToOnboard(): JSX.Element { + const colors = useSporeColors() + const { t } = useTranslation() + + const { goToNextStep } = useOnboardingSteps() + const isDocumentVisible = useDocumentVisibility() + + const { sessionUUID, isLoadingUUID, publicKey, resetScantastic, expirationTimestamp, setExpirationTimestamp } = + useScantasticContext() + + const scantasticValue = useMemo(() => { + const parser = new UAParser(window.navigator.userAgent) + const { + device: { vendor, model }, + browser: { name: browser }, + } = parser.getResult() + + if (!publicKey || !sessionUUID) { + return '' + } + + try { + const params = ScantasticParamsSchema.parse({ + uuid: sessionUUID, + publicKey, + vendor, + browser, + model, + }) + + return getScantasticUrl(params) + } catch (e) { + const wrappedError = new Error('Failed to build scantastic params', { cause: e }) + logger.error(wrappedError, { + tags: { + file: 'ScanToOnboard.tsx', + function: 'useMemo', + }, + }) + return '' + } + }, [publicKey, sessionUUID]) + + const errorDerivingQR = Boolean(!isLoadingUUID && !scantasticValue) + + const checkOTPState = useCallback(async (): Promise => { + if (!sessionUUID) { + return + } + try { + // poll OTP state + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp-state/${sessionUUID}`, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to check OTP state: ${await response.text()}`) + } + const data = (await response.json()) as { otp: string; expiresAtInSeconds: number } + const otpState = data.otp + if (!otpState) { + throw new Error(`Scantastic OTP check response did not include the requested OTP state`) + } + + setExpirationTimestamp((current) => data?.expiresAtInSeconds * ONE_SECOND_MS ?? current) + + // mobile app has received the OTP and the user should input it into this UI + if (otpState === 'ready') { + goToNextStep() + } + if (otpState === 'expired') { + resetScantastic() + } + } catch (e) { + logger.error(e, { + tags: { + file: 'ScanToOnboard.tsx', + function: 'checkOTPState', + }, + extra: { uuid: sessionUUID }, + }) + } + }, [sessionUUID, setExpirationTimestamp, goToNextStep, resetScantastic]) + + useEffect(() => { + let interval: NodeJS.Timeout | undefined + + if (isDocumentVisible) { + interval = setInterval(checkOTPState, ONE_SECOND_MS) + } + + return () => clearInterval(interval) + }, [checkOTPState, isDocumentVisible]) + + useTimeout(resetScantastic, expirationTimestamp - Date.now()) + + const qrScale = useSharedValue(UNISWAP_LOGO_SCALE_DEFAULT) + useEffect(() => { + if (!isLoadingUUID) { + qrScale.value = UNISWAP_LOGO_SCALE_DEFAULT + return + } + + const springConfig: SpringConfig = { + mass: 1, + stiffness: 80, + damping: 20, + } + qrScale.value = withRepeat( + withSequence( + withSpring(UNISWAP_LOGO_SCALE_LOADING, springConfig), + withSpring(UNISWAP_LOGO_SCALE_DEFAULT, springConfig), + ), + 0, + true, + ) + + return () => cancelAnimation(qrScale) + }, [isLoadingUUID, qrScale]) + // Using useAnimatedStyle and AnimatedFlex because tamagui scale animation not working + const qrAnimatedStyle = useAnimatedStyle(() => { + return { + transform: `scale(${qrScale.value})`, + } + }, [qrScale]) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={errorDerivingQR ? t('common.button.retry') : t('onboarding.scan.button')} + nextButtonTheme="secondary" + subtitle={t('onboarding.scan.subtitle')} + title={t('onboarding.scan.title')} + onBack={(): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })} + > + + + {errorDerivingQR ? ( + + + {t('onboarding.scan.error')} + + + ) : ( + <> + {/* + NOTE: if you modify the style or colors of the QR code, make sure to thoroughly test how they perform when scanning them both on light and dark modes. + */} + + + + {isLoadingUUID ? ( + + ) : ( + + + + )} + + )} + + + + + {t('onboarding.scan.wifi')} + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx b/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx new file mode 100644 index 00000000000..84dd33c8b54 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx @@ -0,0 +1,142 @@ +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { cryptoKeyToJWK, KEY_PARAMS } from 'src/app/features/onboarding/scan/utils' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { logger } from 'utilities/src/logger/logger' +import { ONE_DAY_MS, ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' +import { ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +type ScantasticContextState = { + isLoadingUUID: boolean + privateKey: CryptoKey | null + publicKey: JsonWebKey | null + sessionUUID: string | null + resetScantastic: () => void + expirationTimestamp: number + setExpirationTimestamp: Dispatch> +} + +const uuidSchema = ScantasticParamsSchema.shape.uuid + +export const ScantasticContext = createContext(undefined) + +export function ScantasticContextProvider({ children }: PropsWithChildren): JSX.Element { + const { isResetting } = useOnboardingSteps() + + const [isLoadingUUID, setIsLoadingUUID] = useState(true) + const [publicKey, setPublicKey] = useState(null) + const [privateKey, setPrivateKey] = useState(null) + const [sessionUUID, setSessionUUID] = useState(null) + // Users have 20 minutes to scan the QR code. This is reduced to 6 minutes for OTP input once the scan is completed. + const [expirationTimestamp, setExpirationTimestamp] = useState(Date.now() + 20 * ONE_MINUTE_MS) + + const reset = useCallback(() => { + setPublicKey(null) + setPrivateKey(null) + setSessionUUID(null) + setExpirationTimestamp(Date.now() + ONE_DAY_MS) + navigate(`/${TopLevelRoutes.Onboarding}/${isResetting ? OnboardingRoutes.ResetScan : OnboardingRoutes.Scan}`, { + replace: true, + }) + }, [isResetting]) + + useEffect(() => { + async function getSessionUUID(): Promise { + if (sessionUUID) { + return + } + + try { + const { publicKey: pub, privateKey: priv } = await window.crypto.subtle.generateKey(KEY_PARAMS, true, [ + 'encrypt', + 'decrypt', + ]) + const jwk = await cryptoKeyToJWK(pub) + setPublicKey(jwk) + setPrivateKey(priv) + } catch (e) { + logger.error(e, { + tags: { + file: 'OnboardingContextProvider.tsx', + function: 'getSessionUUID->generateKeyPair', + }, + }) + } + + // Initiate scantastic onboarding session + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/uuid`, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch uuid for mobile->ext onboarding: ${await response.text()}`) + } + + const data = await response.json() + + if (!data.uuid) { + throw new Error('Missing uuid from onboarding session initiation request.') + } + + try { + const uuid = uuidSchema.parse(data.uuid) + setSessionUUID(uuid) + } catch { + throw new Error('Invalid uuid from onboarding session initiation request.') + } + + if (data.expiresAtInSeconds) { + setExpirationTimestamp(data.expiresAtInSeconds * ONE_SECOND_MS) + } + } + + setIsLoadingUUID(true) + getSessionUUID() + .catch((e) => { + logger.error(e, { + tags: { file: 'OnboardingContextProvider.tsx', function: 'getSessionUUID' }, + }) + }) + .finally(() => { + setIsLoadingUUID(false) + }) + }, [sessionUUID]) + + return ( + + {children} + + ) +} + +export const useScantasticContext = (): ScantasticContextState => { + const scantasticContext = useContext(ScantasticContext) + if (scantasticContext === undefined) { + throw new Error('useScantasticContext must be inside a ScantasticContextProvider') + } + return scantasticContext +} diff --git a/apps/extension/src/app/features/onboarding/scan/utils.ts b/apps/extension/src/app/features/onboarding/scan/utils.ts new file mode 100644 index 00000000000..e08cdb84be0 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/utils.ts @@ -0,0 +1,52 @@ +import { logger } from 'utilities/src/logger/logger' +import { ScantasticParams } from 'wallet/src/features/scantastic/types' + +export const KEY_PARAMS = { + name: 'RSA-OAEP', + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', +} + +export async function cryptoKeyToJWK(key: CryptoKey): Promise { + const exportedKeyData = await window.crypto.subtle.exportKey('jwk', key) + return exportedKeyData +} + +export function getScantasticUrl({ uuid, publicKey, vendor, model, browser }: ScantasticParams): string { + let qrURI = `uniswap://scantastic?pubKey=${JSON.stringify(publicKey)}&uuid=${encodeURIComponent(uuid)}` + if (vendor) { + qrURI = qrURI.concat(`&vendor=${encodeURIComponent(vendor)}`) + } + if (model) { + qrURI = qrURI.concat(`&model=${encodeURIComponent(model)}`) + } + if (browser) { + qrURI = qrURI.concat(`&browser=${encodeURIComponent(browser)}`) + } + return qrURI +} + +function base64ToArrayBuffer(base64Data: string): ArrayBuffer { + const binaryString = window.atob(base64Data) + const len = binaryString.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes.buffer +} + +export async function decryptMessage(privateKey: CryptoKey, ciphertext: string): Promise { + const cipherTextBuffer = base64ToArrayBuffer(ciphertext) + + try { + const decryptedArrayBuffer = await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, cipherTextBuffer) + + const textDecoder = new TextDecoder() + return textDecoder.decode(decryptedArrayBuffer) + } catch (e) { + logger.error(e, { tags: { file: 'scan/utils.ts', function: 'decryptMessage' } }) + return '' + } +} diff --git a/apps/extension/src/app/features/onboarding/utils.ts b/apps/extension/src/app/features/onboarding/utils.ts new file mode 100644 index 00000000000..7e0c93ddb42 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/utils.ts @@ -0,0 +1,2 @@ +export const ONBOARDING_CONTENT_WIDTH = 460 +export const ONBOARDING_INITIAL_FRAME_HEIGHT = 636 diff --git a/apps/extension/src/app/features/popups/ConnectPopup.tsx b/apps/extension/src/app/features/popups/ConnectPopup.tsx new file mode 100644 index 00000000000..f6c9df13cc4 --- /dev/null +++ b/apps/extension/src/app/features/popups/ConnectPopup.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { extractUrlHost } from 'src/app/features/dappRequests/utils' +import { Anchor, Button, Flex, Popover, Separator, Text, TouchableArea } from 'ui/src' +import { X } from 'ui/src/components/icons' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ConnectPopupContent({ + onClose, + asPopover = false, + showConnectButton = false, +}: { + onClose?: () => void + asPopover?: boolean + showConnectButton?: boolean +}): JSX.Element { + const { t } = useTranslation() + + const { dappUrl } = useDappContext() + const activeAccount = useActiveAccountWithThrow() + + const onConnect = async (): Promise => { + await saveDappConnection(dappUrl, activeAccount) + onClose?.() + } + + return ( + + + + {t('extension.connection.titleNotConnected')} + + + + {extractUrlHost(dappUrl)} + + + + + {!asPopover && ( + + + + )} + + + + + {showConnectButton ? t('extension.connection.popupWithButton') : t('extension.connection.popup')} + + {showConnectButton ? ( + asPopover ? ( + + + + ) : ( + + ) + ) : ( + + sendAnalyticsEvent(ExtensionEventName.DappTroubleConnecting, { + dappUrl, + }) + } + > + + {t('extension.connection.popup.trouble')} + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/popups/selectors.ts b/apps/extension/src/app/features/popups/selectors.ts new file mode 100644 index 00000000000..9c68f6b3891 --- /dev/null +++ b/apps/extension/src/app/features/popups/selectors.ts @@ -0,0 +1,6 @@ +import { PopupsState } from 'src/app/features/popups/slice' +import { ExtensionState } from 'src/store/extensionReducer' + +export function selectPopupState(name: T): (state: ExtensionState) => PopupsState[T] { + return (state) => state.popups[name] +} diff --git a/apps/extension/src/app/features/popups/slice.ts b/apps/extension/src/app/features/popups/slice.ts new file mode 100644 index 00000000000..a11c446698a --- /dev/null +++ b/apps/extension/src/app/features/popups/slice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export enum PopupName { + Connect = 'connect', +} + +export interface PopupsState { + [PopupName.Connect]: { + isOpen: boolean + } +} + +const initialState: PopupsState = { + [PopupName.Connect]: { + isOpen: false, + }, +} + +const slice = createSlice({ + name: 'popups', + initialState, + reducers: { + openPopup: (state, action: PayloadAction) => { + state[action.payload].isOpen = true + }, + closePopup: (state, action: PayloadAction) => { + state[action.payload].isOpen = false + }, + }, +}) + +export const { openPopup, closePopup } = slice.actions +export const { reducer: popupsReducer } = slice diff --git a/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx b/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx new file mode 100644 index 00000000000..fb640ab5706 --- /dev/null +++ b/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx @@ -0,0 +1,24 @@ +import { ReceiveScreen } from 'src/app/features/receive/ReceiveScreen' +import { cleanup, render, screen } from 'src/test/test-utils' +import { ACCOUNT, preloadedSharedState } from 'wallet/src/test/fixtures' + +const preloadedState = preloadedSharedState({ + account: ACCOUNT, +}) + +describe(ReceiveScreen, () => { + it('renders without error', async () => { + const tree = render(, { preloadedState }) + + expect(tree).toMatchSnapshot() + cleanup() + }) + + it('renders a QR code', async () => { + render(, { preloadedState }) + + const qrCode = await screen.getByTestId('wallet-qr-code') + expect(qrCode).toBeDefined() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/receive/ReceiveScreen.tsx b/apps/extension/src/app/features/receive/ReceiveScreen.tsx new file mode 100644 index 00000000000..ac8ebfb2e50 --- /dev/null +++ b/apps/extension/src/app/features/receive/ReceiveScreen.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { X } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WalletQRCode } from 'wallet/src/components/QRCodeScanner/WalletQRCode' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ReceiveScreen(): JSX.Element { + const { t } = useTranslation() + const { navigateBack } = useExtensionNavigation() + const activeAddress = useActiveAccountAddressWithThrow() + + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap b/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap new file mode 100644 index 00000000000..bd4a03beab2 --- /dev/null +++ b/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap @@ -0,0 +1,12595 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReceiveScreen renders without error 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ + +
+
+
+
+ + + +
+
+ + Receive + +
+
+
+
+
+
+
+
+
+
+ + Test Account + +
+
+
+
+ + 0x​82D5...3Fa6 + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ + You can receive tokens & NFTs on Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast, and BNB Chain. + +
+ + Learn more + +
+
+
+
+ + +
+ , + "container":
+ + +
+
+
+
+ + + +
+
+ + Receive + +
+
+
+
+
+
+
+
+
+
+ + Test Account + +
+
+
+
+ + 0x​82D5...3Fa6 + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ + You can receive tokens & NFTs on Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast, and BNB Chain. + +
+ + Learn more + +
+
+
+
+ + +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "store": { + "@@observable": [Function], + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + }, + "unmount": [Function], +} +`; diff --git a/apps/extension/src/app/features/settings/DevMenuScreen.tsx b/apps/extension/src/app/features/settings/DevMenuScreen.tsx new file mode 100644 index 00000000000..d7e8ac89523 --- /dev/null +++ b/apps/extension/src/app/features/settings/DevMenuScreen.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' +import { Accordion, Flex, ScrollView } from 'ui/src' +import { Settings } from 'ui/src/components/icons' +import i18n from 'uniswap/src/i18n/i18n' +import { GatingOverrides } from 'wallet/src/components/gating/GatingOverrides' +import { Language, SUPPORTED_LANGUAGES } from 'wallet/src/features/language/constants' +import { getLanguageInfo, useCurrentLanguageInfo } from 'wallet/src/features/language/hooks' +import { setCurrentLanguage } from 'wallet/src/features/language/slice' + +export function DevMenuScreen(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + // Changing extension language requires changing system settings, so allowing for easy override here + const currentLanguageInfo = useCurrentLanguageInfo() + + return ( + + + + { + return { value: language, label: getLanguageInfo(t, language).displayName } + })} + selected={currentLanguageInfo.displayName} + title="Language Override" + onSelect={async (value) => { + const language = value as Language + const languageInfo = getLanguageInfo(t, language) + await i18n.changeLanguage(languageInfo.locale) + dispatch(setCurrentLanguage(language)) + }} + /> + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsDropdown.tsx b/apps/extension/src/app/features/settings/SettingsDropdown.tsx new file mode 100644 index 00000000000..be9ee09cd86 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsDropdown.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Flex, Popover, ScrollView, Text, TouchableArea } from 'ui/src' +import { Check, RotatableChevron } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +type DropdownItem = { + label: string + value: unknown +} + +export type SettingsDropdownProps = { + selected: string + items: DropdownItem[] + disableDropdown?: boolean + onSelect: (item: unknown) => void +} + +const MAX_DROPDOWN_HEIGHT = 220 +const MAX_DROPDOWN_WIDTH = 200 + +export function SettingsDropdown({ selected, items, disableDropdown, onSelect }: SettingsDropdownProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false) + + return ( + + setIsOpen(open)}> + + + + {selected} + + + + + + + + + + {items.map((item, index) => ( + { + onSelect(item.value) + setIsOpen(false) + }} + > + + + + {item.label} + + + {selected === item.label ? ( + + ) : ( + + )} + + + ))} + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx b/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx new file mode 100644 index 00000000000..a7f1c166cb4 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx @@ -0,0 +1,33 @@ +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SettingsDropdown, SettingsDropdownProps } from 'src/app/features/settings/SettingsDropdown' +import { Flex, GeneratedIcon, Text, TouchableArea } from 'ui/src' +import { iconSizes } from 'ui/src/theme' + +type SettingsItemWithDropdownProps = { + Icon: GeneratedIcon + title: string + disableDropdown?: boolean + onDisabledDropdownPress?: () => void +} & SettingsDropdownProps + +export function SettingsItemWithDropdown(props: SettingsItemWithDropdownProps): JSX.Element { + const { title, disableDropdown, Icon, onDisabledDropdownPress, ...dropdownProps } = props + + const dropdown = + + return ( + + + + + {title} + + + {disableDropdown ? ( + onDisabledDropdownPress?.()}>{dropdown} + ) : ( + dropdown + )} + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsPrivacyScreen.tsx b/apps/extension/src/app/features/settings/SettingsPrivacyScreen.tsx new file mode 100644 index 00000000000..03d2036640a --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsPrivacyScreen.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { Flex } from 'ui/src' +import { AnalyticsToggleLineSwitch } from 'wallet/src/components/settings/AnalyticsToggleLineSwitch' + +export function SettingsPrivacyScreen(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx new file mode 100644 index 00000000000..27684050a46 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { removeAllDappConnectionsFromExtension } from 'src/app/features/dapp/actions' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { CheckBox, Flex, Text, inputStyles } from 'ui/src' +import { TrashFilled } from 'ui/src/components/icons' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +export function RemoveRecoveryPhraseVerify(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const [password, setPassword] = useState('') + const [showPasswordError, setShowPasswordError] = useState(false) + const [hideInput, setHideInput] = useState(true) + const [checked, setChecked] = useState(false) + + const onChangeText = (text: string): void => { + setPassword(text) + setShowPasswordError(false) + } + + const onCheckPressed = (): void => { + setChecked(!checked) + } + + const associatedAccounts = useSignerAccounts() + + const onRemove = async (): Promise => { + const accountsToRemove = associatedAccounts + const mnemonicId = accountsToRemove?.[0]?.mnemonicId + const accAddress = accountsToRemove?.[0]?.address + + if (!accAddress) { + logger.error(new Error('No accounts to remove'), { + tags: { file: 'RemoveRecoveryPhraseVerify', function: 'onRemove' }, + }) + return + } + + if (!mnemonicId) { + logger.error(new Error('mnemonicId does not exist'), { + tags: { file: 'RemoveRecoveryPhraseVerify', function: 'onRemove' }, + }) + return + } + + await Keyring.removeMnemonic(mnemonicId) + await Keyring.removePassword() + + await removeAllDappConnectionsFromExtension() + + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: accountsToRemove, + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletRemoved, { + wallets_removed: accountsToRemove.map((a) => a.address), + }) + + await focusOrCreateOnboardingTab() + window.close() + } + + const checkPassword = async (): Promise => { + if (!checked) { + return + } + const success = await Keyring.checkPassword(password) + if (!success) { + setShowPasswordError(true) + return + } + await onRemove() + } + + const removeButtonEnabled = checked && !showPasswordError && password.length > 0 + + return ( + + + } + nextButtonEnabled={removeButtonEnabled} + nextButtonText={t('setting.recoveryPhrase.remove')} + nextButtonTheme="detrimental_Button" + subtitle={t('setting.recoveryPhrase.remove.subtitle')} + title={t('setting.recoveryPhrase.remove.title')} + onNextPressed={checkPassword} + > + + + + + {showPasswordError ? t('setting.recoveryPhrase.remove.password.error') : ''} + + + + + + {t('setting.recoveryPhrase.remove.confirm.title')} + + + {t('setting.recoveryPhrase.remove.confirm.subtitle')} + + + } + onCheckPressed={onCheckPressed} + /> + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx new file mode 100644 index 00000000000..c6b25f871ba --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx @@ -0,0 +1,111 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex, ScrollView, Text } from 'ui/src' +import { AlertTriangle } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { NumberType } from 'utilities/src/format/types' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { useAccountList } from 'wallet/src/features/accounts/hooks' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +export function RemoveRecoveryPhraseWallets(): JSX.Element { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + + const accounts = useSignerAccounts() + + return ( + + + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonTheme="secondary_Button" + subtitle={t('setting.recoveryPhrase.remove.initial.subtitle')} + title={t('setting.recoveryPhrase.remove.initial.title')} + onNextPressed={(): void => { + navigateTo( + `${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Verify}`, + ) + }} + > + + + + ) +} + +// TODO(@thomasthachil): merge this with mobile AccountList +function AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element { + const addresses = useMemo(() => accounts.map((account) => account.address), [accounts]) + const { data, loading } = useAccountList({ + addresses, + notifyOnNetworkStatusChange: true, + }) + + const sortedAddressesByBalance = addresses + .map((address) => { + const wallet = data?.portfolios?.find((portfolio) => portfolio?.ownerAddress === address) + return { address, balance: wallet?.tokensTotalDenominatedValue?.value } + }) + .sort((a, b) => (b.balance ?? 0) - (a.balance ?? 0)) + + return ( + + + {sortedAddressesByBalance.map(({ address, balance }, index) => ( + + ))} + + + ) +} + +function AssociatedAccountRow({ + index, + address, + balance, + totalCount, + loading, +}: { + index: number + address: string + balance: number | undefined + totalCount: number + loading: boolean +}): JSX.Element { + const { convertFiatAmountFormatted } = useLocalizationContext() + const balanceFormatted = convertFiatAmountFormatted(balance, NumberType.PortfolioBalance) + + return ( + + + + + + {balanceFormatted} + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx new file mode 100644 index 00000000000..654fb831a27 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx @@ -0,0 +1,52 @@ +import { Button, Flex, Square, Text } from 'ui/src' +import { ThemeNames } from 'ui/src/theme' + +export type SettingsRecoveryPhraseProps = { + title: string + subtitle: string + icon: React.ReactNode + nextButtonEnabled: boolean + nextButtonText: string + nextButtonTheme: string + onNextPressed: () => void + children: React.ReactNode +} +export function SettingsRecoveryPhrase({ + title, + subtitle, + icon, + nextButtonEnabled, + nextButtonText, + nextButtonTheme, + onNextPressed, + children, +}: SettingsRecoveryPhraseProps): JSX.Element { + return ( + + + + {icon} + + + + {title} + + + {subtitle} + + + + {children} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx new file mode 100644 index 00000000000..297bce48b86 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LayoutChangeEvent } from 'react-native' +import { CopyButton } from 'src/app/components/buttons/CopyButton' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { EnterPasswordModal } from 'src/app/features/settings/password/EnterPasswordModal' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, Separator, Text } from 'ui/src' +import { AlertTriangle, Eye, Key, Laptop } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { setClipboard } from 'uniswap/src/utils/clipboard' +import { logger } from 'utilities/src/logger/logger' +import { useAsyncData } from 'utilities/src/react/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +const enum ViewStep { + Warning, + Password, + Reveal, +} + +export function SettingsViewRecoveryPhraseScreen(): JSX.Element { + const { t } = useTranslation() + + const [viewStep, setViewStep] = useState(ViewStep.Warning) + + const mnemonicAccounts = useSignerAccounts() + const mnemonicAccount = mnemonicAccounts[0] + if (!mnemonicAccount) { + throw new Error('Screen should not be accessed unless mnemonic account exists') + } + + const placeholderWordArrayLength = 12 + + const recoveryPhraseString = useAsyncData( + useCallback(async () => Keyring.retrieveMnemonicUnlocked(mnemonicAccount.mnemonicId), [mnemonicAccount.mnemonicId]), + ).data + const recoveryPhraseArray = recoveryPhraseString?.split(' ') ?? Array(placeholderWordArrayLength).fill('') + + const onCopyPress = async (): Promise => { + try { + if (recoveryPhraseString) { + await setClipboard(recoveryPhraseString) + } + } catch (error) { + logger.error(error, { + tags: { file: 'SettingsViewRecoveryPhraseScreen.tsx', function: 'onCopyPress' }, + }) + } + } + + const showPasswordModal = (): void => { + setViewStep(ViewStep.Password) + } + + useEffect(() => { + sendAnalyticsEvent(WalletEventName.ViewRecoveryPhrase) + + // Clear clipboard when the component unmounts + return () => { + navigator.clipboard.writeText('').catch((error) => { + logger.error(error, { + tags: { file: 'SettingsViewRecoveryPhraseScreen.tsx', function: 'maybeClearClipboard' }, + }) + }) + } + }, []) + + return ( + + + {viewStep !== ViewStep.Reveal ? ( + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonTheme="secondary_Button" + subtitle={t('setting.recoveryPhrase.view.warning.message1')} + title={t('setting.recoveryPhrase.view.warning.title')} + onNextPressed={showPasswordModal} + > + {viewStep === ViewStep.Password && ( + setViewStep(ViewStep.Warning)} + onNext={() => setViewStep(ViewStep.Reveal)} + /> + )} + + + + + + + {t('setting.recoveryPhrase.view.warning.message2')} + + + + + + + + {t('setting.recoveryPhrase.view.warning.message3')} + + + + + + + + {t('setting.recoveryPhrase.view.warning.message4')} + + + + + ) : ( + + + + + + + + + + + + {t('setting.recoveryPhrase.warning.view.message')} + + + + + + + )} + + ) +} + +function SeedPhraseColumnGroup({ recoveryPhraseArray }: { recoveryPhraseArray: string[] }): JSX.Element { + const [largestIndexWidth, setLargestIndexWidth] = useState(0) + + const halfLength = recoveryPhraseArray.length / 2 + const firstHalfWords = recoveryPhraseArray.slice(0, halfLength) + const secondHalfWords = recoveryPhraseArray.slice(halfLength) + + const onIndexLayout = (event: LayoutChangeEvent): void => { + const { width } = event.nativeEvent.layout + if (width > largestIndexWidth) { + setLargestIndexWidth(width) + } + } + + return ( + + + + + + ) +} + +function SeedPhraseColumn({ + words, + indexOffset, + largestIndexWidth, + onIndexLayout, +}: { + words: string[] + indexOffset: number + largestIndexWidth: number + onIndexLayout: (event: LayoutChangeEvent) => void +}): JSX.Element { + return ( + + {words.map((word, index) => ( + + ))} + + ) +} + +function SeedPhraseWord({ + index, + word, + indexMinWidth, + onIndexLayout, +}: { + index: number + word: string + indexMinWidth: number + onIndexLayout: (event: LayoutChangeEvent) => void +}): JSX.Element { + return ( + + + {index} + + {word} + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsScreen.tsx b/apps/extension/src/app/features/settings/SettingsScreen.tsx new file mode 100644 index 00000000000..9cc44c67528 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsScreen.tsx @@ -0,0 +1,271 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { Link } from 'react-router-dom' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { + Button, + ColorTokens, + Flex, + GeneratedIcon, + ScrollView, + Separator, + Text, + TouchableArea, + useSporeColors, +} from 'ui/src' +import { + Chart, + Coins, + FileListLock, + HelpCenter, + Key, + Language, + LineChartDots, + Lock, + RotatableChevron, + Settings, + ShieldQuestion, +} from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { isDevEnv } from 'utilities/src/environment' +import noop from 'utilities/src/react/noop' +import { WebSwitch } from 'wallet/src/components/buttons/Switch' +import { SettingsLanguageModal } from 'wallet/src/components/settings/language/SettingsLanguageModal' +import { authActions } from 'wallet/src/features/auth/saga' +import { AuthActionType } from 'wallet/src/features/auth/types' +import { FiatCurrency, ORDERED_CURRENCIES } from 'wallet/src/features/fiatCurrency/constants' +import { getFiatCurrencyName, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { setCurrentFiatCurrency } from 'wallet/src/features/fiatCurrency/slice' +import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks' +import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' +import { setHideSmallBalances, setHideSpamTokens } from 'wallet/src/features/wallet/slice' + +const manifestVersion = chrome.runtime.getManifest().version + +export function SettingsScreen(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + const { navigateTo, navigateBack } = useExtensionNavigation() + const currentLanguageInfo = useCurrentLanguageInfo() + const appFiatCurrencyInfo = useAppFiatCurrencyInfo() + + const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false) + + const onPressLockWallet = async (): Promise => { + navigateBack() + await dispatch(authActions.trigger({ type: AuthActionType.Lock })) + } + + const hideSpamTokens = useHideSpamTokensSetting() + const handleSpamTokensToggle = async (): Promise => { + await dispatch(setHideSpamTokens(!hideSpamTokens)) + } + + const hideSmallBalances = useHideSmallBalancesSetting() + const handleSmallBalancesToggle = async (): Promise => { + await dispatch(setHideSmallBalances(!hideSmallBalances)) + } + + return ( + <> + {isLanguageModalOpen ? setIsLanguageModalOpen(false)} /> : undefined} + + + + + <> + {isDevEnv() && ( + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.DevMenu}`)} + /> + )} + + { + setIsLanguageModalOpen(true) + }} + onSelect={noop} + /> + { + return { + label: getFiatCurrencyName(t, currency).shortName, + value: currency, + } + })} + selected={appFiatCurrencyInfo.shortName} + title={t('settings.setting.currency.title')} + onSelect={(value) => { + const currency = value as FiatCurrency + dispatch(setCurrentFiatCurrency(currency)) + }} + /> + + + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.Privacy}`)} + /> + + + + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ChangePassword}`)} + /> + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)} + /> + + + + + {`Version ${manifestVersion}`} + + + + + + ) +} + +function SettingsItem({ + Icon, + title, + onPress, + iconProps, + themeProps, + url, +}: { + Icon: GeneratedIcon + title: string + onPress?: () => void + iconProps?: { strokeWidth?: number } + // TODO: do this with a wrapping Theme, "detrimental" wasn't working + themeProps?: { color?: string; hoverColor?: string } + url?: string +}): JSX.Element { + const colors = useSporeColors() + const hoverColor = themeProps?.hoverColor ?? colors.surface2.val + + const content = ( + + + + + {title} + + + + + ) + + if (url) { + return ( + + {content} + + ) + } + + return content +} + +function SettingsToggleRow({ + Icon, + title, + value, + onValueChange, +}: { + title: string + Icon: GeneratedIcon + value: boolean + onValueChange: (value: boolean) => void +}): JSX.Element { + return ( + + + + {title} + + + + ) +} + +function SettingsSection({ title, children }: { title: string; children: JSX.Element | JSX.Element[] }): JSX.Element { + return ( + + + {title} + + {children} + + ) +} + +function SettingsSectionSeparator(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx b/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx new file mode 100644 index 00000000000..eaeb5f208f4 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom' +import { Flex } from 'ui/src' + +/** + * SettingsScreenWrapper is a wrapper used by all settings screens. + */ +export function SettingsScreenWrapper(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx b/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx new file mode 100644 index 00000000000..76a247f7c27 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { PADDING_STRENGTH_INDICATOR, PasswordInput } from 'src/app/components/PasswordInput' +import { Button, Flex, Text } from 'ui/src' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { usePasswordForm } from 'wallet/src/utils/password' + +export function ChangePasswordForm({ onNext }: { onNext: () => void }): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const { + enableNext, + hideInput, + debouncedPasswordStrength, + password, + onPasswordBlur, + onChangePassword, + confirmPassword, + onChangeConfirmPassword, + setHideInput, + errorText, + checkSubmit, + } = usePasswordForm() + + const onSubmit = useCallback(async () => { + if (checkSubmit()) { + await Keyring.changePassword(password) + onNext() + dispatch(pushNotification({ type: AppNotificationType.PasswordChanged })) + sendAnalyticsEvent(ExtensionEventName.PasswordChanged) + } + }, [checkSubmit, password, onNext, dispatch]) + + return ( + + + + + + {errorText || 'Placeholder text'} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/EnterPasswordForm.tsx b/apps/extension/src/app/features/settings/password/EnterPasswordForm.tsx new file mode 100644 index 00000000000..bb83e733f23 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/EnterPasswordForm.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { Button, Flex, Text } from 'ui/src' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +function useEnterPasswordForm(): { + password: string + submitEnabled: boolean + error: string + onInputChange: (input: string) => void + onSubmit: () => Promise +} { + const { t } = useTranslation() + const [password, setPassword] = useState('') + const [submitEnabled, setSubmitEnabled] = useState(false) + const [error, setError] = useState('') + + const onInputChange = function onInputChange(input: string): void { + setPassword(input) + setSubmitEnabled(!!input) + setError('') + } + + const onSubmit = async function onSubmit(): Promise { + const success = await Keyring.checkPassword(password) + if (!success) { + setError(t('extension.settings.password.error.wrong')) + } + return success + } + + return { + password, + submitEnabled, + error, + onInputChange, + onSubmit, + } +} + +export function EnterPasswordForm({ onNext }: { onNext: () => void }): JSX.Element { + const { t } = useTranslation() + const [hideInput, setHideInput] = useState(true) + const { password, submitEnabled, error, onInputChange, onSubmit } = useEnterPasswordForm() + + const onContinue = async (): Promise => { + const success = await onSubmit() + if (success) { + onNext() + } + } + + return ( + + + + {t('extension.settings.password.enter.title')} + + + {error && ( + + {error} + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx b/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx new file mode 100644 index 00000000000..de9c21fbf3a --- /dev/null +++ b/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { Button, Flex, Square, Text, inputStyles, useSporeColors } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +export function EnterPasswordModal({ onNext, onClose }: { onNext: () => void; onClose: () => void }): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + const [password, setPassword] = useState('') + const [showPasswordError, setShowPasswordError] = useState(false) + const [hideInput, setHideInput] = useState(true) + + const onChangeText = (text: string): void => { + setPassword(text) + setShowPasswordError(false) + } + + const checkPassword = async (): Promise => { + const success = await Keyring.checkPassword(password) + if (!success) { + setShowPasswordError(true) + return + } + onNext() + } + + return ( + + + + + + + {t('settings.setting.recoveryPhrase.password.title')} + + + + {showPasswordError ? t('setting.recoveryPhrase.remove.password.error') : ''} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/SettingsChangePasswordScreen.tsx b/apps/extension/src/app/features/settings/password/SettingsChangePasswordScreen.tsx new file mode 100644 index 00000000000..67ca27a0306 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/SettingsChangePasswordScreen.tsx @@ -0,0 +1,34 @@ +import { t } from 'i18next' +import { useState } from 'react' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { ChangePasswordForm } from 'src/app/features/settings/password/ChangePasswordForm' +import { EnterPasswordForm } from 'src/app/features/settings/password/EnterPasswordForm' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' + +enum Step { + EnterPassword, + ChangePassword, +} + +export function SettingsChangePasswordScreen(): JSX.Element { + const [currentStep, setCurrentStep] = useState(Step.EnterPassword) + const { navigateBack } = useExtensionNavigation() + + let formContent + switch (currentStep) { + case Step.EnterPassword: + formContent = setCurrentStep(Step.ChangePassword)} /> + break + case Step.ChangePassword: + formContent = navigateBack()} /> + break + } + + return ( + + + {formContent} + + ) +} diff --git a/apps/extension/src/app/features/swap/SwapFlowScreen.tsx b/apps/extension/src/app/features/swap/SwapFlowScreen.tsx new file mode 100644 index 00000000000..a1cee3a6d50 --- /dev/null +++ b/apps/extension/src/app/features/swap/SwapFlowScreen.tsx @@ -0,0 +1,16 @@ +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { SwapFlow } from 'wallet/src/features/transactions/swap/SwapFlow' +import { useSwapPrefilledState } from 'wallet/src/features/transactions/swap/hooks/useSwapPrefilledState' + +export function SwapFlowScreen(): JSX.Element { + const { navigateBack, locationState } = useExtensionNavigation() + + const swapPrefilledState = useSwapPrefilledState(locationState?.initialTransactionState) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/GasFeeRow.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/GasFeeRow.tsx new file mode 100644 index 00000000000..a885d265afe --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/GasFeeRow.tsx @@ -0,0 +1,55 @@ +import { t } from 'i18next' +import { FadeIn } from 'react-native-reanimated' +import { Flex, SpinningLoader, Text } from 'ui/src' +import { Gas } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes } from 'ui/src/theme' +import { WalletChainId } from 'uniswap/src/types/chains' +import { NumberType } from 'utilities/src/format/types' +import { useUSDValue } from 'wallet/src/features/gas/hooks' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { NetworkFeeWarning } from 'wallet/src/features/transactions/swap/modals/NetworkFeeWarning' + +type GasFeeRowProps = { + gasFee: GasFeeResult + chainId: WalletChainId +} + +export function GasFeeRow({ gasFee, chainId }: GasFeeRowProps): JSX.Element | null { + const { convertFiatAmountFormatted } = useLocalizationContext() + + const gasFeeUSD = useUSDValue(chainId, gasFee.value ?? undefined) + const gasFeeFormatted = convertFiatAmountFormatted(gasFeeUSD, NumberType.FiatTokenPrice) + + if (!gasFeeUSD) { + return null + } + + return ( + + + {t('send.gas.networkCost.title')} + + {gasFee.loading ? ( + + ) : gasFee.error ? ( + + {t('send.gas.error.title')} + + ) : ( + + + {gasFeeFormatted} + + + + } + /> + )} + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/RecipientPanel.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/RecipientPanel.tsx new file mode 100644 index 00000000000..0257b0247a3 --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/RecipientPanel.tsx @@ -0,0 +1,128 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Keyboard } from 'react-native' +import { useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Flex, Separator, Text, TouchableArea } from 'ui/src' +import { RotatableChevron, WalletFilled } from 'ui/src/components/icons' +import { iconSizes, spacing } from 'ui/src/theme' +import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' +import { WalletChainId } from 'uniswap/src/types/chains' +import { RecipientList } from 'wallet/src/components/RecipientSearch/RecipientList' +import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' +import { useFilteredRecipientSections } from 'wallet/src/components/RecipientSearch/hooks' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { selectRecipient } from 'wallet/src/features/transactions/transactionState/transactionState' +import { + useOnToggleShowRecipientSelector, + useSetShowRecipientSelector, +} from 'wallet/src/features/transactions/transfer/hooks/useOnToggleShowRecipientSelector' + +type RecipientPanelProps = { + chainId?: WalletChainId +} + +export function RecipientPanel({ chainId }: RecipientPanelProps): JSX.Element { + const { t } = useTranslation() + + const [pattern, setPattern] = useState('') + const [selectedRecipient, setSelectedRecipient] = useState() + const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) + const { recipient, dispatch, showRecipientSelector } = useTransferContext() + const onToggleShowRecipientSelector = useOnToggleShowRecipientSelector(dispatch) + const setShowRecipientSelector = useSetShowRecipientSelector(dispatch) + const sections = useFilteredRecipientSections(pattern) + + const onSelectRecipient = useCallback((newRecipient: string) => { + setSelectedRecipient(newRecipient) + setCheckSpeedBumps(true) + }, []) + + const onSpeedBumpConfirm = useCallback(() => { + if (!selectedRecipient) { + return + } + dispatch(selectRecipient({ recipient: selectedRecipient })) + setShowRecipientSelector(false) + }, [dispatch, selectedRecipient, setShowRecipientSelector]) + + const onClose = (): void => { + setShowRecipientSelector(false) + } + + const noPatternOrFavorites = !pattern && sections.length === 0 + + return showRecipientSelector || !recipient ? ( + + + + {t('common.text.recipient')} + + + + Keyboard.dismiss()} + onFocus={() => setShowRecipientSelector(true)} + /> + + {showRecipientSelector && ( + + + + )} + + {showRecipientSelector && } + + {showRecipientSelector && + (noPatternOrFavorites ? ( + + + + {t('send.recipientSelect.search.empty.title')} + + {t('send.recipientSelect.search.empty.message')} + + + + ) : !sections.length ? ( + + {t('send.search.empty.title')} + + {t('send.search.empty.subtitle')} + + + ) : ( + // Show either suggested recipients or filtered sections based on query + + ))} + + + ) : ( + + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/ReviewButton.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/ReviewButton.tsx new file mode 100644 index 00000000000..3a239ee69b6 --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/ReviewButton.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from 'react-i18next' +import { useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Button, Flex, Text, isWeb } from 'ui/src' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { WarningLabel } from 'wallet/src/features/transactions/WarningModal/types' + +type ReviewButtonProps = { + onPress: () => void + disabled?: boolean +} + +export function ReviewButton({ onPress, disabled }: ReviewButtonProps): JSX.Element { + const { t } = useTranslation() + + const { + warnings, + derivedTransferInfo: { chainId }, + } = useTransferContext() + + const nativeCurrencySymbol = NativeCurrency.onChain(chainId).symbol + + const insufficientGasFunds = warnings.warnings.some((warning) => warning.type === WarningLabel.InsufficientGasFunds) + + const disableReviewButton = !!warnings.blockingWarning || disabled + + const buttonText = insufficientGasFunds + ? t('send.warning.insufficientFunds.title', { + currencySymbol: nativeCurrencySymbol, + }) + : t('common.button.review') + + return ( + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/SendFormScreen.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/SendFormScreen.tsx new file mode 100644 index 00000000000..b01f91400ca --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/SendFormScreen.tsx @@ -0,0 +1,161 @@ +import { useCallback } from 'react' +import { GasFeeRow } from 'src/app/features/transfer/SendFormScreen/GasFeeRow' +import { RecipientPanel } from 'src/app/features/transfer/SendFormScreen/RecipientPanel' +import { ReviewButton } from 'src/app/features/transfer/SendFormScreen/ReviewButton' +import { SendReviewScreen } from 'src/app/features/transfer/SendReviewScreen/SendReviewScreen' +import { TransferScreen, useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Flex, Separator, useSporeColors } from 'ui/src' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' +import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' +import { InsufficientNativeTokenWarning } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' +import { useTokenFormActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenFormActionHandlers' +import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { useUSDTokenUpdater } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDTokenUpdater' +import { transactionStateActions } from 'wallet/src/features/transactions/transactionState/transactionState' +import { TokenSelectorPanel } from 'wallet/src/features/transactions/transfer/TokenSelectorPanel' +import { TransferAmountInput } from 'wallet/src/features/transactions/transfer/TransferAmountInput' +import { useShowSendNetworkNotification } from 'wallet/src/features/transactions/transfer/hooks/useShowSendNetworkNotification' +import { createTransactionId } from 'wallet/src/features/transactions/utils' +import { BlockedAddressWarning } from 'wallet/src/features/trm/BlockedAddressWarning' +import { useIsBlocked, useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' + +export function SendFormScreen(): JSX.Element { + const colors = useSporeColors() + const { + dispatch, + derivedTransferInfo, + selectingCurrencyField, + exactAmountToken, + exactAmountFiat, + isFiatInput, + warnings, + gasFee, + showRecipientSelector, + screen, + setScreen, + recipient, + } = useTransferContext() + + const { currencyInInfo, currencyBalances, currencyAmounts, chainId } = derivedTransferInfo + + useShowSendNetworkNotification({ chainId: currencyInInfo?.currency.chainId }) + + const { onSetExactAmount, onSetMax, onToggleFiatInput } = useTokenFormActionHandlers(dispatch) + const { onSelectCurrency, onHideTokenSelector, onShowTokenSelector } = useTokenSelectorActionHandlers( + dispatch, + TokenSelectorFlow.Transfer, + ) + + const currencyUSDValue = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + // Sync fiat and token amounts + useUSDTokenUpdater(dispatch, Boolean(isFiatInput), exactAmountToken, exactAmountFiat ?? '', currencyInInfo?.currency) + + const exactValue = isFiatInput ? exactAmountFiat : exactAmountToken + + const showTokenSelector = selectingCurrencyField === CurrencyField.INPUT + + // blocked addresses + const { isBlocked: isActiveBlocked, isBlockedLoading: isActiveBlockedLoading } = useIsBlockedActiveAddress() + const { isBlocked: isRecipientBlocked, isBlockedLoading: isRecipientBlockedLoading } = useIsBlocked(recipient) + const isBlocked = isActiveBlocked || isRecipientBlocked + const isBlockedLoading = isActiveBlockedLoading || isRecipientBlockedLoading + + const onPressReview = useCallback(() => { + const txId = createTransactionId() + dispatch(transactionStateActions.setTxId(txId)) + setScreen(TransferScreen.SendReview) + }, [dispatch, setScreen]) + + const inputShadowProps = { + shadowColor: colors.surface3.val, + shadowRadius: 10, + shadowOpacity: 0.04, + zIndex: 1, + } + + return ( + + {screen === TransferScreen.SendReview && ( + + + + )} + + + onShowTokenSelector(CurrencyField.INPUT)} + /> + {!showTokenSelector && ( + <> + + + + )} + + {!showTokenSelector && ( + <> + + + + {!showRecipientSelector && ( + <> + {isBlocked && ( + + )} + + + + + )} + + )} + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendReviewScreen/SendDetails.tsx b/apps/extension/src/app/features/transfer/SendReviewScreen/SendDetails.tsx new file mode 100644 index 00000000000..87784b0b1e9 --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendReviewScreen/SendDetails.tsx @@ -0,0 +1,198 @@ +import { providers } from 'ethers' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Separator, Text, useSporeColors } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' +import { NumberType } from 'utilities/src/format/types' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { Arrow } from 'wallet/src/components/icons/Arrow' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { NFTTransfer } from 'wallet/src/components/nfts/NFTTransfer' +import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { TransactionDetails } from 'wallet/src/features/transactions/TransactionDetails/TransactionDetails' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { ParsedWarnings } from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { DerivedTransferInfo } from 'wallet/src/features/transactions/transfer/types' +import { AccountType } from 'wallet/src/features/wallet/accounts/types' +import { useActiveAccountWithThrow, useAvatar } from 'wallet/src/features/wallet/hooks' + +interface TransferFormProps { + derivedTransferInfo: DerivedTransferInfo + txRequest?: providers.TransactionRequest + gasFee: GasFeeResult + onReviewSubmit: () => void + warnings: ParsedWarnings +} + +/** + * TODO: MOB-2563 https://linear.app/uniswap/issue/MOB-2563/consolidate-all-transfer-logic-ext-mob + * Re-use this component when implemting shared review UI on mobile, and move to shared package. + */ +export function SendDetails({ + derivedTransferInfo, + gasFee, + onReviewSubmit, + txRequest, + warnings, +}: TransferFormProps): JSX.Element | null { + const { t } = useTranslation() + const { fullHeight } = useDeviceDimensions() + const colors = useSporeColors() + + const { formatCurrencyAmount, formatNumberOrString, convertFiatAmountFormatted } = useLocalizationContext() + + const account = useActiveAccountWithThrow() + + const [showWarningModal, setShowWarningModal] = useState(false) + const currency = useAppFiatCurrencyInfo() + + const onShowWarning = (): void => { + setShowWarningModal(true) + } + + const onCloseWarning = (): void => { + setShowWarningModal(false) + } + + const { + currencyAmounts, + recipient, + isFiatInput = false, + currencyInInfo, + nftIn, + chainId, + exactAmountFiat, + } = derivedTransferInfo + + const { avatar } = useAvatar(recipient) + + const inputCurrencyUSDValue = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + const { blockingWarning } = warnings + + const actionButtonDisabled = + !!blockingWarning || !gasFee.value || !!gasFee.error || !txRequest || account.type === AccountType.Readonly + + const actionButtonProps = { + disabled: actionButtonDisabled, + label: t('send.review.summary.button.title'), + name: ElementName.Send, + onPress: onReviewSubmit, + } + + const transferWarning = warnings.warnings.find((warning) => warning.severity >= WarningSeverity.Medium) + + const formattedCurrencyAmount = formatCurrencyAmount({ + value: currencyAmounts[CurrencyField.INPUT], + type: NumberType.TokenTx, + }) + const formattedAmountIn = isFiatInput + ? formatNumberOrString({ + value: exactAmountFiat, + type: NumberType.FiatTokenQuantity, + currencyCode: currency.code, + }) + : formattedCurrencyAmount + + const formattedInputFiatValue = convertFiatAmountFormatted( + inputCurrencyUSDValue?.toExact(), + NumberType.FiatTokenQuantity, + ) + + if (!recipient) { + throw new Error('Invalid render of SendDetails with no recipient') + } + + return ( + <> + {showWarningModal && transferWarning?.title && ( + + )} + + {currencyInInfo ? ( + + + + + {formattedAmountIn} {!isFiatInput ? currencyInInfo.currency.symbol : ''} + + + {isFiatInput ? ( + + {formattedCurrencyAmount} {currencyInInfo.currency.symbol} + + ) : ( + inputCurrencyUSDValue && ( + + {formattedInputFiatValue} + + ) + )} + + + + ) : ( + nftIn && ( + + + + ) + )} + + + + {recipient && ( + + + + + )} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendReviewScreen/SendReviewScreen.tsx b/apps/extension/src/app/features/transfer/SendReviewScreen/SendReviewScreen.tsx new file mode 100644 index 00000000000..694d145911d --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendReviewScreen/SendReviewScreen.tsx @@ -0,0 +1,112 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { SendDetails } from 'src/app/features/transfer/SendReviewScreen/SendDetails' +import { TransferScreen, useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Flex, Text, TouchableArea } from 'ui/src' +import { X } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { SectionName } from 'uniswap/src/features/telemetry/constants' +import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' +import { currencyAddress } from 'uniswap/src/utils/currencyId' +import { logger } from 'utilities/src/logger/logger' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { + useTransferERC20Callback, + useTransferNFTCallback, +} from 'wallet/src/features/transactions/transfer/hooks/useTransferCallback' + +export function SendReviewScreen(): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + + const { navigateToAccountActivityList } = useWalletNavigation() + + const { derivedTransferInfo, warnings, txRequest, gasFee, setScreen } = useTransferContext() + const { txId, chainId, recipient, currencyInInfo, currencyAmounts, nftIn } = derivedTransferInfo + + // for transfer analytics + const currencyAmountUSD = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + const triggerTransferPendingNotification = useCallback(() => { + if (!currencyInInfo) { + // This should never happen. Just keeping TS happy. + logger.error(new Error('Missing `currencyInInfo` when triggering transfer pending notification'), { + tags: { file: 'SendReviewScreen.tsx', function: 'triggerTransferPendingNotification' }, + }) + } else { + dispatch( + pushNotification({ + type: AppNotificationType.TransferCurrencyPending, + currencyInfo: currencyInInfo, + }), + ) + } + }, [currencyInInfo, dispatch]) + + const onNext = useCallback((): void => { + triggerTransferPendingNotification() + navigateToAccountActivityList() + }, [navigateToAccountActivityList, triggerTransferPendingNotification]) + + const transferERC20Callback = useTransferERC20Callback( + txId, + chainId, + recipient, + currencyInInfo ? currencyAddress(currencyInInfo.currency) : undefined, + currencyAmounts[CurrencyField.INPUT]?.quotient.toString(), + txRequest, + onNext, + currencyAmountUSD, + ) + + const transferNFTCallback = useTransferNFTCallback( + txId, + chainId, + recipient, + nftIn?.nftContract?.address, + nftIn?.tokenId, + txRequest, + onNext, + ) + + const onTransfer = (): void => { + nftIn ? transferNFTCallback?.() : transferERC20Callback?.() + } + + const onPrev = (): void => { + setScreen(TransferScreen.SendForm) + } + + return ( + + + + {t('send.review.modal.title')} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/TransferContext.tsx b/apps/extension/src/app/features/transfer/TransferContext.tsx new file mode 100644 index 00000000000..900c66e0d50 --- /dev/null +++ b/apps/extension/src/app/features/transfer/TransferContext.tsx @@ -0,0 +1,114 @@ +import { TransactionRequest } from '@ethersproject/providers' +import { providers } from 'ethers' +import React, { createContext, ReactNode, useContext, useMemo, useReducer, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AnyAction } from 'redux' +import { TransactionState } from 'uniswap/src/features/transactions/transactionState/types' +import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' +import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' +import { + ParsedWarnings, + useParsedSendWarnings, +} from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' +import { useTransactionGasWarning } from 'wallet/src/features/transactions/hooks/useTransactionGasWarning' +import { + INITIAL_TRANSACTION_STATE, + transactionStateReducer, +} from 'wallet/src/features/transactions/transactionState/transactionState' +import { useDerivedTransferInfo } from 'wallet/src/features/transactions/transfer/hooks/useDerivedTransferInfo' +import { useTransferTransactionRequest } from 'wallet/src/features/transactions/transfer/hooks/useTransferTransactionRequest' +import { useTransferWarnings } from 'wallet/src/features/transactions/transfer/hooks/useTransferWarnings' +import { WarningAction } from 'wallet/src/features/transactions/WarningModal/types' + +export enum TransferScreen { + SendForm, + SendReview, +} + +export enum TransferEntryType { + Fiat, + Crypto, +} + +type TransferContextState = { + screen: TransferScreen + setScreen: (newScreen: TransferScreen) => void + dispatch: React.Dispatch + derivedTransferInfo: ReturnType + gasFee: GasFeeResult + warnings: ParsedWarnings + txRequest: TransactionRequest | undefined +} & TransactionState + +export const TransferContext = createContext(undefined) + +export function TransferContextProvider({ + prefilledTransactionState, + children, +}: { + prefilledTransactionState?: TransactionState + children: ReactNode +}): JSX.Element { + const { t } = useTranslation() + + // state and reducers + const [transferFormState, dispatch] = useReducer(transactionStateReducer, { + ...(prefilledTransactionState ?? INITIAL_TRANSACTION_STATE), + showRecipientSelector: false, + }) + const [screen, setScreen] = useState(TransferScreen.SendForm) + + // derived info based on transfer state + const derivedTransferInfo = useDerivedTransferInfo(transferFormState) + + const warnings = useTransferWarnings(t, derivedTransferInfo) + + const txRequest = useTransferTransactionRequest(derivedTransferInfo) + + const gasFee = useTransactionGasFee( + txRequest, + GasSpeed.Urgent, + warnings.some((warning) => warning.action === WarningAction.DisableReview), + ) + + const txRequestWithGasSettings = useMemo( + (): providers.TransactionRequest => ({ ...txRequest, ...gasFee.params }), + [gasFee.params, txRequest], + ) + + const gasWarning = useTransactionGasWarning({ + derivedInfo: derivedTransferInfo, + gasFee: gasFee?.value, + }) + + const allSendWarnings = useMemo(() => { + return !gasWarning ? warnings : [...warnings, gasWarning] + }, [warnings, gasWarning]) + + const parsedSendWarnings = useParsedSendWarnings(allSendWarnings) + + const state: TransferContextState = useMemo(() => { + return { + derivedTransferInfo, + screen, + setScreen, + dispatch, + gasFee, + warnings: parsedSendWarnings, + txRequest: txRequestWithGasSettings, + ...transferFormState, + } + }, [derivedTransferInfo, gasFee, parsedSendWarnings, screen, transferFormState, txRequestWithGasSettings]) + + return {children} +} + +export const useTransferContext = (): TransferContextState => { + const transferContext = useContext(TransferContext) + + if (transferContext === undefined) { + throw new Error('`useTransferContext` must be used inside of `TransferContextProvider`') + } + + return transferContext +} diff --git a/apps/extension/src/app/features/transfer/TransferFlowScreen.tsx b/apps/extension/src/app/features/transfer/TransferFlowScreen.tsx new file mode 100644 index 00000000000..6a8a3daba90 --- /dev/null +++ b/apps/extension/src/app/features/transfer/TransferFlowScreen.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SendFormScreen } from 'src/app/features/transfer/SendFormScreen/SendFormScreen' +import { TransferContextProvider } from 'src/app/features/transfer/TransferContext' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { X } from 'ui/src/components/icons' + +export function TransferFlowScreen(): JSX.Element { + const { t } = useTranslation() + const { navigateBack, locationState } = useExtensionNavigation() + + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/warnings/StorageWarningModal.tsx b/apps/extension/src/app/features/warnings/StorageWarningModal.tsx new file mode 100644 index 00000000000..dc7c3f5e642 --- /dev/null +++ b/apps/extension/src/app/features/warnings/StorageWarningModal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { useCheckLowStorage } from 'src/app/features/warnings/useCheckLowStorage' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { spacing } from 'ui/src/theme' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' + +export type StorageWarningModalProps = { + isOnboarding: boolean +} +export function StorageWarningModal({ isOnboarding }: StorageWarningModalProps): JSX.Element | null { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + const { showStorageWarning, onStorageWarningClose } = useCheckLowStorage({ isOnboarding }) + + if (!showStorageWarning) { + return null + } + return ( + { + onStorageWarningClose() + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`) + } + } + /> + ) +} diff --git a/apps/extension/src/app/features/warnings/useCheckLowStorage.ts b/apps/extension/src/app/features/warnings/useCheckLowStorage.ts new file mode 100644 index 00000000000..3207d917f53 --- /dev/null +++ b/apps/extension/src/app/features/warnings/useCheckLowStorage.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react' +import { GlobalErrorEvent } from 'src/app/events/constants' +import { globalEventEmitter } from 'src/app/events/global' +import { logger } from 'utilities/src/logger/logger' + +export const REMAINING_STORAGE_THRESHOLD_BYTES = 500000 // 500KB + +export function useCheckLowStorage({ isOnboarding }: { isOnboarding: boolean }): { + showStorageWarning: boolean + onStorageWarningClose: () => void +} { + const [hasShownWarning, setHasShownWarning] = useState(false) + const [showStorageWarning, setShowStorageWarning] = useState(false) + + const onStorageWarningClose = useCallback(() => setShowStorageWarning(false), []) + const triggerStorageWarning = useCallback((): void => { + if (!hasShownWarning) { + setShowStorageWarning(true) + setHasShownWarning(true) + } + }, [hasShownWarning]) + + useEffect(() => { + if (!isOnboarding) { + navigator.storage + .estimate() + .then(({ quota }) => { + if (quota && quota < REMAINING_STORAGE_THRESHOLD_BYTES) { + triggerStorageWarning() + logger.info('useCheckLowStorage.ts', 'useCheckLowStorage', 'Low storage warning shown') + } + }) + .catch(() => {}) + } + }, [isOnboarding, triggerStorageWarning]) + + useEffect(() => { + const listener = (): void => { + triggerStorageWarning() + } + globalEventEmitter.addListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + return () => { + globalEventEmitter.removeListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + } + }, [hasShownWarning, triggerStorageWarning]) + + return { showStorageWarning, onStorageWarningClose } +} diff --git a/apps/extension/src/app/hooks/useIsWalletUnlocked.ts b/apps/extension/src/app/hooks/useIsWalletUnlocked.ts new file mode 100644 index 00000000000..15dc368a865 --- /dev/null +++ b/apps/extension/src/app/hooks/useIsWalletUnlocked.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' +import { logger } from 'utilities/src/logger/logger' +import { useAsyncData } from 'utilities/src/react/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { ENCRYPTION_KEY_STORAGE_KEY, PersistedStorage } from 'wallet/src/utils/persistedStorage' + +/** + * In order to speed up the initial load of the app and avoid a half a second loading spinner every time the sidebar opens, + * we will first do a quick light check to see if the wallet *might* be unlocked by simply checking if the encryption key + * exists in local storage, but without actually verifying that this key is valid. + * + * After the React app fully loads, we will then do a more thorough check to see if the wallet is actually unlocked. + */ + +// exported to be used in saga's +export let isWalletUnlocked: boolean | null = null + +const sessionStorage = new PersistedStorage('session') + +sessionStorage + .getItem(ENCRYPTION_KEY_STORAGE_KEY) + .then((val) => { + isWalletUnlocked = val !== undefined + }) + .catch((err) => { + logger.error(err, { + tags: { + file: 'useIsWalletUnlocked.ts', + function: 'sessionStorage.getItem', + }, + }) + }) + +export function useIsWalletUnlocked(): boolean | null { + const [isUnlocked, setIsUnlocked] = useState(isWalletUnlocked) + + const checkWalletStatus = useCallback(async () => { + isWalletUnlocked = await Keyring.isUnlocked() + setIsUnlocked(isWalletUnlocked) + }, []) + + useEffect(() => { + const listener: Parameters[0] = async (changes, namespace) => { + if (namespace === 'session' && changes[ENCRYPTION_KEY_STORAGE_KEY]) { + await checkWalletStatus() + } + } + + chrome.storage.onChanged.addListener(listener) + + return () => { + chrome.storage.onChanged.removeListener(listener) + } + }, [checkWalletStatus]) + + useAsyncData(checkWalletStatus) + + return isUnlocked +} diff --git a/apps/extension/src/app/hooks/useOnCopyToClipboard.tsx b/apps/extension/src/app/hooks/useOnCopyToClipboard.tsx new file mode 100644 index 00000000000..88e3eb1de2e --- /dev/null +++ b/apps/extension/src/app/hooks/useOnCopyToClipboard.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' + +export function useCopyToClipboard(): ({ + textToCopy, + copyType, +}: { + textToCopy: string + copyType: CopyNotificationType +}) => Promise { + const dispatch = useDispatch() + + const copyToClipboard = useCallback( + async ({ textToCopy, copyType }: { textToCopy: string; copyType: CopyNotificationType }) => { + try { + await navigator.clipboard.writeText(textToCopy) + + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType, + }), + ) + } catch (e) { + dispatch( + pushNotification({ + type: AppNotificationType.CopyFailed, + copyType, + }), + ) + } + }, + [dispatch], + ) + + return copyToClipboard +} diff --git a/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts new file mode 100644 index 00000000000..d30b4afec5b --- /dev/null +++ b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts @@ -0,0 +1,152 @@ +import { State, useOpeningKeyboardShortCut } from 'src/app/hooks/useOpeningKeyboardShortCut' +import * as isAppleDeviceDep from 'src/app/utils/isAppleDevice' +import { act, renderHook } from 'src/test/test-utils' + +jest.mock('src/app/utils/isAppleDevice', () => ({ + isAppleDevice: jest.fn(), +})) + +const isAppleDevice = isAppleDeviceDep.isAppleDevice as jest.MockedFunction + +describe('useOpeningKeyboardShortCut', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should initialize with the correct keys for an Apple device', () => { + isAppleDevice.mockReturnValue(true) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing16', + title: 'Meta', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should initialize with the correct keys for a non-Apple device', () => { + isAppleDevice.mockReturnValue(false) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should handle keyDown and keyUp events', () => { + isAppleDevice.mockReturnValue(false) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })) + }) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyDown, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + + act(() => { + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' })) + }) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should highlight keys when shortCutPressed is true', () => { + isAppleDevice.mockReturnValue(false) + const { result, rerender } = renderHook((props) => useOpeningKeyboardShortCut(props), { + initialProps: false, + }) + + rerender(true) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.Highlighted, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.Highlighted, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.Highlighted, + }, + ]) + }) +}) diff --git a/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts new file mode 100644 index 00000000000..87433f2d9c0 --- /dev/null +++ b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts @@ -0,0 +1,77 @@ +import { useEffect, useReducer } from 'react' +import { KeyboardKeyProps } from 'src/app/features/onboarding/KeyboardKey' +import { isAppleDevice } from 'src/app/utils/isAppleDevice' + +const KEY_LONG_TEXT_FONT_SIZE = 28 +const KEY_SHORT_TEXT_FONT_SIZE = 41 + +// export for tests +export enum State { + KeyUp, + KeyDown, + Highlighted, +} + +type ReducerAction = { type: 'keyUp' | 'keyDown' | 'highlight'; key: string } | { type: 'highlight' } + +export const useOpeningKeyboardShortCut = (shortCutPressed: boolean): KeyboardKeyProps[] => { + const reducer = (state: KeyboardKeyProps[], action: ReducerAction): KeyboardKeyProps[] => { + switch (action.type) { + case 'keyDown': + return state.map((key) => (key.title.toLowerCase() === action.key ? { ...key, state: State.KeyDown } : key)) + case 'keyUp': + return state.map((key) => + key.title.toLowerCase() === action.key || + // after pressing Cmd+ keyUp event would only be fired for Cmd, this would "simulate" keyDown for letter + // context: https://github.com/electron/electron/issues/5188 + (action.key === 'meta' && key.title.length === 1) + ? { ...key, state: shortCutPressed ? State.Highlighted : State.KeyUp } + : key, + ) + case 'highlight': + return state.map((key) => ({ ...key, state: State.Highlighted })) + } + } + + const [keys, dispatch] = useReducer(reducer, [ + { + fontSize: KEY_LONG_TEXT_FONT_SIZE, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + isAppleDevice() + ? { + fontSize: KEY_SHORT_TEXT_FONT_SIZE, + px: '$spacing16', + title: 'Meta', + state: State.KeyUp, + } + : { + fontSize: KEY_LONG_TEXT_FONT_SIZE, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { fontSize: KEY_SHORT_TEXT_FONT_SIZE, px: '$spacing24', title: 'U', state: State.KeyUp }, + ]) + + useEffect(() => { + if (shortCutPressed) { + dispatch({ type: 'highlight' }) + } + }, [shortCutPressed]) + + useEffect(() => { + const keyDownHandler = (event: KeyboardEvent): void => dispatch({ type: 'keyDown', key: event.key.toLowerCase() }) + const keyUpHandler = (event: KeyboardEvent): void => dispatch({ type: 'keyUp', key: event.key.toLowerCase() }) + window.addEventListener('keydown', keyDownHandler) + window.addEventListener('keyup', keyUpHandler) + + return () => { + window.removeEventListener('keydown', keyDownHandler) + window.removeEventListener('keyup', keyUpHandler) + } + }, []) + return keys +} diff --git a/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx b/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx new file mode 100644 index 00000000000..083985c2466 --- /dev/null +++ b/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react' +import { createSearchParams } from 'react-router-dom' +import { getRouter } from 'src/app/navigation/state' +import { sleep } from 'utilities/src/time/timing' + +const getSearchParams = (): URLSearchParams => createSearchParams(new URLSearchParams(window.location.hash.slice(2))) + +/** + * It's just like useSearchParams but avoids re-rendering on every page navigation + */ + +export function useOptimizedSearchParams(): URLSearchParams { + const [searchParams, setSearchParams] = useState(getSearchParams) + + useEffect(() => { + return getRouter().subscribe(async () => { + // react-router-dom calls this before it actually updates the url bar :/ + await sleep(0) + setSearchParams((prev) => { + const next = getSearchParams() + if (prev.toString() !== next.toString()) { + return next + } + return prev + }) + }) + }, []) + + return searchParams +} diff --git a/apps/extension/src/app/hooks/useSagaStatus.ts b/apps/extension/src/app/hooks/useSagaStatus.ts new file mode 100644 index 00000000000..42298cfa2f5 --- /dev/null +++ b/apps/extension/src/app/hooks/useSagaStatus.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { monitoredSagas } from 'src/app/saga' +import { ExtensionState } from 'src/store/extensionReducer' +import { SagaState, SagaStatus } from 'wallet/src/utils/saga' + +// Convenience hook to get the status + error of an active saga +export function useSagaStatus(sagaName: string, onSuccess?: () => void, resetSagaOnSuccess = true): SagaState { + const dispatch = useDispatch() + const sagaState = useSelector((s: ExtensionState): SagaState | undefined => s.saga[sagaName]) + if (!sagaState) { + throw new Error(`No saga state found, is sagaName valid? Name: ${sagaName}`) + } + + const saga = monitoredSagas[sagaName] + if (!saga) { + throw new Error(`No saga found, is sagaName valid? Name: ${sagaName}`) + } + + const { status, error } = sagaState + + useEffect(() => { + if (status === SagaStatus.Success) { + if (resetSagaOnSuccess) { + dispatch(saga.actions.reset()).catch(() => undefined) + } + onSuccess?.() + } + }, [saga, status, error, onSuccess, resetSagaOnSuccess, dispatch]) + + useEffect(() => { + return () => { + if (resetSagaOnSuccess) { + dispatch(saga.actions.reset()).catch(() => undefined) + } + } + }, [saga, resetSagaOnSuccess, dispatch]) + + return sagaState +} diff --git a/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx b/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx new file mode 100644 index 00000000000..a91cb1c1689 --- /dev/null +++ b/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren, useEffect } from 'react' +import { Flex } from 'ui/src' +import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' +import { ONE_MINUTE_MS } from 'utilities/src/time/time' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' + +// The sidebar becomes "inactive" when this amount of time has passed since the window lost focus. +const INACTIVITY_TIMEOUT = 15 * ONE_MINUTE_MS + +export function HideContentsWhenSidebarBecomesInactive({ children }: PropsWithChildren): JSX.Element { + const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(INACTIVITY_TIMEOUT) + + const { navigateToAccountTokenList } = useWalletNavigation() + + useEffect(() => { + if (!isChromeWindowFocused) { + // We navigate to the homepage because we'll lose the local state when the sidebar becomes active again, + // and we want to avoid the user making mistakes because their swap/flow state was lost. + navigateToAccountTokenList() + } + }, [isChromeWindowFocused, navigateToAccountTokenList]) + + return isChromeWindowFocused ? ( + <>{children} + ) : ( + + + + ) +} diff --git a/apps/extension/src/app/navigation/SideBarNavigationProvider.tsx b/apps/extension/src/app/navigation/SideBarNavigationProvider.tsx new file mode 100644 index 00000000000..a8398ccb216 --- /dev/null +++ b/apps/extension/src/app/navigation/SideBarNavigationProvider.tsx @@ -0,0 +1,186 @@ +import { PropsWithChildren, useCallback } from 'react' +import { createSearchParams, useNavigate } from 'react-router-dom' +import { navigateToInterfaceFiatOnRamp } from 'src/app/features/for/utils' +import { useCopyToClipboard } from 'src/app/hooks/useOnCopyToClipboard' +import { AppRoutes, HomeQueryParams, HomeTabs } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { SidebarLocationState, focusOrCreateTokensExploreTab } from 'src/app/navigation/utils' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { logger } from 'utilities/src/logger/logger' +import { + NavigateToFiatOnRampArgs, + NavigateToNftItemArgs, + NavigateToSendFlowArgs, + NavigateToSwapFlowArgs, + ShareNftArgs, + ShareTokenArgs, + WalletNavigationProvider, + getNavigateToSendFlowArgsInitialState, + getNavigateToSwapFlowArgsInitialState, +} from 'wallet/src/contexts/WalletNavigationContext' +import { CopyNotificationType } from 'wallet/src/features/notifications/types' +import { ExplorerDataType, getExplorerLink, getNftUrl, getTokenUrl } from 'wallet/src/utils/linking' + +export function SideBarNavigationProvider({ children }: PropsWithChildren): JSX.Element { + const handleShareNft = useHandleShareNft() + const handleShareToken = useHandleShareToken() + const navigateToAccountActivityList = useNavigateToAccountActivityList() + const navigateToAccountTokenList = useNavigateToAccountTokenList() + const navigateToBuyOrReceiveWithEmptyWallet = useNavigateToBuyOrReceiveWithEmptyWallet() + const navigateToNftDetails = useNavigateToNftDetails() + const navigateToReceive = useNavigateToReceive() + const navigateToSend = useNavigateToSend() + const navigateToSwapFlow = useNavigateToSwapFlow() + const navigateToTokenDetails = useNavigateToTokenDetails() + const navigateToNftCollection = useCallback(() => { + // no-op until we have proper NFT collection + }, []) + const navigateToFiatOnRamp = useNavigateToFiatOnRamp() + + return ( + + {children} + + ) +} + +function useHandleShareNft(): (args: ShareNftArgs) => void { + const copyToClipboard = useCopyToClipboard() + + return useCallback( + async ({ contractAddress, tokenId }: ShareNftArgs): Promise => { + const url = getNftUrl(contractAddress, tokenId) + + await copyToClipboard({ textToCopy: url, copyType: CopyNotificationType.NftUrl }) + + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.NftItem, + url, + }) + }, + [copyToClipboard], + ) +} + +function useHandleShareToken(): (args: ShareTokenArgs) => void { + const copyToClipboard = useCopyToClipboard() + + return useCallback( + async ({ currencyId }: ShareTokenArgs): Promise => { + const url = getTokenUrl(currencyId) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'SideBarNavigationProvider.tsx', function: 'useHandleShareToken' }, + extra: { currencyId }, + }) + return + } + + await copyToClipboard({ textToCopy: url, copyType: CopyNotificationType.TokenUrl }) + + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.Token, + url, + }) + }, + [copyToClipboard], + ) +} + +function useNavigateToAccountActivityList(): () => void { + // TODO(EXT-1029): determine why we need useNavigate here + const navigateFix = useNavigate() + + return useCallback( + (): void => + navigateFix({ + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: HomeTabs.Activity, + }).toString(), + }), + [navigateFix], + ) +} + +function useNavigateToAccountTokenList(): () => void { + // TODO(EXT-1029): determine why we need useNavigate here + const navigateFix = useNavigate() + + return useCallback( + (): void => + navigateFix({ + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: HomeTabs.Tokens, + }).toString(), + }), + [navigateFix], + ) +} + +function useNavigateToReceive(): () => void { + return useCallback((): void => navigate(AppRoutes.Receive), []) +} + +function useNavigateToSend(): (args: NavigateToSendFlowArgs) => void { + return useCallback((args: NavigateToSendFlowArgs): void => { + const initialState = getNavigateToSendFlowArgsInitialState(args) + + const state: SidebarLocationState = args ? { initialTransactionState: initialState } : undefined + + navigate(AppRoutes.Transfer, { state }) + }, []) +} + +function useNavigateToSwapFlow(): (args: NavigateToSwapFlowArgs) => void { + return useCallback((args: NavigateToSwapFlowArgs): void => { + const initialState = getNavigateToSwapFlowArgsInitialState(args) + + const state: SidebarLocationState = initialState ? { initialTransactionState: initialState } : undefined + + navigate(AppRoutes.Swap, { state }) + }, []) +} + +function useNavigateToTokenDetails(): (currencyId: string) => void { + return useCallback(async (currencyId: string): Promise => { + await focusOrCreateTokensExploreTab({ currencyId }) + }, []) +} + +function useNavigateToNftDetails(): (args: NavigateToNftItemArgs) => void { + return useCallback(({ address, tokenId, chainId }: NavigateToNftItemArgs): void => { + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(getExplorerLink(chainId ?? UniverseChainId.Mainnet, `${address}/${tokenId}`, ExplorerDataType.NFT)) + }, []) +} + +function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { + return useCallback((): void => { + navigateToInterfaceFiatOnRamp() + }, []) +} + +function useNavigateToFiatOnRamp(): (args: NavigateToFiatOnRampArgs) => void { + return useCallback(({ prefilledCurrency }: NavigateToFiatOnRampArgs): void => { + navigateToInterfaceFiatOnRamp(prefilledCurrency?.currencyInfo?.currency.chainId) + }, []) +} diff --git a/apps/extension/src/app/navigation/constants.ts b/apps/extension/src/app/navigation/constants.ts new file mode 100644 index 00000000000..7ada8b59b50 --- /dev/null +++ b/apps/extension/src/app/navigation/constants.ts @@ -0,0 +1,42 @@ +export { HomeTabs } from 'uniswap/src/types/screens/extension' + +export enum TopLevelRoutes { + Onboarding = 'onboarding', + Notifications = 'notifications', +} + +export enum OnboardingRoutes { + Import = 'import', + Create = 'create', + Scan = 'scan', + Reset = 'reset', + ResetScan = 'reset-scan', + UnsupportedBrowser = 'unsupported-browser', +} + +export enum AppRoutes { + AccountSwitcher = 'account-switcher', + Home = '', + Receive = 'receive', + Requests = 'requests', + Settings = 'settings', + Swap = 'swap', + Transfer = 'transfer', +} + +export enum HomeQueryParams { + Tab = 'tab', +} + +export enum SettingsRoutes { + ChangePassword = 'change-password', + DevMenu = 'dev-menu', + ViewRecoveryPhrase = 'view-recovery-phrase', + RemoveRecoveryPhrase = 'remove-recovery-phrase', + Privacy = 'privacy', +} + +export enum RemoveRecoveryPhraseRoutes { + Wallets = 'wallets', + Verify = 'verify', +} diff --git a/apps/extension/src/app/navigation/index.tsx b/apps/extension/src/app/navigation/index.tsx new file mode 100644 index 00000000000..2708834cef6 --- /dev/null +++ b/apps/extension/src/app/navigation/index.tsx @@ -0,0 +1,245 @@ +import { useCallback, useMemo, useRef } from 'react' +import { useSelector } from 'react-redux' +import { Outlet, useLocation } from 'react-router-dom' +import { DappRequestQueueProvider } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { DappRequestWrapper } from 'src/app/features/dappRequests/DappRequestWrapper' +import { HomeScreen } from 'src/app/features/home/HomeScreen' +import { Locked } from 'src/app/features/lockScreen/Locked' +import { NotificationToastWrapper } from 'src/app/features/notifications/NotificationToastWrapper' +import { StorageWarningModal } from 'src/app/features/warnings/StorageWarningModal' +import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' +import { HideContentsWhenSidebarBecomesInactive } from 'src/app/navigation/HideContentsWhenSidebarBecomesInactive' +import { SideBarNavigationProvider } from 'src/app/navigation/SideBarNavigationProvider' +import { AppRoutes } from 'src/app/navigation/constants' +import { useRouterState } from 'src/app/navigation/state' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { ExtensionState } from 'src/store/extensionReducer' +import { AnimatePresence, Flex, SpinningLoader, styled } from 'ui/src' +import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' +import { useAsyncData } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { TransactionHistoryUpdater } from 'wallet/src/features/transactions/TransactionHistoryUpdater' +import { QueuedOrderModal } from 'wallet/src/features/transactions/swap/modals/QueuedOrderModal' + +export function MainContent(): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + + if (!isOnboarded) { + // TODO: add an error state that takes the user to fullscreen onboarding + throw new Error('you should have onboarded') + } + + return ( + <> + + + + ) +} + +enum Direction { + Left = 'left', + Right = 'right', + Up = 'up', + Down = 'down', +} + +const oppositeDirection = { + [Direction.Left]: Direction.Right, + [Direction.Right]: Direction.Left, + [Direction.Up]: Direction.Down, + [Direction.Down]: Direction.Up, +} + +// default is Right +const routeDirections = { + [AppRoutes.AccountSwitcher]: Direction.Up, + [AppRoutes.Swap]: Direction.Down, + [AppRoutes.Home]: Direction.Right, + [AppRoutes.Requests]: Direction.Right, + [AppRoutes.Receive]: Direction.Down, + [AppRoutes.Settings]: Direction.Right, + [AppRoutes.Transfer]: Direction.Down, +} satisfies Record + +const getAppRouteFromPathName = (pathname: string): AppRoutes | null => { + const val = (pathname.split('/')[1] || '') as AppRoutes + if (Object.values(AppRoutes).includes(val)) { + return val + } + return null +} + +export function WebNavigation(): JSX.Element { + const isLoggedIn = useIsWalletUnlocked() + const { pathname } = useLocation() + const history = useRef([]).current + if (history[0] !== pathname) { + history.unshift(pathname) + } + + let towards = Direction.Right + const routeName = getAppRouteFromPathName(pathname) + const routerState = useRouterState() + if (routeName != null) { + towards = routeDirections[routeName] + const isBackwards = routerState?.historyAction === 'POP' + if (isBackwards) { + const lastRoute = getAppRouteFromPathName(history[1] || '') + const previousDirection = lastRoute ? routeDirections[lastRoute] : 'right' + towards = oppositeDirection[previousDirection] + } + } + + const childrenMemo = useMemo(() => { + return ( + + + + {isLoggedIn === null ? ( + + ) : isLoggedIn === true ? ( + + + + ) : ( + + )} + + + + ) + }, [isLoggedIn, pathname, towards]) + + return ( + + + {childrenMemo} + + ) +} + +// TODO(EXT-994): improve this loading screen. +function Loading(): JSX.Element { + return ( + + + + ) +} + +const AnimatedPane = styled(Flex, { + zIndex: 1, + fill: true, + position: 'absolute', + inset: 0, + x: 0, + opacity: 1, + maxWidth: 'calc(min(495px, 100vw))', + minHeight: '100vh', + mx: 'auto', + width: '100%', + + variants: { + towards: (dir: Direction) => ({ + enterStyle: { + x: isVertical(dir) ? 0 : dir === 'right' ? 30 : -30, + y: !isVertical(dir) ? 0 : dir === 'down' ? 15 : -15, + opacity: 0, + zIndex: 1, + }, + exitStyle: { + zIndex: 0, + x: isVertical(dir) ? 0 : dir === 'left' ? 30 : -30, + y: !isVertical(dir) ? 0 : dir === 'up' ? 15 : -15, + opacity: 0, + }, + }), + } as const, +}) + +const isVertical = (dir: Direction): boolean => dir === 'up' || dir === 'down' + +function useConstant(c: A): A { + const out = useRef() + if (!out.current) { + out.current = c + } + return out.current +} + +function LoggedIn(): JSX.Element { + /** + * + * So, rendering directly means the internal hooks in Outlet + * will update instantly on page change, but we don't want that. + * + * Instead we run an animation on page change and keep the old page around + * until the animation completes. + * + * So what this does is "unwraps" the Outlet component in a sense, the hooks + * actually run inside *this* component instead of inside the sub-component + * Outlet. + * + * Then we wrap that in `useConstant` so it never changes. + * + * This makes it so the old page doesn't render with the new page contents + * as it does its exit animation. + * + **/ + const outletContents = Outlet({}) + const contents = useConstant(outletContents) + const pendingDappRequests = useSelector((state: ExtensionState) => state.dappRequests.pending) + const areRequestsPending = pendingDappRequests.length > 0 + + // To avoid excessive API calls, we pause the transaction history updater a short time after the window loses focus. + const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(30 * ONE_SECOND_MS) + + return ( + <> + {contents} + + + + {isChromeWindowFocused && } + + {areRequestsPending && ( + + + + )} + + ) +} + +function LoggedOut(): JSX.Element { + const isOnboarded = useSelector(isOnboardedSelector) + const didOpenOnboarding = useRef(false) + + const handleOnboarding = useCallback(async () => { + if (!isOnboarded && !didOpenOnboarding.current) { + // We keep track of this to avoid opening the onboarding page multiple times if this component remounts. + didOpenOnboarding.current = true + await focusOrCreateOnboardingTab() + // Automatically close the pop up after focusing on the onboarding tab. + window.close() + } + }, [isOnboarded]) + + useAsyncData(handleOnboarding) + + // If the user has not onboarded, we render nothing and let the `useEffect` above automatically close the popup. + // We could consider showing a loading spinner while the popup is being closed. + return isOnboarded ? : <> +} diff --git a/apps/extension/src/app/navigation/state.ts b/apps/extension/src/app/navigation/state.ts new file mode 100644 index 00000000000..1dc49187dc2 --- /dev/null +++ b/apps/extension/src/app/navigation/state.ts @@ -0,0 +1,86 @@ +import { RouterState } from '@sentry/react/types/types' +import { useEffect, useState } from 'react' +import { Router } from 'react-router-dom' +import { sentryCreateHashRouter } from 'src/app/sentry' + +/** + * Note this file is separate from SidebarApp on purpose! + * + * Because the router imports all the top-level pages, you can't import it from + * below those pages without causing circular imports. + * + * Circular imports break many things - HMR, bundle splitting, tree shaking, + * etc. + * + * So instead we use this file as a way to "push" the router into an import that + * is safe from circularity. + */ + +type RouterStateListener = (state: RouterState) => void + +let state: RouterState | null = null + +const listeners = new Set() + +export function setRouterState(next: RouterState): void { + state = next + listeners.forEach((l) => l(next)) +} + +export function getRouterState(): RouterState | null { + return state +} + +export function subscribeToRouterState(listener: RouterStateListener): () => void { + listeners.add(listener) + + if (state) { + listener(state) + } + + return () => { + listeners.delete(listener) + } +} + +export function useRouterState(): RouterState | null { + const [val, setVal] = useState(state) + + useEffect(() => { + return subscribeToRouterState(setVal) + }, []) + + return val +} + +// as far as i can tell, react-router-dom doesn't give us this type so have to work around +type Router = ReturnType + +let router: Router | null = null + +export function setRouter(next: Router): void { + router = next +} + +export function getRouter(): Router { + if (!router) { + throw new Error('Invalid call to `getRouter` before the router was initialized') + } + return router +} + +type RouterNavigate = Router['navigate'] +type RouterNavigateArgs = Parameters + +// this is a navigate that doesn't need any useNavigate() hook, which in react router has performance issues: +// https://github.com/remix-run/react-router/issues/7634#issuecomment-1306650156 +// note: useNavigation().navigate() returns void, so making this match that function for easier swapping out +export const navigate = (to: RouterNavigateArgs[0] | number, opts?: RouterNavigateArgs[1]): void => { + if (typeof to === 'number') { + // eslint-disable-next-line no-void + void getRouter().navigate(to) + return + } + // eslint-disable-next-line no-void + void getRouter().navigate(to, opts) +} diff --git a/apps/extension/src/app/navigation/utils.ts b/apps/extension/src/app/navigation/utils.ts new file mode 100644 index 00000000000..f690045e889 --- /dev/null +++ b/apps/extension/src/app/navigation/utils.ts @@ -0,0 +1,185 @@ +import { To, matchPath, useLocation } from 'react-router-dom' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { TransactionState } from 'uniswap/src/features/transactions/transactionState/types' +import { logger } from 'utilities/src/logger/logger' +import { escapeRegExp } from 'utilities/src/primitives/string' +import { getTokenUrl } from 'wallet/src/utils/linking' + +export type SidebarLocationState = + | { + initialTransactionState?: TransactionState + } + | undefined + +export function useRouteMatch(pathToMatch: string): boolean { + const { pathname } = useLocation() + + return !!matchPath(pathToMatch, pathname) +} + +export const useExtensionNavigation = (): { + navigateTo: (path: To) => void + navigateBack: () => void + locationState: SidebarLocationState +} => { + const navigateTo = (path: To): void => navigate(path) + const navigateBack = (): void => navigate(-1) + const locationState = useLocation().state as SidebarLocationState + + return { navigateTo, navigateBack, locationState } +} + +export async function focusOrCreateOnboardingTab(page?: string): Promise { + const extension = await chrome.management.getSelf() + + const tabs = await chrome.tabs.query({ url: `chrome-extension://${extension.id}/onboarding.html*` }) + const tab = tabs[0] + + const url = 'onboarding.html#/' + (page ? page : TopLevelRoutes.Onboarding) + + if (!tab?.id) { + await chrome.tabs.create({ url }) + return + } + + await chrome.tabs.update(tab.id, { + active: true, + highlighted: true, + // We only want to update the URL if we're navigating to a specific page. + // Otherwise, just focus the existing tab without overriding the current URL. + url: page ? url : undefined, + }) + + if (page) { + // When navigating to a specific page, we need to reload the tab to ensure that the app state is reset and the store synchronization is properly initialized. + // This is necessary to handle the edge case where the user leaves a completed onboarding tab open (with synchronization paused) + // and then clicks on the "forgot password" link. + await chrome.tabs.reload(tab.id) + } + + await chrome.windows.update(tab.windowId, { focused: true }) + + await onboardingMessageChannel.sendMessage({ + type: OnboardingMessageType.HighlightOnboardingTab, + }) +} + +export async function focusOrCreateDappRequestWindow(tabId: number | undefined, windowId: number): Promise { + const extension = await chrome.management.getSelf() + + const window = await chrome.windows.getCurrent() + + const tabs = await chrome.tabs.query({ url: `chrome-extension://${extension.id}/popup.html*` }) + const tab = tabs[0] + + // Centering within current window + const height = 410 + const width = 330 + const top = Math.round((window.top ?? 0) + ((window.height ?? height) - height) / 2) + const left = Math.round((window.left ?? 0) + ((window.width ?? width) - width) / 2) + let url = `popup.html?windowId=${windowId}` + if (tabId) { + url += `&tabId=${tabId}` + } + + if (!tab?.id) { + await chrome.windows.create({ + url, + type: 'popup', + top, + left, + width, + height, + }) + return + } + + await chrome.tabs.update(tab.id, { + url, + active: true, + highlighted: true, + }) + await chrome.windows.update(tab.windowId, { focused: true, top, left, width, height }) +} + +/** + * To avoid opening too many tabs while also ensuring that we don't take over the user's active tab, + * we only update the URL of the active tab if it's already in a specific route of the Uniswap interface. + * + * If the current tab is not in that route, we open a new tab instead. + */ +export async function focusOrCreateUniswapInterfaceTab({ + url, + reuseActiveTabIfItMatches, +}: { + url: string + reuseActiveTabIfItMatches?: RegExp +}): Promise { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) + + const activeTab = tabs[0] + const activeTabUrl = activeTab?.url + + const isInNewTabPage = activeTabUrl === 'chrome://newtab/' + + const shouldReuseActiveTab = reuseActiveTabIfItMatches + ? activeTabUrl && reuseActiveTabIfItMatches?.test(activeTabUrl) + : false + + if (activeTab?.id && (shouldReuseActiveTab || isInNewTabPage)) { + await chrome.tabs.update(activeTab.id, { + active: true, + highlighted: true, + url, + }) + return + } + + await chrome.tabs.create({ url }) +} + +export async function focusOrCreateTokensExploreTab({ currencyId }: { currencyId: string }): Promise { + const url = getTokenUrl(currencyId) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'navigation/utils.ts', function: 'focusOrCreateTokensExploreTab' }, + extra: { currencyId }, + }) + return + } + + return focusOrCreateUniswapInterfaceTab({ + url, + // We want to reuse the active tab only if it's already in any other TDP. + // eslint-disable-next-line security/detect-non-literal-regexp + reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfaceTokensUrl)}`), + }) +} + +export async function focusOrCreateNftItemTab({ + address, + tokenId, +}: { + address: string + tokenId: string +}): Promise { + return focusOrCreateUniswapInterfaceTab({ + url: `${uniswapUrls.webInterfaceNftItemUrl}/${address}/${tokenId}`, + // We want to reuse the active tab only if it's already in any other NFT item page. + // eslint-disable-next-line security/detect-non-literal-regexp + reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfaceNftItemUrl)}`), + }) +} + +export async function getCurrentTabAndWindowId(): Promise<{ tabId: number; windowId: number }> { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) + if (tabs.length === 0 || !tabs[0] || typeof tabs[0].id !== 'number' || typeof tabs[0].windowId !== 'number') { + throw new Error('No active tab found or missing tab/window ID') + } + return { tabId: tabs[0].id, windowId: tabs[0].windowId } +} diff --git a/apps/extension/src/app/saga.ts b/apps/extension/src/app/saga.ts new file mode 100644 index 00000000000..86d67b549dd --- /dev/null +++ b/apps/extension/src/app/saga.ts @@ -0,0 +1,99 @@ +import { initDappStore } from 'src/app/features/dapp/saga' +import { dappRequestApprovalWatcher } from 'src/app/features/dappRequests/dappRequestApprovalWatcherSaga' +import { dappRequestWatcher } from 'src/app/features/dappRequests/saga' +import { call, spawn } from 'typed-redux-saga' +import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient' +import { authActions, authReducer, authSaga, authSagaName } from 'wallet/src/features/auth/saga' +import { appLanguageWatcherSaga } from 'wallet/src/features/language/saga' +import { initProviders } from 'wallet/src/features/providers' +import { swapActions, swapReducer, swapSaga, swapSagaName } from 'wallet/src/features/transactions/swap/swapSaga' +import { + tokenWrapActions, + tokenWrapReducer, + tokenWrapSaga, + tokenWrapSagaName, +} from 'wallet/src/features/transactions/swap/wrapSaga' +import { transactionWatcher, watchTransactionEvents } from 'wallet/src/features/transactions/transactionWatcherSaga' +import { + editAccountActions, + editAccountReducer, + editAccountSaga, + editAccountSagaName, +} from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { + createAccountsActions, + createAccountsReducer, + createAccountsSaga, + createAccountsSagaName, +} from 'wallet/src/features/wallet/create/createAccountsSaga' +import { MonitoredSaga, getMonitoredSagaReducers } from 'wallet/src/state/saga' + +// Stateful sagas that are registered with the store on startup +export const monitoredSagas: Record = { + [authSagaName]: { + name: authSagaName, + wrappedSaga: authSaga, + reducer: authReducer, + actions: authActions, + }, + [createAccountsSagaName]: { + name: createAccountsSagaName, + wrappedSaga: createAccountsSaga, + reducer: createAccountsReducer, + actions: createAccountsActions, + }, + [editAccountSagaName]: { + name: editAccountSagaName, + wrappedSaga: editAccountSaga, + reducer: editAccountReducer, + actions: editAccountActions, + }, + [swapSagaName]: { + name: swapSagaName, + wrappedSaga: swapSaga, + reducer: swapReducer, + actions: swapActions, + }, + [tokenWrapSagaName]: { + name: tokenWrapSagaName, + wrappedSaga: tokenWrapSaga, + reducer: tokenWrapReducer, + actions: tokenWrapActions, + }, +} as const + +const sagasInitializedOnStartup = [ + appLanguageWatcherSaga, + initDappStore, + dappRequestApprovalWatcher, + dappRequestWatcher, + initProviders, + watchTransactionEvents, +] as const + +export const monitoredSagaReducers = getMonitoredSagaReducers(monitoredSagas) + +export function* webRootSaga() { + for (const s of sagasInitializedOnStartup) { + yield* spawn(s) + } + + const apolloClient = yield* call(apolloClientRef.onReady) + yield* spawn(transactionWatcher, { apolloClient }) + + for (const m of Object.values(monitoredSagas)) { + yield* spawn(m.wrappedSaga) + } +} + +const onboardingSagasInitializedOnStartup = [initProviders] as const + +export function* onboardingRootSaga() { + for (const s of onboardingSagasInitializedOnStartup) { + yield* spawn(s) + } + + for (const m of Object.values(monitoredSagas)) { + yield* spawn(m.wrappedSaga) + } +} diff --git a/apps/extension/src/app/sentry.ts b/apps/extension/src/app/sentry.ts new file mode 100644 index 00000000000..513cb535205 --- /dev/null +++ b/apps/extension/src/app/sentry.ts @@ -0,0 +1,89 @@ +import * as SentryBrowser from '@sentry/browser' +import * as Sentry from '@sentry/react' +import { setTag } from '@sentry/react' +import { useEffect } from 'react' +import { + createHashRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom' +import { getSentryEnvironment } from 'src/app/version' +import { config } from 'uniswap/src/config' +import { logger } from 'utilities/src/logger/logger' +import { beforeSend } from 'wallet/src/utils/sentry' + +export const enum SentryAppNameTag { + Sidebar = 'sidebar', + Onboarding = 'onboarding', + ContentScript = 'content-script', + Background = 'background', + Popup = 'popup', +} + +export function initializeSentry(appNameTag: SentryAppNameTag, sentryUserId: string): void { + if (__DEV__) { + return + } + Sentry.init({ + environment: getSentryEnvironment(), + dsn: config.sentryDsn, + release: process.env.VERSION, + integrations: [ + new Sentry.BrowserTracing({ + // See docs for support of different versions of variation of react router + // https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ), + }), + ], + beforeSend, + ...sentrySampleRateOptions, + }) + setTag('appName', appNameTag) + Sentry.setUser({ id: sentryUserId }) +} + +export function initSentryForBrowserScripts(appNameTag: SentryAppNameTag, sentryUserId: string): void { + if (__DEV__) { + return + } + + // Wrapped in try/catch because in this context it can fail silently + try { + SentryBrowser.init({ + environment: getSentryEnvironment(), + dsn: config.sentryDsn, + release: process.env.VERSION, + // TODO (EXT-528): Look into adding tracing integration + beforeSend, + ...sentrySampleRateOptions, + }) + } catch (e) { + logger.debug('sentry.ts', 'initSentryForBrowserScripts', 'Error in Sentry init', e) + } + setTag('appName', appNameTag) + + if (sentryUserId) { + SentryBrowser.setUser({ id: sentryUserId }) + } +} + +const sentrySampleRateOptions = { + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + tracesSampleRate: 1.0, + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, +} + +export const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter) diff --git a/apps/extension/src/app/utils/analytics.ts b/apps/extension/src/app/utils/analytics.ts new file mode 100644 index 00000000000..41920b0a63b --- /dev/null +++ b/apps/extension/src/app/utils/analytics.ts @@ -0,0 +1,23 @@ +import '@tamagui/core/reset.css' +import 'src/app/Global.css' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { getLocalUserId } from 'src/app/utils/storage' +import { EXTENSION_ORIGIN_APPLICATION } from 'src/app/version' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ApplicationTransport } from 'utilities/src/telemetry/analytics/ApplicationTransport' +// eslint-disable-next-line no-restricted-imports +import { analytics, getAnalyticsAtomDirect } from 'utilities/src/telemetry/analytics/analytics' + +export async function initExtensionAnalytics(): Promise { + const analyticsAllowed = await getAnalyticsAtomDirect(true) + await analytics.init( + new ApplicationTransport({ + serverUrl: uniswapUrls.amplitudeProxyUrl, + appOrigin: EXTENSION_ORIGIN_APPLICATION, + }), + analyticsAllowed, + undefined, + getLocalUserId, + ) +} diff --git a/apps/extension/src/app/utils/chrome.ts b/apps/extension/src/app/utils/chrome.ts new file mode 100644 index 00000000000..00ebcea34d6 --- /dev/null +++ b/apps/extension/src/app/utils/chrome.ts @@ -0,0 +1,25 @@ +/** + * Helper function to detect if user is using arc chromium browser + * Will not work until stylesheets are loaded + * @returns true if user is using arc browser + */ +export function isArcBrowser(): boolean { + return !!getComputedStyle(document.documentElement).getPropertyValue('--arc-palette-background') +} + +/** + * Helper function to detect if user is using an android device + * @returns true if user is using an android device + */ +export function isAndroid(): boolean { + return navigator.userAgent.toLowerCase().indexOf('android') > -1 +} + +/** + * Helper function to check if chrome extension environment supports side panel + * Some environments have the functions defined but do not do anything so needs to be explicitly checked + * @returns true if chrome environment supports side panel + */ +export function checksIfSupportsSidePanel(): boolean { + return !!chrome.sidePanel && !isArcBrowser() && !isAndroid() +} diff --git a/apps/extension/src/app/utils/devtools.ts b/apps/extension/src/app/utils/devtools.ts new file mode 100644 index 00000000000..fe355ad4b6f --- /dev/null +++ b/apps/extension/src/app/utils/devtools.ts @@ -0,0 +1,3 @@ +if (process.env.NODE_ENV === 'development' && window.location.search.includes('why-did-you-render')) { + require('./whyDidYouRender') +} diff --git a/apps/extension/src/app/utils/isAppleDevice.test.ts b/apps/extension/src/app/utils/isAppleDevice.test.ts new file mode 100644 index 00000000000..558774ab472 --- /dev/null +++ b/apps/extension/src/app/utils/isAppleDevice.test.ts @@ -0,0 +1,56 @@ +import { isAppleDevice } from 'src/app/utils/isAppleDevice' + +describe('isAppleDevice', () => { + beforeEach(() => { + // Reset any mocks before each test + jest.resetModules() + }) + + it('should return true for macOS', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'MacIntel', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return true for iPhone', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'iPhone', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return true for iPad', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'iPad', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return false for Windows', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Win32', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) + + it('should return false for Linux', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Linux', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) + + it('should return false for Android', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Android', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) +}) diff --git a/apps/extension/src/app/utils/isAppleDevice.ts b/apps/extension/src/app/utils/isAppleDevice.ts new file mode 100644 index 00000000000..48adc3955a2 --- /dev/null +++ b/apps/extension/src/app/utils/isAppleDevice.ts @@ -0,0 +1,7 @@ +/** + * Checks if the operating system is macOS. + * @returns {boolean} - True if the OS is macOS, otherwise false. + */ +export function isAppleDevice(): boolean { + return /Mac|iPod|iPhone|iPad/.test(navigator.platform) +} diff --git a/apps/extension/src/app/utils/isOnboardedSelector.ts b/apps/extension/src/app/utils/isOnboardedSelector.ts new file mode 100644 index 00000000000..f1f14017e1e --- /dev/null +++ b/apps/extension/src/app/utils/isOnboardedSelector.ts @@ -0,0 +1,5 @@ +import { RootState } from 'wallet/src/state' + +export const isOnboardedSelector: (state: RootState) => boolean = (state: RootState) => { + return Object.values(state.wallet.accounts).length > 0 +} diff --git a/apps/extension/src/app/utils/storage.ts b/apps/extension/src/app/utils/storage.ts new file mode 100644 index 00000000000..1107161fd20 --- /dev/null +++ b/apps/extension/src/app/utils/storage.ts @@ -0,0 +1,18 @@ +import { v4 as uuidv4 } from 'uuid' +import { PersistedStorage } from 'wallet/src/utils/persistedStorage' + +const STORAGE_AREA_KEY = 'local' +export const USER_ID_KEY = 'USER_ID' +export const LOCAL_STORAGE = new PersistedStorage(STORAGE_AREA_KEY) + +export async function getLocalUserId(): Promise { + let userId: string | undefined = await LOCAL_STORAGE.getItem(USER_ID_KEY) + + if (userId) { + return userId + } + + userId = uuidv4() + await LOCAL_STORAGE.setItem(USER_ID_KEY, userId) + return userId +} diff --git a/apps/extension/src/app/utils/whyDidYouRender.ts b/apps/extension/src/app/utils/whyDidYouRender.ts new file mode 100644 index 00000000000..1f0d3645831 --- /dev/null +++ b/apps/extension/src/app/utils/whyDidYouRender.ts @@ -0,0 +1,13 @@ +import whyDidYouRender from '@welldone-software/why-did-you-render' +import React from 'react' + +if (process.env.NODE_ENV === 'development') { + whyDidYouRender(React, { + // use this to filter down to specific component names, ie /Select.*/ + include: [/.*/], + collapseGroups: true, + logOnDifferentValues: true, + trackAllPureComponents: true, + trackHooks: true, + }) +} diff --git a/apps/extension/src/app/version.ts b/apps/extension/src/app/version.ts new file mode 100644 index 00000000000..a1b425aa712 --- /dev/null +++ b/apps/extension/src/app/version.ts @@ -0,0 +1,31 @@ +import { isBetaEnv, isDevEnv } from 'utilities/src/environment' +import { StatsigEnvironmentTier } from 'wallet/src/version' + +// TODO: Add to analytics package and remove +export const EXTENSION_ORIGIN_APPLICATION = 'extension' + +export function getStatsigEnvironmentTier(): StatsigEnvironmentTier { + if (isDevEnv()) { + return StatsigEnvironmentTier.DEV + } + if (isBetaEnv()) { + return StatsigEnvironmentTier.BETA + } + return StatsigEnvironmentTier.PROD +} + +export function getSentryEnvironment(): SentryEnvironment { + if (isDevEnv()) { + return SentryEnvironment.DEV + } + if (isBetaEnv()) { + return SentryEnvironment.BETA + } + return SentryEnvironment.PROD +} + +enum SentryEnvironment { + DEV = 'development', + BETA = 'beta', + PROD = 'production', +} diff --git a/apps/extension/src/assets/beta-logo.png b/apps/extension/src/assets/beta-logo.png new file mode 100644 index 00000000000..a8e2387a9f7 Binary files /dev/null and b/apps/extension/src/assets/beta-logo.png differ diff --git a/apps/extension/src/assets/fonts/Basel-Book.woff b/apps/extension/src/assets/fonts/Basel-Book.woff new file mode 100644 index 00000000000..7cfd4abb6e9 Binary files /dev/null and b/apps/extension/src/assets/fonts/Basel-Book.woff differ diff --git a/apps/extension/src/assets/fonts/Basel-Medium.woff b/apps/extension/src/assets/fonts/Basel-Medium.woff new file mode 100644 index 00000000000..004a41fcb1c Binary files /dev/null and b/apps/extension/src/assets/fonts/Basel-Medium.woff differ diff --git a/apps/extension/src/assets/fonts/Inter-normal.var.ttf b/apps/extension/src/assets/fonts/Inter-normal.var.ttf new file mode 100644 index 00000000000..600b384ad81 Binary files /dev/null and b/apps/extension/src/assets/fonts/Inter-normal.var.ttf differ diff --git a/apps/extension/src/assets/graphics/extension-preview-dark.png b/apps/extension/src/assets/graphics/extension-preview-dark.png new file mode 100644 index 00000000000..11145a70ae2 Binary files /dev/null and b/apps/extension/src/assets/graphics/extension-preview-dark.png differ diff --git a/apps/extension/src/assets/graphics/extension-preview-light.png b/apps/extension/src/assets/graphics/extension-preview-light.png new file mode 100644 index 00000000000..f29c5585399 Binary files /dev/null and b/apps/extension/src/assets/graphics/extension-preview-light.png differ diff --git a/apps/extension/src/assets/icon128.png b/apps/extension/src/assets/icon128.png new file mode 100644 index 00000000000..e9c2299dfd5 Binary files /dev/null and b/apps/extension/src/assets/icon128.png differ diff --git a/apps/extension/src/assets/icon16.png b/apps/extension/src/assets/icon16.png new file mode 100644 index 00000000000..3de1e355d12 Binary files /dev/null and b/apps/extension/src/assets/icon16.png differ diff --git a/apps/extension/src/assets/icon32.png b/apps/extension/src/assets/icon32.png new file mode 100644 index 00000000000..071e63e7fd2 Binary files /dev/null and b/apps/extension/src/assets/icon32.png differ diff --git a/apps/extension/src/assets/icon48.png b/apps/extension/src/assets/icon48.png new file mode 100644 index 00000000000..6626bc3b0d0 Binary files /dev/null and b/apps/extension/src/assets/icon48.png differ diff --git a/apps/extension/src/assets/icon64.png b/apps/extension/src/assets/icon64.png new file mode 100644 index 00000000000..cf5e77b53cd Binary files /dev/null and b/apps/extension/src/assets/icon64.png differ diff --git a/apps/extension/src/assets/index.ts b/apps/extension/src/assets/index.ts new file mode 100644 index 00000000000..b9de945e81a --- /dev/null +++ b/apps/extension/src/assets/index.ts @@ -0,0 +1,6 @@ +export const ONBOARDING_BACKGROUND_LIGHT = require('./onboarding-background-light.png') +export const ONBOARDING_BACKGROUND_DARK = require('./onboarding-background-dark.png') +export const LOCK_SCREEN_BACKGROUND = require('./lock-screen-background.png') +export const UNISWAP_BETA_LOGO = require('./beta-logo.png') +export const EXTENSION_PREVIEW_LIGHT = require('./graphics/extension-preview-light.png') +export const EXTENSION_PREVIEW_DARK = require('./graphics/extension-preview-dark.png') diff --git a/apps/extension/src/assets/lock-screen-background.png b/apps/extension/src/assets/lock-screen-background.png new file mode 100644 index 00000000000..61b22ebf394 Binary files /dev/null and b/apps/extension/src/assets/lock-screen-background.png differ diff --git a/apps/extension/src/assets/onboarding-background-dark.png b/apps/extension/src/assets/onboarding-background-dark.png new file mode 100644 index 00000000000..d171047c301 Binary files /dev/null and b/apps/extension/src/assets/onboarding-background-dark.png differ diff --git a/apps/extension/src/assets/onboarding-background-light.png b/apps/extension/src/assets/onboarding-background-light.png new file mode 100644 index 00000000000..9c1296b3011 Binary files /dev/null and b/apps/extension/src/assets/onboarding-background-light.png differ diff --git a/apps/extension/src/background/background.ts b/apps/extension/src/background/background.ts new file mode 100644 index 00000000000..3f8f27d545c --- /dev/null +++ b/apps/extension/src/background/background.ts @@ -0,0 +1,91 @@ +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { initStatSigForBrowserScripts } from 'src/app/StatsigProvider' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { SentryAppNameTag, initSentryForBrowserScripts } from 'src/app/sentry' +import { initExtensionAnalytics } from 'src/app/utils/analytics' +import { getLocalUserId } from 'src/app/utils/storage' +import { initMessageBridge } from 'src/background/backgroundDappRequests' +import { backgroundStore } from 'src/background/backgroundStore' +import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels' +import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests' +import { setSidePanelBehavior, setSidePanelOptions } from 'src/background/utils/chromeSidePanelUtils' +import { readIsOnboardedFromStorage } from 'src/background/utils/persistedStateUtils' +import { logger } from 'utilities/src/logger/logger' + +export const EXTENSION_ID = chrome.runtime.id + +initMessageBridge() + +async function initApp(): Promise { + const userId = await getLocalUserId() + initSentryForBrowserScripts(SentryAppNameTag.Background, userId) + await initStatSigForBrowserScripts() + await initExtensionAnalytics() + + // Enables or disables sidebar based on onboarding status + // Injected script will reject any requests if not onboarded + backgroundStore.addOnboardingChangedListener(async (isOnboarded) => { + if (isOnboarded) { + await enableSidebar() + } else { + await disableSidebar() + await focusOrCreateOnboardingTab() + } + }) + + await backgroundStore.init() +} + +chrome.tabs.onActivated.addListener(onTabChange) +chrome.tabs.onUpdated.addListener(onTabChange) + +chrome.action.onClicked.addListener(async () => { + await checkAndHandleOnboarding() +}) + +chrome.runtime.onInstalled.addListener(async () => { + await checkAndHandleOnboarding() +}) + +// Utility Functions +async function checkAndHandleOnboarding(): Promise { + const isOnboarded = await readIsOnboardedFromStorage() + + if (!isOnboarded) { + await disableSidebar() + await focusOrCreateOnboardingTab() + } else { + await enableSidebar() + } +} + +async function enableSidebar(): Promise { + await setSidePanelOptions({ enabled: true }) + await setSidePanelBehavior({ openPanelOnActionClick: true }) +} + +async function disableSidebar(): Promise { + await setSidePanelOptions({ enabled: false }) + await setSidePanelBehavior({ openPanelOnActionClick: false }) +} + +/** Fires an event whenever a tab is changed so the sidebar can reflect the current connection status properly. */ +async function onTabChange(): Promise { + try { + await backgroundToSidePanelMessageChannel.sendMessage({ + type: BackgroundToSidePanelRequestType.TabActivated, + }) + } catch (e) { + // an error will be thrown if the sidebar is not open. This is expected and in this case there is no action to be taken anyways so ignore. + } +} + +initApp().catch((error) => { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'initApp', + }, + }) +}) diff --git a/apps/extension/src/background/backgroundDappRequests.ts b/apps/extension/src/background/backgroundDappRequests.ts new file mode 100644 index 00000000000..12f142cafd3 --- /dev/null +++ b/apps/extension/src/background/backgroundDappRequests.ts @@ -0,0 +1,267 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import { removeDappConnection } from 'src/app/features/dapp/actions' +import { changeChain } from 'src/app/features/dapp/changeChain' +import { dappStore } from 'src/app/features/dapp/store' +import { SenderTabInfo } from 'src/app/features/dappRequests/slice' +import { + ChangeChainRequest, + DappRequest, + DappRequestType, + DappResponseType, + RevokePermissionsRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { + DappBackgroundPortChannel, + contentScriptToBackgroundMessageChannel, + contentScriptUtilityMessageChannel, + createBackgroundToSidePanelMessagePort, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, + DappRequestMessage, +} from 'src/background/messagePassing/types/requests' +import { openSidePanel } from 'src/background/utils/chromeSidePanelUtils' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { hexadecimalStringToInt, toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants/extension' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { RPCType } from 'uniswap/src/types/chains' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { walletContextValue } from 'wallet/src/features/wallet/context' + +const INACTIVITY_ALARM_NAME = 'inactivity' +// TODO(EXT-546): add a setting to turn off the auto-lock setting +const INACTIVITY_TIMEOUT_MINUTES = 60 * 24 // 1 day + +const windowIdToSidebarPortMap = new Map() +// TODO EXT-1020 add timeout support to avoid memory leaks +const windowIdToPendingRequestsMap = new Map() + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== INACTIVITY_ALARM_NAME) { + return + } + + await lockWallet() +}) + +async function lockWallet(): Promise { + logger.debug('background', 'lockWallet', 'Locking wallet via background script') + sendAnalyticsEvent(ExtensionEventName.ChangeLockedState, { locked: true, location: 'background' }) + await Keyring.lock() +} + +chrome.runtime.onConnect.addListener(async (port) => { + await chrome.alarms.clear(INACTIVITY_ALARM_NAME) + + const windowId = port.name + const portChannel = createBackgroundToSidePanelMessagePort(port) + windowIdToSidebarPortMap.set(windowId, portChannel) + + const pendingRequests = windowIdToPendingRequestsMap.get(windowId) + + if (pendingRequests) { + for (const pendingRequest of pendingRequests) { + await portChannel.sendMessage(pendingRequest) + } + windowIdToPendingRequestsMap.delete(windowId) + } + + // Only gets called when `port.disconnect()` is called or `port.sendMessage()` for a disconnected port + port.onDisconnect.addListener(async () => { + windowIdToSidebarPortMap.delete(windowId) + + if (windowIdToSidebarPortMap.size <= 0) { + await chrome.alarms.create(INACTIVITY_ALARM_NAME, { + delayInMinutes: INACTIVITY_TIMEOUT_MINUTES, + }) + } + }) +}) + +let initialized = false +export function initMessageBridge(): void { + if (initialized) { + return + } + + contentScriptToBackgroundMessageChannel.addAllMessageListener(async (message, sender) => { + // The side panel needs to be opened here because it has to be in response to a user action. + // Further down in the chain it will be opened in response to a message from the background script. + + if (sender?.tab?.id === undefined || sender?.tab?.url === undefined) { + logger.error(new Error('sender.tab id or url is not defined'), { + tags: { + file: 'background/background.ts', + function: 'dappMessageListener', + }, + }) + return + } + + const senderTabInfo = { + id: sender.tab.id, + url: sender.tab.url, + favIconUrl: sender.tab.favIconUrl, + } + + const isSidebarActive = Boolean(windowIdToSidebarPortMap.get(sender.tab.windowId.toString())) + if (!isSidebarActive) { + const handled = handleSilentBackgroundRequest(message, senderTabInfo) + if (handled) { + return + } + } + + await handleSidebarRequest(message, sender.tab.windowId, senderTabInfo) + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.ErrorLog, async (message) => { + // Need to re-construct the error object from the message since the error object is not serializable + logger.error(new Error(message.message), { + tags: { + file: message.fileName, + function: message.functionName, + ...message.tags, + }, + }) + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.InfoLog, async (message) => { + logger.info(message.fileName, message.functionName, message.message, message.tags) + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => { + focusOrCreateOnboardingTab().catch((error) => + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'contentScriptUtilityMessageListener', + }, + }), + ) + }) + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => { + focusOrCreateOnboardingTab().catch((error) => + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'contentScriptUtilityMessageListener', + }, + }), + ) + }) + + initialized = true +} + +/** + * Dapp requests that should be silently handled by the background worker as a proxy if the sidebar is not open + * Avoids async to trigger open side panel as quickly as possible + * @returns true if the request was handled, false otherwise + */ +function handleSilentBackgroundRequest(request: DappRequest, senderTabInfo: SenderTabInfo): boolean { + const dappUrl = extractBaseUrl(senderTabInfo.url) + + if (!dappUrl) { + return false + } + + switch (request.type) { + case DappRequestType.ChangeChain: + handleChainChange(request, dappUrl, senderTabInfo.id).catch(() => {}) + return true + case DappRequestType.RevokePermissions: + handleRevokePermissions(request, dappUrl, senderTabInfo.id).catch(() => {}) + return true + default: + return false + } +} + +async function handleChainChange(request: ChangeChainRequest, dappUrl: string, tabId: number): Promise { + await dappStore.init() + const { activeConnectedAddress } = dappStore.getDappInfo(dappUrl) ?? {} + const updatedChainId = toSupportedChainId(hexadecimalStringToInt(request.chainId)) + const provider = updatedChainId ? walletContextValue.providers.getProvider(updatedChainId, RPCType.Public) : undefined + const response = changeChain({ + provider, + dappUrl, + updatedChainId, + requestId: request.requestId, + activeConnectedAddress, + }) + + await dappResponseMessageChannel.sendMessageToTab(tabId, response) +} + +async function handleRevokePermissions( + request: RevokePermissionsRequest, + dappUrl: string, + tabId: number, +): Promise { + await dappStore.init() + const revokedPermissions = Object.keys(request.permissions) + + if (revokedPermissions.includes(ExtensionEthMethods.eth_accounts)) { + await removeDappConnection(dappUrl) + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.RevokePermissionsResponse, + requestId: request.requestId, + }) + } else { + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + }) + } +} + +class ExpectedNoPortError extends Error { + constructor() { + super('No port in storage to post message to') + } +} + +async function handleSidebarRequest( + request: DappRequest, + windowId: number, + senderTabInfo: DappRequestMessage['senderTabInfo'], +): Promise { + const windowIdString = windowId.toString() + const portChannel = windowIdToSidebarPortMap.get(windowIdString) + const message: DappRequestMessage = { + type: BackgroundToSidePanelRequestType.DappRequestReceived, + dappRequest: request, + senderTabInfo, + isSidebarClosed: !portChannel, + } + + try { + if (!portChannel) { + throw new ExpectedNoPortError() + } + + await portChannel.sendMessage(message) + } catch (error) { + await openSidePanel(senderTabInfo.id, windowId) + + windowIdToPendingRequestsMap.set(windowIdString, windowIdToPendingRequestsMap.get(windowIdString) ?? []) + windowIdToPendingRequestsMap.get(windowIdString)?.push(message) + + if (!(error instanceof ExpectedNoPortError)) { + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'handleSidebarRequest', + }, + }) + } + } +} diff --git a/apps/extension/src/background/backgroundStore.ts b/apps/extension/src/background/backgroundStore.ts new file mode 100644 index 00000000000..5c0a5d7d0bf --- /dev/null +++ b/apps/extension/src/background/backgroundStore.ts @@ -0,0 +1,71 @@ +import { readIsOnboardedFromStorage, readReduxStateFromStorage } from 'src/background/utils/persistedStateUtils' +import { ExtensionState } from 'src/store/extensionReducer' +import { logger } from 'utilities/src/logger/logger' + +type BackgroundState = { + isOnboarded: boolean +} + +const state: BackgroundState = { + isOnboarded: false, +} + +type OnboardingChangedListener = (isOnboarded: boolean) => void +const onboardingChangedListeners: OnboardingChangedListener[] = [] + +// Allows for multiple init attempts from different sources +let initPromise: Promise | undefined + +async function init(): Promise { + if (!initPromise) { + initPromise = initInternal() + } + + return initPromise +} + +async function initInternal(): Promise { + try { + const reduxState = await readReduxStateFromStorage() + + if (!reduxState) { + logger.debug('backgroundStore.ts', 'initInternal', 'Failed to read redux state from storage') + } + + await updateFromReduxState(reduxState) + chrome.storage.local.onChanged.addListener(async (changes) => { + const newReduxState = await readReduxStateFromStorage(changes) + await updateFromReduxState(newReduxState) + }) + } catch (error) { + logger.error(error, { + tags: { + file: 'backgroundStore.ts', + function: 'init', + }, + }) + } +} + +async function updateFromReduxState(reduxState: ExtensionState | undefined): Promise { + if (reduxState) { + updateIsOnboarded(await readIsOnboardedFromStorage()) // Can replace this with selector after migration is complete + } +} + +function updateIsOnboarded(isOnboarded: boolean): void { + if (isOnboarded !== state.isOnboarded) { + state.isOnboarded = isOnboarded + onboardingChangedListeners.forEach((listener) => listener(isOnboarded)) + } +} + +function addOnboardingChangedListener(listener: OnboardingChangedListener): void { + onboardingChangedListeners.push(listener) +} + +export const backgroundStore = { + state, + init, + addOnboardingChangedListener, +} diff --git a/apps/extension/src/background/messagePassing/messageChannels.ts b/apps/extension/src/background/messagePassing/messageChannels.ts new file mode 100644 index 00000000000..ffadf741f2f --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageChannels.ts @@ -0,0 +1,339 @@ +import { + AccountResponse, + AccountResponseSchema, + ChainIdResponse, + ChainIdResponseSchema, + ChangeChainRequest, + ChangeChainRequestSchema, + ChangeChainResponse, + ChangeChainResponseSchema, + DappRequestType, + DappResponseType, + ErrorResponse, + ErrorResponseSchema, + GetAccountRequest, + GetAccountRequestSchema, + GetChainIdRequest, + GetChainIdRequestSchema, + GetPermissionsRequest, + GetPermissionsRequestSchema, + GetPermissionsResponse, + GetPermissionsResponseSchema, + RequestAccountRequest, + RequestAccountRequestSchema, + RequestPermissionsRequest, + RequestPermissionsRequestSchema, + RequestPermissionsResponse, + RequestPermissionsResponseSchema, + RevokePermissionsRequest, + RevokePermissionsRequestSchema, + RevokePermissionsResponse, + RevokePermissionsResponseSchema, + SendTransactionRequest, + SendTransactionRequestSchema, + SendTransactionResponse, + SendTransactionResponseSchema, + SignMessageRequest, + SignMessageRequestSchema, + SignMessageResponse, + SignMessageResponseSchema, + SignTransactionRequest, + SignTransactionRequestSchema, + SignTransactionResponse, + SignTransactionResponseSchema, + SignTypedDataRequest, + SignTypedDataRequestSchema, + SignTypedDataResponse, + SignTypedDataResponseSchema, + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, + UniswapOpenSidebarResponse, + UniswapOpenSidebarResponseSchema, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + MessageParsers, + TypedPortMessageChannel, + TypedRuntimeMessageChannel, +} from 'src/background/messagePassing/platform' +import { + HighlightOnboardingTabMessage, + HighlightOnboardingTabMessageSchema, + OnboardingMessageType, + SidebarOpenedMessage, + SidebarOpenedMessageSchema, +} from 'src/background/messagePassing/types/ExtensionMessages' +import { + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, + DappRequestMessage, + DappRequestMessageSchema, + ErrorLog, + ErrorLogSchema, + ExtensionChainChange, + ExtensionChainChangeSchema, + ExtensionToDappRequestType, + FocusOnboardingMessage, + FocusOnboardingMessageSchema, + InfoLog, + InfoLogSchema, + TabActivatedRequest, + TabActivatedRequestSchema, + UpdateConnectionRequest, + UpdateConnectionRequestSchema, +} from 'src/background/messagePassing/types/requests' + +export enum MessageChannelName { + DappContentScript = 'DappContentScript', + DappBackground = 'DappBackground', + DappResponse = 'DappResponse', + Onboarding = 'Onboarding', + ExternalDapp = 'ExternalDapp', + ContentScriptUtility = 'ContentScriptUtility', +} + +type OnboardingMessageSchemas = { + [OnboardingMessageType.HighlightOnboardingTab]: HighlightOnboardingTabMessage + [OnboardingMessageType.SidebarOpened]: SidebarOpenedMessage +} +const onboardingMessageParsers: MessageParsers = { + [OnboardingMessageType.HighlightOnboardingTab]: (message): HighlightOnboardingTabMessage => + HighlightOnboardingTabMessageSchema.parse(message), + [OnboardingMessageType.SidebarOpened]: (message): SidebarOpenedMessage => SidebarOpenedMessageSchema.parse(message), +} + +function createOnboardingMessageChannel(): TypedRuntimeMessageChannel { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.Onboarding, + messageParsers: onboardingMessageParsers, + }) +} + +export function createOnboardingMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.Onboarding, + messageParsers: onboardingMessageParsers, + port, + }) +} + +type BackgroundToSidePanelMessageSchemas = { + [BackgroundToSidePanelRequestType.DappRequestReceived]: DappRequestMessage + [BackgroundToSidePanelRequestType.TabActivated]: TabActivatedRequest +} +const backgroundToSidePanelMessageParsers: MessageParsers< + BackgroundToSidePanelRequestType, + BackgroundToSidePanelMessageSchemas +> = { + [BackgroundToSidePanelRequestType.DappRequestReceived]: (message): DappRequestMessage => + DappRequestMessageSchema.parse(message), + [BackgroundToSidePanelRequestType.TabActivated]: (message): TabActivatedRequest => + TabActivatedRequestSchema.parse(message), +} + +function createBackgroundToSidePanelMessageChannel(): TypedRuntimeMessageChannel< + BackgroundToSidePanelRequestType, + BackgroundToSidePanelMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappBackground, + messageParsers: backgroundToSidePanelMessageParsers, + }) +} + +export function createBackgroundToSidePanelMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappBackground, + messageParsers: backgroundToSidePanelMessageParsers, + port, + }) +} + +type ContentScriptToBackgroundMessageSchemas = { + [DappRequestType.ChangeChain]: ChangeChainRequest + [DappRequestType.GetAccount]: GetAccountRequest + [DappRequestType.GetChainId]: GetChainIdRequest + [DappRequestType.GetPermissions]: GetPermissionsRequest + [DappRequestType.RequestAccount]: RequestAccountRequest + [DappRequestType.RequestPermissions]: RequestPermissionsRequest + [DappRequestType.RevokePermissions]: RevokePermissionsRequest + [DappRequestType.SendTransaction]: SendTransactionRequest + [DappRequestType.SignMessage]: SignMessageRequest + [DappRequestType.SignTransaction]: SignTransactionRequest + [DappRequestType.SignTypedData]: SignTypedDataRequest + [DappRequestType.UniswapOpenSidebar]: UniswapOpenSidebarRequest +} +const contentScriptToBackgroundMessageParsers: MessageParsers< + DappRequestType, + ContentScriptToBackgroundMessageSchemas +> = { + [DappRequestType.ChangeChain]: (message): ChangeChainRequest => ChangeChainRequestSchema.parse(message), + [DappRequestType.GetAccount]: (message): GetAccountRequest => GetAccountRequestSchema.parse(message), + [DappRequestType.GetChainId]: (message): GetChainIdRequest => GetChainIdRequestSchema.parse(message), + [DappRequestType.GetPermissions]: (message): GetPermissionsRequest => GetPermissionsRequestSchema.parse(message), + [DappRequestType.RequestAccount]: (message): RequestAccountRequest => RequestAccountRequestSchema.parse(message), + [DappRequestType.RequestPermissions]: (message): RequestPermissionsRequest => + RequestPermissionsRequestSchema.parse(message), + [DappRequestType.RevokePermissions]: (message): RevokePermissionsRequest => + RevokePermissionsRequestSchema.parse(message), + [DappRequestType.SendTransaction]: (message): SendTransactionRequest => SendTransactionRequestSchema.parse(message), + [DappRequestType.SignMessage]: (message): SignMessageRequest => SignMessageRequestSchema.parse(message), + [DappRequestType.SignTransaction]: (message): SignTransactionRequest => SignTransactionRequestSchema.parse(message), + [DappRequestType.SignTypedData]: (message): SignTypedDataRequest => SignTypedDataRequestSchema.parse(message), + [DappRequestType.UniswapOpenSidebar]: (message): UniswapOpenSidebarRequest => + UniswapOpenSidebarRequestSchema.parse(message), +} + +function createContentScriptToBackgroundMessageChannel(): TypedRuntimeMessageChannel< + DappRequestType, + ContentScriptToBackgroundMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappContentScript, + messageParsers: contentScriptToBackgroundMessageParsers, + canReceiveFromContentScript: true, + }) +} + +export function createContentScriptToBackgroundMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappContentScript, + messageParsers: contentScriptToBackgroundMessageParsers, + canReceiveFromContentScript: true, + port, + }) +} + +type DappResponseMessageSchemas = { + [DappResponseType.AccountResponse]: AccountResponse + [DappResponseType.ChainChangeResponse]: ChangeChainResponse + [DappResponseType.ChainIdResponse]: ChainIdResponse + [DappResponseType.ErrorResponse]: ErrorResponse + [DappResponseType.GetPermissionsResponse]: GetPermissionsResponse + [DappResponseType.RequestPermissionsResponse]: RequestPermissionsResponse + [DappResponseType.RevokePermissionsResponse]: RevokePermissionsResponse + [DappResponseType.SendTransactionResponse]: SendTransactionResponse + [DappResponseType.SignMessageResponse]: SignMessageResponse + [DappResponseType.SignTransactionResponse]: SignTransactionResponse + [DappResponseType.SignTypedDataResponse]: SignTypedDataResponse + [DappResponseType.UniswapOpenSidebarResponse]: UniswapOpenSidebarResponse +} +const dappResponseMessageParsers: MessageParsers = { + [DappResponseType.AccountResponse]: (message): AccountResponse => AccountResponseSchema.parse(message), + [DappResponseType.ChainChangeResponse]: (message): ChangeChainResponse => ChangeChainResponseSchema.parse(message), + [DappResponseType.ChainIdResponse]: (message): ChainIdResponse => ChainIdResponseSchema.parse(message), + [DappResponseType.ErrorResponse]: (message): ErrorResponse => ErrorResponseSchema.parse(message), + [DappResponseType.GetPermissionsResponse]: (message): GetPermissionsResponse => + GetPermissionsResponseSchema.parse(message), + [DappResponseType.RequestPermissionsResponse]: (message): RequestPermissionsResponse => + RequestPermissionsResponseSchema.parse(message), + [DappResponseType.RevokePermissionsResponse]: (message): RevokePermissionsResponse => + RevokePermissionsResponseSchema.parse(message), + [DappResponseType.SendTransactionResponse]: (message): SendTransactionResponse => + SendTransactionResponseSchema.parse(message), + [DappResponseType.SignMessageResponse]: (message): SignMessageResponse => SignMessageResponseSchema.parse(message), + [DappResponseType.SignTransactionResponse]: (message): SignTransactionResponse => + SignTransactionResponseSchema.parse(message), + [DappResponseType.SignTypedDataResponse]: (message): SignTypedDataResponse => + SignTypedDataResponseSchema.parse(message), + [DappResponseType.UniswapOpenSidebarResponse]: (message): UniswapOpenSidebarResponse => + UniswapOpenSidebarResponseSchema.parse(message), +} + +function createDappResponseMessageChannel(): TypedRuntimeMessageChannel { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappResponse, + messageParsers: dappResponseMessageParsers, + }) +} + +export function createDappResponseMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappResponse, + messageParsers: dappResponseMessageParsers, + port, + }) +} + +type ExternalDappMessageSchemas = { + [ExtensionToDappRequestType.SwitchChain]: ExtensionChainChange + [ExtensionToDappRequestType.UpdateConnections]: UpdateConnectionRequest +} +const externalDappMessageParsers: MessageParsers = { + [ExtensionToDappRequestType.SwitchChain]: (message): ExtensionChainChange => + ExtensionChainChangeSchema.parse(message), + [ExtensionToDappRequestType.UpdateConnections]: (message): UpdateConnectionRequest => + UpdateConnectionRequestSchema.parse(message), +} + +export function createExternalDappMessageChannel(): TypedRuntimeMessageChannel< + ExtensionToDappRequestType, + ExternalDappMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: externalDappMessageParsers, + }) +} + +export function createExternalDappMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: externalDappMessageParsers, + port, + }) +} + +type ContentScriptUtilityMessageSchemas = { + [ContentScriptUtilityMessageType.FocusOnboardingTab]: FocusOnboardingMessage + [ContentScriptUtilityMessageType.ErrorLog]: ErrorLog + [ContentScriptUtilityMessageType.InfoLog]: InfoLog +} +const contentScriptUtilityMessageParsers: MessageParsers< + ContentScriptUtilityMessageType, + ContentScriptUtilityMessageSchemas +> = { + [ContentScriptUtilityMessageType.FocusOnboardingTab]: (message): FocusOnboardingMessage => + FocusOnboardingMessageSchema.parse(message), + [ContentScriptUtilityMessageType.ErrorLog]: (message): ErrorLog => ErrorLogSchema.parse(message), + [ContentScriptUtilityMessageType.InfoLog]: (message): InfoLog => InfoLogSchema.parse(message), +} + +export function createContentScriptUtilityMessageChannel(): TypedRuntimeMessageChannel< + ContentScriptUtilityMessageType, + ContentScriptUtilityMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.ContentScriptUtility, + messageParsers: contentScriptUtilityMessageParsers, + canReceiveFromContentScript: true, + }) +} + +export function createContentScriptUtilityMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: contentScriptUtilityMessageParsers, + port, + }) +} + +export const onboardingMessageChannel = createOnboardingMessageChannel() +export const backgroundToSidePanelMessageChannel = createBackgroundToSidePanelMessageChannel() +export const contentScriptToBackgroundMessageChannel = createContentScriptToBackgroundMessageChannel() +export const dappResponseMessageChannel = createDappResponseMessageChannel() +export const externalDappMessageChannel = createExternalDappMessageChannel() +export const contentScriptUtilityMessageChannel = createContentScriptUtilityMessageChannel() + +export type DappBackgroundPortChannel = ReturnType diff --git a/apps/extension/src/background/messagePassing/messageTypes.ts b/apps/extension/src/background/messagePassing/messageTypes.ts new file mode 100644 index 00000000000..a73e41bb09b --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageTypes.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +// SCHEMAS +export const MessageSchema = z.object({}) + +// TYPES +export type Message = z.infer diff --git a/apps/extension/src/background/messagePassing/messageUtils.ts b/apps/extension/src/background/messagePassing/messageUtils.ts new file mode 100644 index 00000000000..86209df7fc6 --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageUtils.ts @@ -0,0 +1,28 @@ +import { Message } from 'src/background/messagePassing/messageTypes' + +type MessageValidator = (message: unknown) => message is T + +type WindowMessageHandler = (message: T, source: MessageEventSource | null) => void +type InvalidWindowMessageHandler = (message: unknown, source?: MessageEventSource | null) => void + +// Message listener for chrome.window with validation logic. Used only to receive external messages from dapps. +export function addWindowMessageListener( + validator: MessageValidator, + handler: WindowMessageHandler, + invalidMessageHandler?: InvalidWindowMessageHandler, +): (event: MessageEvent) => void { + const listener = (event: MessageEvent): void => { + if (event.source !== window || !validator(event.data)) { + invalidMessageHandler?.(event.data, event.source) + return + } + + handler(event.data, event.source) + } + window.addEventListener('message', listener) + return listener +} + +export function removeWindowMessageListener(listener: (event: MessageEvent) => void): void { + window.removeEventListener('message', listener) +} diff --git a/apps/extension/src/background/messagePassing/platform.ts b/apps/extension/src/background/messagePassing/platform.ts new file mode 100644 index 00000000000..a7ab70c0d2e --- /dev/null +++ b/apps/extension/src/background/messagePassing/platform.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { logger } from 'utilities/src/logger/logger' + +const EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR = 'Extension context invalidated.' + +type MessageListener = (message: T, sender?: chrome.runtime.MessageSender) => void +class ChromeMessageChannel { + protected readonly channelName: string + readonly port?: chrome.runtime.Port + + protected listeners: MessageListener[] = [] + + constructor({ + channelName, + port, + canReceiveFromContentScript = false, + }: { + channelName: string + canReceiveFromContentScript?: boolean + port?: chrome.runtime.Port + }) { + this.channelName = channelName + this.port = port + + const mainListener: MessageListener = (message, sender) => { + const targetMessage = message[this.channelName] + + if (targetMessage !== undefined) { + if (sender?.tab !== undefined && !canReceiveFromContentScript) { + return + } + + if (sender?.id !== chrome.runtime.id && !this.port) { + return + } + + this.listeners.forEach((listener) => { + listener(targetMessage, sender) + }) + } + } + + if (this.port) { + this.port.onMessage.addListener((message, senderPort) => mainListener(message, senderPort.sender)) + } else { + // eslint-disable-next-line no-restricted-syntax + chrome.runtime.onMessage.addListener(mainListener) + } + + this.sendMessage = this.sendMessage.bind(this) + this.sendMessageToTab = this.sendMessageToTab.bind(this) + this.sendMessageToTabUrl = this.sendMessageToTabUrl.bind(this) + this.addMessageListener = this.addMessageListener.bind(this) + this.removeMessageListener = this.removeMessageListener.bind(this) + } + + async sendMessage(message: any): Promise { + if (this.port) { + this.port.postMessage({ [this.channelName]: message }) + } else { + // eslint-disable-next-line no-restricted-syntax + chrome.runtime.sendMessage({ [this.channelName]: message }).catch(() => {}) + } + } + + async sendMessageToTab(tabId: number, message: any): Promise { + // eslint-disable-next-line no-restricted-syntax + await chrome.tabs.sendMessage(tabId, { [this.channelName]: message }) + } + + async sendMessageToTabUrl(tabUrl: string, message: any): Promise { + const urlMatcher = `${tabUrl}/*` + const promises: Promise[] = [] + chrome.tabs.query({ url: urlMatcher }, (tabs) => { + tabs.forEach((tab) => { + if (tab?.id) { + promises.push( + // eslint-disable-next-line no-restricted-syntax + chrome.tabs.sendMessage(tab.id, { [this.channelName]: message }).catch(() => { + // Not logging error here because it is expected that inactive tabs will not be able to receive the message + }), + ) + } + }) + }) + return Promise.all(promises) + } + + addMessageListener(listener: MessageListener): () => void { + this.listeners.push(listener) + + return () => this.removeMessageListener(listener) + } + + removeMessageListener(listener: MessageListener): void { + this.listeners = this.listeners.filter((l) => l !== listener) + } +} + +export type MessageParsers = { + [key in T]: (message: unknown) => R[key] +} +abstract class TypedMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> { + private readonly chromeMessageChannel: ChromeMessageChannel + private readonly messageParsers: MessageParsers + private listeners = new Map() + + constructor({ + channelName, + port, + messageParsers, + canReceiveFromContentScript, + }: { + channelName: string + port?: chrome.runtime.Port + messageParsers: MessageParsers + canReceiveFromContentScript?: boolean + }) { + this.messageParsers = messageParsers + this.chromeMessageChannel = new ChromeMessageChannel({ + channelName, + port, + canReceiveFromContentScript, + }) + + this.chromeMessageChannel.addMessageListener((message, sender) => { + let type: T | undefined + try { + const processed = this.processMessage(message) + const messageParser = processed.messageParser + type = processed.type + + const parsed = messageParser(message) + this.listeners.get(type)?.forEach((listener) => { + listener(parsed, sender) + }) + } catch (error) { + logger.error( + new Error(`Error validating message. Possible type is ${type}`, { + cause: error, + }), + { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.constructor', + }, + }, + ) + } + }) + + this.sendMessage = this.sendMessage.bind(this) + this.sendMessageToTab = this.sendMessageToTab.bind(this) + this.sendMessageToTabUrl = this.sendMessageToTabUrl.bind(this) + this.addMessageListener = this.addMessageListener.bind(this) + this.removeMessageListener = this.removeMessageListener.bind(this) + } + + private processMessage(message: any): { type: T; messageParser: (message: unknown) => R[T] } { + const type = message.type as Maybe + if (!type) { + throw new Error('No type provided on message') + } + + const messageParser = this.messageParsers[type] + if (!messageParser) { + throw new Error(`No message parser found for type ${type}`) + } + return { type, messageParser } + } + + async sendMessage(message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessage(message) + return true + } catch (error) { + const isExtensionInvalidatedError = + error instanceof Error && error.message === EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR + logger.error( + new Error( + `${isExtensionInvalidatedError ? 'Please refresh the page. ' : ''}Error sending message for type ${type}`, + { cause: error }, + ), + { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessage', + }, + }, + ) + return false + } + } + + async sendMessageToTab(tabId: number, message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessageToTab(tabId, message) + return true + } catch (error) { + logger.error(new Error(`Error sending message to tab for type ${type}`, { cause: error }), { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessageToTab', + }, + }) + return false + } + } + + async sendMessageToTabUrl(tabUrl: string, message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessageToTabUrl(tabUrl, message) + return true + } catch (error) { + logger.error(new Error(`Error sending message to tab for type ${type}`, { cause: error }), { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessageToTabUrl', + }, + }) + return false + } + } + + addMessageListener(type: T1, listener: L[T1]): () => void { + this.listeners.set(type, this.listeners.get(type) ?? []) + this.listeners.get(type)?.push(listener) + + return () => this.removeMessageListener(type, listener) + } + + addAllMessageListener(listener: MessageListener): () => void { + const removeListeners = Object.keys(this.messageParsers).map((type) => + this.addMessageListener(type as T, listener as L[T]), + ) + + return () => removeListeners.forEach((remove) => remove()) + } + + removeMessageListener(type: T, listener: L[T]): void { + this.listeners.set(type, this.listeners.get(type)?.filter((l) => l !== listener) ?? []) + } +} + +/** + * Type-safe message channel class used for communication. Intended for general global use, backed by chrome.runtime + */ +export class TypedRuntimeMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> extends TypedMessageChannel { + constructor({ + channelName, + messageParsers, + canReceiveFromContentScript, + }: { + channelName: string + messageParsers: MessageParsers + canReceiveFromContentScript?: boolean + }) { + super({ channelName, messageParsers, canReceiveFromContentScript }) + } +} + +/** + * Adaptation of TypedRuntimeMessageChannel used as a wrapper around chrome.runtime.Port + */ +export class TypedPortMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> extends TypedMessageChannel { + readonly port: chrome.runtime.Port + + constructor({ + channelName, + messageParsers, + port, + canReceiveFromContentScript, + }: { + channelName: string + messageParsers: MessageParsers + port: chrome.runtime.Port + canReceiveFromContentScript?: boolean + }) { + super({ channelName, messageParsers, port, canReceiveFromContentScript }) + this.port = port + } +} diff --git a/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts b/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts new file mode 100644 index 00000000000..66bb0d00891 --- /dev/null +++ b/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts @@ -0,0 +1,17 @@ +import { MessageSchema } from 'src/background/messagePassing/messageTypes' +import { z } from 'zod' + +export enum OnboardingMessageType { + HighlightOnboardingTab = 'HighlightOnboardingTab', + SidebarOpened = 'SidebarOpened', +} + +export const HighlightOnboardingTabMessageSchema = MessageSchema.extend({ + type: z.literal(OnboardingMessageType.HighlightOnboardingTab), +}) +export type HighlightOnboardingTabMessage = z.infer + +export const SidebarOpenedMessageSchema = MessageSchema.extend({ + type: z.literal(OnboardingMessageType.SidebarOpened), +}) +export type SidebarOpenedMessage = z.infer diff --git a/apps/extension/src/background/messagePassing/types/requests.ts b/apps/extension/src/background/messagePassing/types/requests.ts new file mode 100644 index 00000000000..f9ac4e49b62 --- /dev/null +++ b/apps/extension/src/background/messagePassing/types/requests.ts @@ -0,0 +1,94 @@ +import { DappRequestSchema } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { MessageSchema } from 'src/background/messagePassing/messageTypes' +import { z } from 'zod' + +// ENUMS + +// Requests from content scripts to the extension (non-dapp requests) +export enum ContentScriptUtilityMessageType { + FocusOnboardingTab = 'FocusOnboardingTab', + ErrorLog = 'Error', + InfoLog = 'Info', +} + +export const ErrorLogSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.ErrorLog), + message: z.string(), + fileName: z.string(), + functionName: z.string(), + tags: z.record(z.string()).optional(), +}) +export type ErrorLog = z.infer + +export const InfoLogSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.InfoLog), + fileName: z.string(), + functionName: z.string(), + message: z.string(), + tags: z.record(z.string()), +}) +export type InfoLog = z.infer + +export const FocusOnboardingMessageSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.FocusOnboardingTab), +}) +export type FocusOnboardingMessage = z.infer + +// Requests from background script to the extension sidebar +export enum BackgroundToSidePanelRequestType { + TabActivated = 'TabActivated', + DappRequestReceived = 'DappRequestReceived', +} + +export const DappRequestMessageSchema = z.object({ + type: z.literal(BackgroundToSidePanelRequestType.DappRequestReceived), + dappRequest: DappRequestSchema, + senderTabInfo: z.object({ + id: z.number(), + url: z.string(), + favIconUrl: z.string().optional(), + }), + isSidebarClosed: z.optional(z.boolean()), +}) +export type DappRequestMessage = z.infer + +export const TabActivatedRequestSchema = MessageSchema.extend({ + type: z.literal(BackgroundToSidePanelRequestType.TabActivated), +}) +export type TabActivatedRequest = z.infer + +// Requests outgoing from the extension to the injected script +export enum ExtensionToDappRequestType { + UpdateConnections = 'UpdateConnections', + SwitchChain = 'SwitchChain', +} + +const BaseExtensionRequestSchema = MessageSchema.extend({ + type: z.nativeEnum(ExtensionToDappRequestType), +}) +export type BaseExtensionRequest = z.infer + +export const ExtensionChainChangeSchema = BaseExtensionRequestSchema.extend({ + type: z.literal(ExtensionToDappRequestType.SwitchChain), + chainId: z.string(), + providerUrl: z.string(), +}) +export type ExtensionChainChange = z.infer + +export const UpdateConnectionRequestSchema = BaseExtensionRequestSchema.extend({ + type: z.literal(ExtensionToDappRequestType.UpdateConnections), + addresses: z.array(z.string()), // TODO (Thomas): Figure out what to do for type safety here +}) +export type UpdateConnectionRequest = z.infer + +export const ExtensionToDappRequestSchema = z.union([ + ExtensionChainChangeSchema, + UpdateConnectionRequestSchema, +]) +export type ExtensionToDappRequest = z.infer + +// VALIDATORS + +export function isValidExtensionToDappRequest(request: unknown): request is ExtensionToDappRequest { + return ExtensionToDappRequestSchema.safeParse(request).success +} diff --git a/apps/extension/src/background/utils/chromeSidePanelUtils.ts b/apps/extension/src/background/utils/chromeSidePanelUtils.ts new file mode 100644 index 00000000000..8696d81e4c0 --- /dev/null +++ b/apps/extension/src/background/utils/chromeSidePanelUtils.ts @@ -0,0 +1,49 @@ +import { focusOrCreateDappRequestWindow } from 'src/app/navigation/utils' +import { logger } from 'utilities/src/logger/logger' + +export async function openSidePanel(tabId: number | undefined, windowId: number): Promise { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + await chrome.sidePanel.open({ + tabId, + windowId, + }) + } catch (error) { + // TODO WALL-4313 - Backup for some broken chrome.sidePanel.open functionality + // Consider removing this once the issue is resolved or leaving as fallback + await focusOrCreateDappRequestWindow(tabId, windowId) + + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'openSidebar', + }, + }) + } +} + +export async function setSidePanelBehavior(behavior: chrome.sidePanel.PanelBehavior): Promise { + try { + await chrome.sidePanel.setPanelBehavior(behavior) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'setSideBarBehavior', + }, + }) + } +} + +export async function setSidePanelOptions(options: chrome.sidePanel.PanelOptions): Promise { + try { + await chrome.sidePanel.setOptions(options) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'setSideBarOptions', + }, + }) + } +} diff --git a/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts new file mode 100644 index 00000000000..338f3a4a545 --- /dev/null +++ b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts @@ -0,0 +1,63 @@ +import { parseCalldata as parseURCalldata } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter' +import { EthSendTransactionRPCActions } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { EthersTransactionRequest } from 'src/app/features/dappRequests/types/EthersTypes' +import { parseCalldata as parseNfPMCalldata } from 'src/app/features/dappRequests/types/NonfungiblePositionManager' +import { NonfungiblePositionManagerCall } from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes' +import { UniversalRouterCall } from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import methodHashToFunctionSignature from 'utilities/src/calldata/methodHashToFunctionSignature' +import noop from 'utilities/src/react/noop' + +interface GetCalldataInfoFromTransactionReturnValue { + functionSignature: string | undefined + contractInteractions: EthSendTransactionRPCActions + to: string | undefined + parsedCalldata?: UniversalRouterCall | NonfungiblePositionManagerCall +} + +function getCalldataInfoFromTransaction( + transaction: EthersTransactionRequest, +): GetCalldataInfoFromTransactionReturnValue { + const calldataMethodHash = transaction.data.substring(2, 10) + const functionSignature = methodHashToFunctionSignature(calldataMethodHash) + const contractInteractions = EthSendTransactionRPCActions.ContractInteraction + const result: GetCalldataInfoFromTransactionReturnValue = { + functionSignature, + contractInteractions, + to: transaction.to, + } + + if (functionSignature) { + if (['approve', 'permit'].some((el) => functionSignature.includes(el))) { + result.contractInteractions = EthSendTransactionRPCActions.Approve + return result + } + try { + const URCalldata = parseURCalldata(transaction.data) + if (URCalldata) { + result.contractInteractions = EthSendTransactionRPCActions.Swap + result.parsedCalldata = URCalldata + return result + } + } catch (_e) { + noop() + } + try { + const NfPMCalldata = parseNfPMCalldata(transaction.data) + + if (NfPMCalldata) { + result.contractInteractions = EthSendTransactionRPCActions.LP + result.parsedCalldata = NfPMCalldata + return result + } + } catch (_e) { + noop() + } + if (functionSignature.includes('wrap')) { + result.contractInteractions = EthSendTransactionRPCActions.Wrap + return result + } + } + return result +} + +export default getCalldataInfoFromTransaction diff --git a/apps/extension/src/background/utils/loggerMiddleware.ts b/apps/extension/src/background/utils/loggerMiddleware.ts new file mode 100644 index 00000000000..b334591de1f --- /dev/null +++ b/apps/extension/src/background/utils/loggerMiddleware.ts @@ -0,0 +1,6 @@ +import { createLogger } from 'redux-logger' + +export const loggerMiddleware = createLogger({ + collapsed: true, + diff: true, +}) diff --git a/apps/extension/src/background/utils/persistedStateUtils.ts b/apps/extension/src/background/utils/persistedStateUtils.ts new file mode 100644 index 00000000000..f909373b007 --- /dev/null +++ b/apps/extension/src/background/utils/persistedStateUtils.ts @@ -0,0 +1,39 @@ +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { STATE_STORAGE_KEY } from 'src/store/constants' +import { ExtensionState } from 'src/store/extensionReducer' +import { readDeprecatedReduxedChromeStorage } from 'src/store/reduxedChromeStorageToReduxPersistMigration' + +export async function readReduxStateFromStorage(storageChanges?: { + [key: string]: chrome.storage.StorageChange +}): Promise { + const root = storageChanges + ? storageChanges[STATE_STORAGE_KEY]?.newValue + : (await chrome.storage.local.get(STATE_STORAGE_KEY))[STATE_STORAGE_KEY] + + if (!root) { + return undefined + } + + const rootParsed = JSON.parse(root) + + Object.keys(rootParsed).forEach((key) => { + // Each reducer must be parsed individually. + rootParsed[key] = JSON.parse(rootParsed[key]) + }) + + return rootParsed as ExtensionState +} + +export async function readIsOnboardedFromStorage(): Promise { + // The migration will happen in the sidebar, not in the background script, + // because the background script never persists the state (only reads it). + // So we need to check both the old and new storage keys to avoid the onboarding + // flow re-opening the first time the migration needs to run. + const [oldReduxedChromeStorageState, newReduxPersistState] = await Promise.all([ + readDeprecatedReduxedChromeStorage(), + readReduxStateFromStorage(), + ]) + + const state = oldReduxedChromeStorageState ?? newReduxPersistState + return state ? isOnboardedSelector(state) : false +} diff --git a/apps/extension/src/contentScript/WindowEthereumProxy.ts b/apps/extension/src/contentScript/WindowEthereumProxy.ts new file mode 100644 index 00000000000..2c479f5021d --- /dev/null +++ b/apps/extension/src/contentScript/WindowEthereumProxy.ts @@ -0,0 +1,160 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import EventEmitter from 'eventemitter3' +import { addWindowMessageListener, removeWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { BaseEthereumRequest, BaseEthereumRequestSchema } from 'src/contentScript/WindowEthereumRequestTypes' +import { ExtensionResponse, isValidExtensionResponse } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuidv4 } from 'uuid' +import { ZodError } from 'zod' + +type EthersSendCallback = (error: unknown, response: unknown) => void +type RequestInput = BaseEthereumRequest & { id?: number; jsonrpc?: string } + +const messages = { + errors: { + disconnected: (): string => 'Uniswap Wallet: Disconnected from chain. Attempting to connect.', + invalidRequestArgs: (): string => `Uniswap Wallet: Expected a single, non-array, object argument.`, + invalidRequestGeneric: (): string => `Uniswap Wallet: Please check the input passed to the request method`, + }, +} + +/** + * Proxy class that is injected at `window.ethereum` to handle all RPC and extension API requests. + * Passes along requests to the content script which then forwards and listens for requests accordingly. + */ +export class WindowEthereumProxy extends EventEmitter { + /** + * Boolean indicating that the provider is Uniswap Wallet. + */ + isUniswapWallet = true + + /** + * Boolean to spoof MetaMask + * TODO(EXT-393): Remove this once more dapps support EIP-6963 or have explicit support for Uniswap Wallet. + */ + isMetaMask: boolean + + /** + * Pending requests are stored as promises that resolve or reject based on the response from the content script. + */ + pendingRequests: { + [key: string]: { + resolve: (value: unknown) => void + reject: (error: unknown) => void + } + } + + constructor() { + super() + + this.isMetaMask = true + this.pendingRequests = {} + } + + // Deprecated EIP-11193 method + enable = async (): Promise => { + return this.request({ method: 'eth_requestAccounts' }) + } + + // Deprecated EIP-1193 method + send = ( + methodOrRequest: string | BaseEthereumRequest, + paramsOrCallback: Array | EthersSendCallback, + ): Promise | void => { + if (typeof methodOrRequest === 'string' && typeof paramsOrCallback !== 'function') { + return this.request({ + method: methodOrRequest, + params: paramsOrCallback, + }) + } else if (typeof methodOrRequest === 'object' && typeof paramsOrCallback === 'function') { + return this.sendAsync(methodOrRequest, paramsOrCallback) + } + return Promise.reject(new Error('Unsupported function parameters')) + } + + // Deprecated EIP-1193 method still in use by some DApps + sendAsync = ( + request: RequestInput, + callback: (error: unknown, response: unknown) => void, + ): Promise | void => { + return this.request(request).then( + (response) => + callback(null, { + result: response, + id: request.id, + jsonrpc: request.jsonrpc, + }), + (error) => callback(error, null), + ) + } + + request = async (args: RequestInput): Promise => { + return new Promise((resolve, reject) => { + try { + const ethereumRequest = BaseEthereumRequestSchema.parse(args) + + // Generate a unique ID for this request and store the promise callbacks + const requestId = uuidv4() + this.pendingRequests[requestId] = { resolve, reject } + const responseListener = addWindowMessageListener(isValidExtensionResponse, (response) => { + if (response.requestId === requestId) { + this.handleResponse(response) + removeWindowMessageListener(responseListener) + } + }) + window.postMessage({ + ...ethereumRequest, + requestId, + }) + } catch (error) { + logger.info('WindowEthereumProxy.ts', 'request', 'Invalid request', args) + + // Based on the zod error, we can determine the type of error and reject accordingly + if (error instanceof ZodError) { + return reject( + serializeError( + rpcErrors.invalidRequest({ + message: messages.errors.invalidRequestArgs(), + data: args, + }), + ), + ) + } + + return reject( + serializeError( + rpcErrors.invalidRequest({ + message: messages.errors.invalidRequestGeneric(), + data: args, + }), + ), + ) + } + }) + } + + private handleResponse(response: ExtensionResponse): boolean { + const { requestId, result, error } = response + const promise = this.pendingRequests[requestId] + if (!promise) { + logger.debug('WindowEthereumProxy.ts', 'handleResponse', 'No promise found for request id:', requestId) + return false + } + + if (error) { + promise.reject(error) + delete this.pendingRequests[requestId] + return true + } + + promise.resolve(result) + + // Clean up after handling the response + delete this.pendingRequests[requestId] + return true + } + // Utility function representing connectivity status for RPC requests to the current chain (as opposed to user accounts). + // Method itself created by MetaMask and not in EIP spec. Necessary since some dapps supporting EIP-6963 require it. + // TODO(EXT-1255): Currently faking real status, replace with actual implementation + isConnected = (): boolean => true +} diff --git a/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts b/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts new file mode 100644 index 00000000000..7e97b6c55d5 --- /dev/null +++ b/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts @@ -0,0 +1,323 @@ +import { ethers } from 'ethers' +import { EthersTransactionRequestSchema } from 'src/app/features/dappRequests/types/EthersTypes' +import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' +import { HomeTabs } from 'src/app/navigation/constants' +import { ZodIssueCode, z } from 'zod' + +/** + * Schemas + types for requests that come via `window.ethereum.request` + * e.g.: {"jsonrpc":"2.0","method":"personal_sign","params": ["0x295a70b2de5e3953354a6a8344e616ed314d7251", "0xasdfasdfasdfasdfasdfasdfa"],"id":1}' + * @see https://eips.ethereum.org/EIPS/eip-1193 + * @see https://docs.metamask.io/guide/ethereum-provider.html#ethereum-request + * @see https://docs.metamask.io/wallet/reference/json-rpc-api/ + * + * Note: Our schemas include transformations to make it easier to work with the data + */ + +export const BaseEthereumRequestSchema = z.object({ + method: z.string(), + params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(), +}) + +export const EthereumRequestWithIdSchema = BaseEthereumRequestSchema.extend({ + requestId: z.string(), +}) +export type EthereumRequestWithId = z.infer + +export type BaseEthereumRequest = z.infer + +export const EthChainIdRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_chainId'), +}) +export type EthChainIdRequest = z.infer + +export const EthRequestAccountsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_requestAccounts'), +}) +export type EthRequestAccountsRequest = z.infer + +export const EthAccountsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_accounts'), +}) +export type EthAccountsRequest = z.infer +export const EthSendTransactionRequestSchema = EthereumRequestWithIdSchema.extend({ + requestId: z.string(), + method: z.literal('eth_sendTransaction'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new Error('Params array must contain at least one element') + } + + const parseResult = EthersTransactionRequestSchema.safeParse(params[0]) + + if (!parseResult.success) { + throw new Error('First element of the array must match EthersTransactionRequestSchema') + } + + const transaction = parseResult.data + + return { + requestId, + method, + params, + transaction, + } +}) +export type EthSendTransactionRequest = z.infer + +export const PersonalSignRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('personal_sign'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 2) { + throw new z.ZodError([ + { + message: 'Params array must contain at least two elements', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const messageHex = z.string().parse(params[0]) + + try { + ethers.utils.toUtf8String(messageHex) + } catch { + throw new z.ZodError([ + { + message: 'Message hex is not a valid hex string', + path: ['params', 'hexMessage'], + code: ZodIssueCode.custom, + }, + ]) + } + + const address = z.string().parse(params[1]) + + return { + requestId, + method, + params, + messageHex, + address, + } +}) + +export type PersonalSignRequest = z.infer + +export const EthSignTransactionRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_signTransaction'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const parseResult = EthersTransactionRequestSchema.safeParse(params[0]) + if (!parseResult.success) { + throw new z.ZodError([ + { + message: 'First element of the array must match EthersTransactionRequestSchema', + path: ['params', '0'], + code: ZodIssueCode.custom, + }, + ]) + } + const transaction = parseResult.data + + return { + requestId, + method, + params, + transaction, + } +}) +export type EthSignTransactionRequest = z.infer + +export const EthSignTypedDataV4RequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_signTypedData_v4'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 2) { + throw new z.ZodError([ + { + message: 'Params array must contain at least two elements', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const address = z.string().parse(params[0]) + const typedData = z.string().parse(params[1]) + + const chainId = JSON.parse(typedData)?.domain?.chainId + const formattedChainId = HexadecimalNumberSchema.parse(chainId) + if (!formattedChainId) { + throw new z.ZodError([ + { + message: 'Typed data must contain a chainId', + path: ['params', '1'], + code: ZodIssueCode.custom, + }, + ]) + } + return { + requestId, + method, + params, + address, + typedData, + } +}) +export type EthSignTypedDataV4Request = z.infer + +export const SwitchEthereumChainParameterSchema = z.object({ + chainId: z.string(), +}) + +export const WalletSwitchEthereumChainRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_switchEthereumChain'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const parseResult = SwitchEthereumChainParameterSchema.safeParse(params[0]) + if (!parseResult.success) { + throw new z.ZodError([ + { + message: 'Chain id should be specified as a hexadecimal string within object', + path: ['params', '0'], + code: ZodIssueCode.custom, + }, + ]) + } + + const { chainId } = parseResult.data + + return { + requestId, + method, + params, + chainId, + } +}) +export type WalletSwitchEthereumChainRequest = z.infer + +// eslint-disable-next-line no-restricted-syntax +export const PermissionRequestSchema = z.record(z.record(z.any())) + +export const RequestedPermissionSchema = z.object({ + parentCapability: z.string(), // name of the method for which the permission is requested + date: z.number().optional(), // in UNIX time +}) + +export const CaveatSchema = z.object({ + type: z.string(), + // eslint-disable-next-line no-restricted-syntax + value: z.any(), +}) +export type Caveat = z.infer + +export const PermissionSchema = z.object({ + invoker: z.string(), + parentCapability: z.string(), + caveats: z.array(CaveatSchema), +}) +export type Permission = z.infer + +export const WalletRequestPermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_requestPermissions'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const permissions = PermissionRequestSchema.parse(params[0]) + + return { + requestId, + method, + params, + permissions, + } +}) + +export type WalletRequestPermissionsRequest = z.infer + +export const WalletRevokePermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_revokePermissions'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const permissions = PermissionRequestSchema.parse(params[0]) + + return { + requestId, + method, + params, + permissions, + } +}) + +export type WalletRevokePermissionsRequest = z.infer + +export const WalletGetPermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_getPermissions'), +}) +export type WalletGetPermissionsRequest = z.infer + +export const UniswapOpenSidebarRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('uniswap_openSidebar'), + params: z.array(z.unknown()), +}).transform((data) => { + const tab = z.nativeEnum(HomeTabs).optional().parse(data.params[0]) + return { + ...data, + tab, + } +}) + +export type UniswapOpenSidebarRequest = z.infer diff --git a/apps/extension/src/contentScript/ethereum.ts b/apps/extension/src/contentScript/ethereum.ts new file mode 100644 index 00000000000..7864cb1953f --- /dev/null +++ b/apps/extension/src/contentScript/ethereum.ts @@ -0,0 +1,84 @@ +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { WindowEthereumProxy } from 'src/contentScript/WindowEthereumProxy' +import { isValidContentScriptToProxyEmission } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuid } from 'uuid' + +// TODO(xtine): Get this working by importing the svg file directly. The svg text comes from packages/ui/src/assets/icons/uniswap-logo.svg +const UNISWAP_LOGO = `data:image/svg+xml,${encodeURIComponent(` + + + + + + + + + + + + +`)}` +const UNISWAP_NAME = 'Uniswap Extension' +const UNISWAP_RDNS = 'org.uniswap.app' + +declare global { + interface Window { + isStretchInstalled?: boolean + ethereum?: WindowEthereumProxy + } +} + +enum EIP6963EventNames { + Announce = 'eip6963:announceProvider', + Request = 'eip6963:requestProvider', +} + +interface EIP6963ProviderInfo { + uuid: string + name: string + icon: string + rdns: string +} + +const uniswapProvider = new WindowEthereumProxy() +window.ethereum = uniswapProvider + +addWindowMessageListener(isValidContentScriptToProxyEmission, (message) => { + logger.debug('ethereum.ts', `Emitting ${message.emitKey} via WindowEthereumProxy`, message.emitValue) + uniswapProvider.emit(message.emitKey, message.emitValue) +}) +function announceProvider(): void { + const info: EIP6963ProviderInfo = { + uuid: uuid(), + name: UNISWAP_NAME, + icon: UNISWAP_LOGO, + rdns: UNISWAP_RDNS, + } + + window.dispatchEvent( + new CustomEvent(EIP6963EventNames.Announce, { + detail: Object.freeze({ info, provider: uniswapProvider }), + }), + ) +} + +window.addEventListener(EIP6963EventNames.Request, (event) => { + if (!isValidRequestProviderEvent(event)) { + throw new Error( + `Invalid EIP-6963 RequestProviderEvent object received from ${EIP6963EventNames.Request} event. See https://eips.ethereum.org/EIPS/eip-6963 for requirements.`, + ) + } + + announceProvider() +}) + +announceProvider() + +type EIP6963RequestProviderEvent = Event & { + type: EIP6963EventNames.Request +} + +function isValidRequestProviderEvent(event: unknown): event is EIP6963RequestProviderEvent { + return event instanceof Event && event.type === EIP6963EventNames.Request +} diff --git a/apps/extension/src/contentScript/index.tsx b/apps/extension/src/contentScript/index.tsx new file mode 100644 index 00000000000..96d0a1de30d --- /dev/null +++ b/apps/extension/src/contentScript/index.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' + +const container = document.createElement('div') +container.id = 'crx-root' +document.body.append(container) + +const root = createRoot(container) +root.render() diff --git a/apps/extension/src/contentScript/injected.test.ts b/apps/extension/src/contentScript/injected.test.ts new file mode 100644 index 00000000000..859888dd8a8 --- /dev/null +++ b/apps/extension/src/contentScript/injected.test.ts @@ -0,0 +1,11 @@ +jest.mock('src/background/messagePassing/messageChannels') + +describe('injected', () => { + it('should run without throwing an error', () => { + // This does not exist in the extension execution environment for content scripts + Object.defineProperty(document, 'head', { value: undefined, writable: true }) + + const injected = require('./injected') + expect(injected).toBeTruthy() + }) +}) diff --git a/apps/extension/src/contentScript/injected.ts b/apps/extension/src/contentScript/injected.ts new file mode 100644 index 00000000000..85ad15cc1cf --- /dev/null +++ b/apps/extension/src/contentScript/injected.ts @@ -0,0 +1,267 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { dappStore } from 'src/app/features/dapp/store' +import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils' +import { backgroundStore } from 'src/background/backgroundStore' +import { + contentScriptUtilityMessageChannel, + externalDappMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { + ContentScriptUtilityMessageType, + ErrorLog, + ExtensionToDappRequestType, + InfoLog, +} from 'src/background/messagePassing/types/requests' +import { ExtensionEthMethodHandler } from 'src/contentScript/methodHandlers/ExtensionEthMethodHandler' +import { ProviderDirectMethodHandler } from 'src/contentScript/methodHandlers/ProviderDirectMethodHandler' +import { UniswapMethodHandler } from 'src/contentScript/methodHandlers/UniswapMethodHandler' +import { emitAccountsChanged, emitChainChanged } from 'src/contentScript/methodHandlers/emitUtils' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { + isDeprecatedMethod, + isExtensionEthMethod, + isProviderDirectMethod, + isUniswapMethod, + isUnsupportedMethod, + postDeprecatedMethodError, + postParsingError, + postUnknownMethodError, +} from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest, isValidWindowEthereumRequest } from 'src/contentScript/types' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { RPCType } from 'uniswap/src/types/chains' +import { logger } from 'utilities/src/logger/logger' +import { arraysAreEqual } from 'utilities/src/primitives/array' +import { walletContextValue } from 'wallet/src/features/wallet/context' + +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { ZodError } from 'zod' + +let _provider: JsonRpcProvider | undefined +let _chainId: string | undefined +let connectedAddresses: Address[] | undefined +const dappUrl = window.origin + +const getChainId = (): string | undefined => { + const storedChainId = dappStore.getDappInfo(dappUrl)?.lastChainId + + if (_chainId === undefined && storedChainId) { + _chainId = chainIdToHexadecimalString(storedChainId) + } + + return _chainId +} + +const getProvider = (): JsonRpcProvider | undefined => _provider +const getConnectedAddresses = (): Address[] | undefined => { + const storedDappInfo = dappStore.getDappInfo(dappUrl) + const storedConnectedAddresses = + storedDappInfo && + getOrderedConnectedAddresses(storedDappInfo.connectedAccounts, storedDappInfo.activeConnectedAddress) + return connectedAddresses ?? storedConnectedAddresses +} + +const setProvider = (newProvider: JsonRpcProvider): void => { + _provider = newProvider +} +const setChainIdAndMaybeEmit = (newChainId: string): void => { + // Only emit if the chain have changed, and it's not the first time + if (_chainId !== undefined && _chainId !== newChainId) { + emitChainChanged(newChainId) + } + _chainId = newChainId +} + +const setConnectedAddressesAndMaybeEmit = (newConnectedAddresses: Address[]): void => { + // Only emit if the addresses have changed, and it's not the first time + const normalizedNewAddresses: Address[] = newConnectedAddresses + .map((address) => getValidAddress(address)) + .filter((normalizedAddress): normalizedAddress is Address => normalizedAddress !== null) + + if (!connectedAddresses || !arraysAreEqual(connectedAddresses, normalizedNewAddresses)) { + emitAccountsChanged(normalizedNewAddresses) + } + connectedAddresses = normalizedNewAddresses +} + +const extensionEthMethodHandler = new ExtensionEthMethodHandler( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, +) +const providerDirectMethodHandler = new ProviderDirectMethodHandler( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, +) + +const uniswapMethodHandler = new UniswapMethodHandler( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, +) + +addWindowMessageListener(isValidWindowEthereumRequest, async (request, source) => { + logger.debug('injected.ts', 'Request received for method', JSON.stringify(request), _provider) + + if (!backgroundStore.state.isOnboarded) { + rejectRequestNotOnboarded(request, source).catch((error) => + logError( + error?.message ?? 'Error rejecting request when not onboarded', + 'injected.ts', + 'WindowEthereumRequestListener', + ), + ) + return + } + + if (isProviderDirectMethod(request.method)) { + // Provider methods are handled directly by the provider instance + // (avoiding roundtrip to background service worker) + providerDirectMethodHandler.handleRequest(request, source) + return + } + + if (isUniswapMethod(request.method)) { + try { + await uniswapMethodHandler.handleRequest(request, source) + } catch (e) { + if (e instanceof ZodError) { + postParsingError(source, request.requestId, request.method) + } + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + await logError(errorMessage, 'injected.ts', 'WindowEthereumRequest') + } + return + } + + if (isExtensionEthMethod(request.method)) { + try { + await extensionEthMethodHandler.handleRequest(request, source) + } catch (e) { + if (e instanceof ZodError) { + postParsingError(source, request.requestId, request.method) + } + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + await logError(errorMessage, 'injected.ts', 'WindowEthereumRequest') + } + return + } + + if (isDeprecatedMethod(request.method)) { + postDeprecatedMethodError(source, request.requestId, request.method) + await logInfo('injected.ts', 'WindowEthereumRequest', 'Deprecated method', { + method: request.method, + dappUrl, + }) + return + } + + if (isUnsupportedMethod(request.method)) { + postUnknownMethodError(source, request.requestId, request.method) + await logInfo('injected.ts', 'WindowEthereumRequest', 'Unsupported method', { + method: request.method, + dappUrl, + }) + return + } + + // Handle any methods we don't know how to handle and are not in the metamask API + await logInfo('injected.ts', 'WindowEthereumRequest', 'Unrecognized method', { + method: request.method, + dappUrl, + }) + postUnknownMethodError(source, request.requestId, request.method) +}) + +externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.SwitchChain, (message) => { + setChainIdAndMaybeEmit(message.chainId) + setProvider(new JsonRpcProvider(message.providerUrl)) +}) + +externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.UpdateConnections, (message) => { + setConnectedAddressesAndMaybeEmit(message.addresses) +}) + +async function init(): Promise { + try { + await Promise.all([backgroundStore.init(), dappStore.init()]) + + const chainId = getChainId() + const provider = getProvider() + + if (chainId && !provider) { + const chainIdNum = parseInt(chainId, 16) + const defaultProvider = walletContextValue.providers.getProvider(chainIdNum, RPCType.Public) + setProvider(defaultProvider) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + await logError(errorMessage, 'injected.ts', 'init') + } +} + +/** Helper function to reject all requests from dapps when the extension is not onboarded. */ +async function rejectRequestNotOnboarded( + request: WindowEthereumRequest, + source: MessageEventSource | null, +): Promise { + if ( + request.method === ExtensionEthMethods.eth_requestAccounts || + request.method === ExtensionEthMethods.wallet_requestPermissions + ) { + await contentScriptUtilityMessageChannel.sendMessage({ + type: ContentScriptUtilityMessageType.FocusOnboardingTab, + }) + } + + source?.postMessage({ + requestId: request.requestId, + error: serializeError(providerErrors.userRejectedRequest()), + }) +} + +init().catch(() => {}) + +async function logError( + errorMessage: string, + fileName: string, + functionName: string, + tags?: Record, +): Promise { + const message: ErrorLog = { + type: ContentScriptUtilityMessageType.ErrorLog, + message: errorMessage, + fileName, + functionName, + tags, + } + await contentScriptUtilityMessageChannel.sendMessage(message) +} + +async function logInfo( + fileName: string, + functionName: string, + message: string, + tags: Record, +): Promise { + const logMessage: InfoLog = { + type: ContentScriptUtilityMessageType.InfoLog, + fileName, + functionName, + message, + tags, + } + await contentScriptUtilityMessageChannel.sendMessage(logMessage) +} diff --git a/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts new file mode 100644 index 00000000000..e9bd18c2baa --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts @@ -0,0 +1,16 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { WindowEthereumRequest } from 'src/contentScript/types' + +export abstract class BaseMethodHandler { + constructor( + protected readonly getChainId: () => string | undefined, + protected readonly getProvider: () => JsonRpcProvider | undefined, + protected readonly getConnectedAddresses: () => Address[] | undefined, + protected readonly setChainIdAndMaybeEmit: (newChainId: string) => void, + protected readonly setProvider: (newProvider: JsonRpcProvider) => void, + protected readonly setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handleRequest(request: T, source: MessageEventSource | null): void {} +} diff --git a/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts new file mode 100644 index 00000000000..e62c7871e2a --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts @@ -0,0 +1,483 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { getPermissions } from 'src/app/features/dappRequests/permissions' +import { + DappRequestType, + DappResponseType, + SendTransactionRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import getCalldataInfoFromTransaction from 'src/background/utils/getCalldataInfoFromTransaction' +import { + EthAccountsRequest, + EthAccountsRequestSchema, + EthChainIdRequest, + EthChainIdRequestSchema, + EthRequestAccountsRequest, + EthRequestAccountsRequestSchema, + EthSendTransactionRequest, + EthSendTransactionRequestSchema, + EthSignTypedDataV4Request, + EthSignTypedDataV4RequestSchema, + PersonalSignRequest, + PersonalSignRequestSchema, + WalletGetPermissionsRequest, + WalletGetPermissionsRequestSchema, + WalletRequestPermissionsRequest, + WalletRequestPermissionsRequestSchema, + WalletRevokePermissionsRequest, + WalletRevokePermissionsRequestSchema, + WalletSwitchEthereumChainRequest, + WalletSwitchEthereumChainRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { getPendingResponseInfo, postUnauthorizedError } from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' + +export class ExtensionEthMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + constructor( + getChainId: () => string | undefined, + getProvider: () => JsonRpcProvider | undefined, + getConnectedAddresses: () => Address[] | undefined, + setChainIdAndMaybeEmit: (newChainId: string) => void, + setProvider: (newProvider: JsonRpcProvider) => void, + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.AccountResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.AccountResponse, + )?.source + + this.handleDappUpdate(message.connectedAddresses, message.chainId, message.providerUrl) + source?.postMessage({ + requestId: message.requestId, + result: message.connectedAddresses, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ChainIdResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.ChainIdResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.chainId, + }) + + const chainId = this.getChainId() + if (!chainId) { + window.postMessage({ + emitKey: 'connect', + emitValue: { + chainId: message.chainId, + }, + }) + } + + this.setChainIdAndMaybeEmit(message.chainId) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ChainChangeResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.ChainChangeResponse, + )?.source + + this.setChainIdAndMaybeEmit(message.chainId) + this.setProvider(new JsonRpcProvider(message.providerUrl)) + source?.postMessage({ + requestId: message.requestId, + result: message.chainId, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SendTransactionResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SendTransactionResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.transactionResponse.hash, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignMessageResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SignMessageResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signature, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignTransactionResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SignTransactionResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signedTransactionHash, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignTypedDataResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SignTypedDataResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signature, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.RequestPermissionsResponse, (message) => { + if (message.accounts) { + const { connectedAddresses, chainId, providerUrl } = message.accounts + this.handleDappUpdate(connectedAddresses, chainId, providerUrl) + } + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.RequestPermissionsResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.permissions, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.RevokePermissionsResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.RevokePermissionsResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: null, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ErrorResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.ErrorResponse, + )?.source + + source?.postMessage(message) + }) + } + + private isAuthorized(): boolean { + const connectedAddresses = this.getConnectedAddresses() + return !!connectedAddresses?.length + } + + private isConnectedToDapp(): boolean { + // Fields that should be populated for connected dapps + return Boolean(this.getConnectedAddresses()?.length && this.getChainId() && this.getProvider()) + } + + private handleDappUpdate(connectedAddresses: string[], chainId: string, providerUrl: string): void { + this.setConnectedAddressesAndMaybeEmit(connectedAddresses) + this.setChainIdAndMaybeEmit(chainId) + this.setProvider(new JsonRpcProvider(providerUrl)) + } + + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case ExtensionEthMethods.eth_chainId: { + const ethChainIdRequest = EthChainIdRequestSchema.parse(request) + await this.handleEthChainIdRequest(ethChainIdRequest, source) + break + } + case ExtensionEthMethods.eth_requestAccounts: { + const parsedRequest = EthRequestAccountsRequestSchema.parse(request) + await this.handleEthRequestAccounts(parsedRequest, source) + break + } + case ExtensionEthMethods.eth_accounts: { + const parsedRequest = EthAccountsRequestSchema.parse(request) + await this.handleEthAccounts(parsedRequest, source) + break + } + case ExtensionEthMethods.eth_sendTransaction: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = EthSendTransactionRequestSchema.parse(request) + await this.handleEthSendTransaction(parsedRequest, source) + break + } + case ExtensionEthMethods.wallet_switchEthereumChain: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = WalletSwitchEthereumChainRequestSchema.parse(request) + await this.handleWalletSwitchEthereumChain(parsedRequest, source) + break + } + case ExtensionEthMethods.wallet_getPermissions: { + const parsedRequest = WalletGetPermissionsRequestSchema.parse(request) + await this.handleWalletGetPermissions(parsedRequest, source) + break + } + + case ExtensionEthMethods.wallet_requestPermissions: { + const parsedRequest = WalletRequestPermissionsRequestSchema.parse(request) + await this.handleWalletRequestPermissions(parsedRequest, source) + break + } + case ExtensionEthMethods.wallet_revokePermissions: { + const parsedRequest = WalletRevokePermissionsRequestSchema.parse(request) + await this.handleWalletRevokePermissions(parsedRequest, source) + break + } + case ExtensionEthMethods.personal_sign: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + + const parsedRequest = PersonalSignRequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + + await this.handlePersonalSign(parsedRequest, source) + break + } + case ExtensionEthMethods.eth_signTypedData_v4: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + + const parsedRequest = EthSignTypedDataV4RequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + + await this.handleEthSignTypedData(parsedRequest, source) + break + } + } + } + + async handleEthChainIdRequest(request: EthChainIdRequest, source: MessageEventSource | null): Promise { + // Defaults to mainnet for unconnected dapps + const chainId = this.getChainId() ?? chainIdToHexadecimalString(UniverseChainId.Mainnet) + + source?.postMessage({ + requestId: request.requestId, + result: chainId, + }) + return + } + + async handleEthRequestAccounts(request: EthRequestAccountsRequest, source: MessageEventSource | null): Promise { + const connectedAddresses = this.getConnectedAddresses() + + if (connectedAddresses?.length && this.isConnectedToDapp()) { + source?.postMessage({ + requestId: request.requestId, + result: connectedAddresses, + }) + return + } + + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.AccountResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RequestAccount, + requestId: request.requestId, + }) + } + + async handleEthAccounts(request: EthAccountsRequest, source: MessageEventSource | null): Promise { + const connectedAddresses = this.getConnectedAddresses() + + if (connectedAddresses?.length && this.isConnectedToDapp()) { + source?.postMessage({ + requestId: request.requestId, + result: connectedAddresses, + }) + return + } + + postUnauthorizedError(source, request.requestId) + } + + async handleEthSendTransaction(request: EthSendTransactionRequest, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SendTransactionResponse, + source, + }) + + const sendTransactionRequest: SendTransactionRequest = { + type: DappRequestType.SendTransaction, + requestId: request.requestId, + transaction: adaptTransactionForEthers(request.transaction), + } + + // native transactions like native send will not have populated data field + const requestIncludesData = Boolean(request.transaction.data) + + if (requestIncludesData && request.transaction.data !== '0x') { + Object.assign(sendTransactionRequest, getCalldataInfoFromTransaction(request.transaction)) + } + + await contentScriptToBackgroundMessageChannel.sendMessage(sendTransactionRequest) + } + + async handlePersonalSign(request: PersonalSignRequest, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SignMessageResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SignMessage, + requestId: request.requestId, + messageHex: request.messageHex, + address: request.address, + }) + } + + async handleEthSignTypedData(request: EthSignTypedDataV4Request, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SignTypedDataResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SignTypedData, + requestId: request.requestId, + typedData: request.typedData, + address: request.address, + }) + } + + async handleWalletSwitchEthereumChain( + request: WalletSwitchEthereumChainRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.ChainChangeResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.ChangeChain, + requestId: request.requestId, + chainId: request.chainId, + }) + } + + async handleWalletGetPermissions( + request: WalletGetPermissionsRequest, + source: MessageEventSource | null, + ): Promise { + const dappUrl = extractBaseUrl(window.origin) + const connectedAddresses = this.getConnectedAddresses() + + const permissions = getPermissions(dappUrl, connectedAddresses) + + source?.postMessage({ + requestId: request.requestId, + result: permissions, + }) + } + + async handleWalletRequestPermissions( + request: WalletRequestPermissionsRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.RequestPermissionsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RequestPermissions, + requestId: request.requestId, + permissions: request.permissions, + }) + } + + async handleWalletRevokePermissions( + request: WalletRevokePermissionsRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.RevokePermissionsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RevokePermissions, + requestId: request.requestId, + permissions: request.permissions, + }) + } + + private isValidRequestAddress(address: string): boolean { + return (this.getConnectedAddresses() ?? []).some((connectedAddress) => areAddressesEqual(connectedAddress, address)) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function adaptTransactionForEthers(transaction: any): any { + if (typeof transaction.chainId === 'string') { + transaction.chainId = parseInt(transaction.chainId, 16) + } + return transaction +} diff --git a/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts new file mode 100644 index 00000000000..78374af5b54 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts @@ -0,0 +1,117 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { BigNumber } from 'ethers' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { ProviderDirectMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' + +/** + * Handles all provider direct requests + * Maps Ethereum JSON-RPC methods to their corresponding ethers.js provider method calls. + */ + +export class ProviderDirectMethodHandler extends BaseMethodHandler { + private methodHandlers: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: (provider: JsonRpcProvider, params: any[]) => Promise + } + + constructor( + getChainId: () => string | undefined, + getProvider: () => JsonRpcProvider | undefined, + getConnectedAddresses: () => Address[] | undefined, + setChainIdAndMaybeEmit: (newChainId: string) => void, + setProvider: (newProvider: JsonRpcProvider) => void, + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + this.methodHandlers = { + /* eslint-disable @typescript-eslint/explicit-function-return-type */ + [ProviderDirectMethods.eth_getBalance]: (provider, params) => provider.getBalance(params[0]), + [ProviderDirectMethods.eth_getCode]: (provider, params) => provider.getCode(params[0]), + [ProviderDirectMethods.eth_getStorageAt]: (provider, params) => provider.getStorageAt(params[0], params[1]), + [ProviderDirectMethods.eth_getTransactionCount]: (provider, params) => provider.getTransactionCount(params[0]), + [ProviderDirectMethods.eth_blockNumber]: (provider, _params) => provider.getBlockNumber(), + [ProviderDirectMethods.eth_getBlockByNumber]: (provider, params) => provider.getBlock(params[0]), + [ProviderDirectMethods.eth_call]: (provider, params) => provider.call(params[0]), + [ProviderDirectMethods.eth_gasPrice]: (provider, _params) => provider.getGasPrice(), + [ProviderDirectMethods.eth_estimateGas]: (provider, params) => provider.estimateGas(params[0]), + [ProviderDirectMethods.eth_getTransactionByHash]: (provider, params) => provider.getTransaction(params[0]), + [ProviderDirectMethods.eth_getTransactionReceipt]: (provider, params) => + provider.getTransactionReceipt(params[0]), + [ProviderDirectMethods.net_version]: async (provider, params) => provider.send('net_version', params), + [ProviderDirectMethods.web3_clientVersion]: async (provider, params) => + provider.send('web3_clientVersion', params), + } + } + + handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): void { + const handler = this.methodHandlers[request.method] + if (handler) { + const provider = this.getProvider() + if (!provider) { + // TODO: Handle error for disconnection + return + } + const response = handler(provider, request.params) + this.handleResponse(response, source, request.requestId) + } else { + // We shouldn't end up here because injected.ts checks that the method is supported before calling this function + logger.error(new Error('Unexpected method requested'), { + tags: { + file: 'ProviderDirectMethodHandler.ts', + function: 'handleRequest', + }, + extra: { + method: request.method, + dapp: window.origin, + }, + }) + } + } + + private handleResponse( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: Promise, + source: MessageEventSource | null, + requestId: string, + ): void { + response + .then((result) => { + source?.postMessage({ + requestId, + result: JSON.parse( + JSON.stringify(result, (_key, value) => { + if (!value) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value + } else if (BigNumber.isBigNumber(value)) { + return value.toHexString() + } else if (value.type === 'BigNumber' && value.hex) { + // Unsure of why but sometimes the provider has converted the BigNumber with BigNumber.toJSON() e.g. eth_getBlockByNumber + // which is a format not currently accepted by some dapps e.g. Morpho + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value.hex + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value + }), + ), + }) + }) + .catch((error) => { + source?.postMessage({ + requestId, + error, + }) + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts new file mode 100644 index 00000000000..bbb888cc949 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts @@ -0,0 +1,81 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { DappRequestType, DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { UniswapMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { getPendingResponseInfo } from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' + +/** + * Handles all uniswap-specific requests + */ + +export class UniswapMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + constructor( + getChainId: () => string | undefined, + getProvider: () => JsonRpcProvider | undefined, + getConnectedAddresses: () => Address[] | undefined, + setChainIdAndMaybeEmit: (newChainId: string) => void, + setProvider: (newProvider: JsonRpcProvider) => void, + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.UniswapOpenSidebarResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.UniswapOpenSidebarResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + }) + }) + } + + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case UniswapMethods.uniswap_openSidebar: { + logger.debug("Handling 'uniswap_openSidebar' request", request.method, request.toString()) + const uniswapOpenTokensRequest = UniswapOpenSidebarRequestSchema.parse(request) + await this.handleUniswapOpenSidebarRequest(uniswapOpenTokensRequest, source) + break + } + } + } + + private async handleUniswapOpenSidebarRequest( + request: UniswapOpenSidebarRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + source, + type: DappResponseType.UniswapOpenSidebarResponse, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.UniswapOpenSidebar, + requestId: request.requestId, + tab: request.tab, + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/emitUtils.ts b/apps/extension/src/contentScript/methodHandlers/emitUtils.ts new file mode 100644 index 00000000000..fb2f96b7770 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/emitUtils.ts @@ -0,0 +1,12 @@ +export function emitChainChanged(newChainId: string): void { + window?.postMessage({ + emitKey: 'chainChanged', + emitValue: newChainId, + }) +} +export function emitAccountsChanged(newConnectedAddresses: Address[]): void { + window?.postMessage({ + emitKey: 'accountsChanged', + emitValue: newConnectedAddresses, + }) +} diff --git a/apps/extension/src/contentScript/methodHandlers/requestMethods.ts b/apps/extension/src/contentScript/methodHandlers/requestMethods.ts new file mode 100644 index 00000000000..45eefe8a59f --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/requestMethods.ts @@ -0,0 +1,89 @@ +// List of eth methods that the extension will handle +/* eslint-disable @typescript-eslint/naming-convention */ +export enum ExtensionEthMethods { + eth_chainId = 'eth_chainId', + eth_requestAccounts = 'eth_requestAccounts', + eth_accounts = 'eth_accounts', + eth_sendTransaction = 'eth_sendTransaction', + personal_sign = 'personal_sign', + wallet_switchEthereumChain = 'wallet_switchEthereumChain', + wallet_getPermissions = 'wallet_getPermissions', + wallet_requestPermissions = 'wallet_requestPermissions', + wallet_revokePermissions = 'wallet_revokePermissions', + eth_signTypedData_v4 = 'eth_signTypedData_v4', +} + +// Custom Uniswap methods that the extension will handle +/* eslint-disable @typescript-eslint/naming-convention */ +export enum UniswapMethods { + uniswap_openSidebar = 'uniswap_openSidebar', +} + +// Methods that are not supported by the extension because they are deprecated +/* eslint-disable @typescript-eslint/naming-convention */ +export enum DeprecatedEthMethods { + eth_sign = 'eth_sign', // Security risk + eth_signTypedData_v3 = 'eth_signTypedData_v3', + eth_signTypedData_v1 = 'eth_signTypedData_v1', + eth_decrypt = 'eth_decrypt', + eth_getEncryptionPublicKey = 'eth_getEncryptionPublicKey', +} + +// Methods that are handled by Metamask but not by the extension. These are logged +// so we can either display an error to the user or track frequency. +// Depending on the frequency with which we see these methods we could show an error +// in the sidebar for users. +// The methods come from: https://docs.metamask.io/wallet/reference/json-rpc-api/ +/* eslint-disable @typescript-eslint/naming-convention */ +export enum UnsupportedEthMethods { + wallet_addEthereumChain = 'wallet_addEthereumChain', + wallet_registerOnboarding = 'wallet_registerOnboarding', + wallet_watchAsset = 'wallet_watchAsset', + wallet_scanQRCode = 'wallet_scanQRCode', + wallet_getSnaps = 'wallet_getSnaps', + wallet_requestSnaps = 'wallet_requestSnaps', + wallet_snap = 'wallet_snap', + wallet_invokeSnap = 'wallet_invokeSnap', + eth_subscribe = 'eth_subscribe', + eth_unsubscribe = 'eth_unsubscribe', + eth_blobBaseFee = 'eth_blobBaseFee', + eth_coinbase = 'eth_coinbase', + eth_feeHistory = 'eth_feeHistory', + eth_getBlockByHash = 'eth_getBlockByHash', + eth_getBlockTransactionCountByHash = 'eth_getBlockTransactionCountByHash', + eth_getBlockTransactionCountByNumber = 'eth_getBlockTransactionCountByNumber', + eth_getFilterChanges = 'eth_getFilterChanges', + eth_getFilterLogs = 'eth_getFilterLogs', + eth_getLogs = 'eth_getLogs', + eth_getProof = 'eth_getProof', + eth_getStorageAt = 'eth_getStorageAt', + eth_getTransactionByBlockHashAndIndex = 'eth_getTransactionByBlockHashAndIndex', + eth_getTransactionByBlockNumberAndIndex = 'eth_getTransactionByBlockNumberAndIndex', + eth_getTransactionCount = 'eth_getTransactionCount', + eth_getUncleCountByBlockHash = 'eth_getUncleCountByBlockHash', + eth_getUncleCountByBlockNumber = 'eth_getUncleCountByBlockNumber', + eth_maxPriorityFeePerGas = 'eth_maxPriorityFeePerGas', + eth_newBlockFilter = 'eth_newBlockFilter', + eth_newFilter = 'eth_newFilter', + eth_newPendingTransactionFilter = 'eth_newPendingTransactionFilter', + eth_sendRawTransaction = 'eth_sendRawTransaction', + eth_syncing = 'eth_syncing', + eth_uninstallFilter = 'eth_uninstallFilter', + eth_signTransaction = 'eth_signTransaction', +} + +export enum ProviderDirectMethods { + eth_getBalance = 'eth_getBalance', + eth_getCode = 'eth_getCode', + eth_getStorageAt = 'eth_getStorageAt', + eth_getTransactionCount = 'eth_getTransactionCount', + eth_blockNumber = 'eth_blockNumber', + eth_getBlockByNumber = 'eth_getBlockByNumber', + eth_call = 'eth_call', + eth_gasPrice = 'eth_gasPrice', + eth_estimateGas = 'eth_estimateGas', + eth_getTransactionByHash = 'eth_getTransactionByHash', + eth_getTransactionReceipt = 'eth_getTransactionReceipt', + net_version = 'net_version', + web3_clientVersion = 'web3_clientVersion', +} diff --git a/apps/extension/src/contentScript/methodHandlers/types.ts b/apps/extension/src/contentScript/methodHandlers/types.ts new file mode 100644 index 00000000000..c0f77c234e1 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/types.ts @@ -0,0 +1,6 @@ +import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' + +export type PendingResponseInfo = { + type: DappResponseType + source: MessageEventSource | null +} diff --git a/apps/extension/src/contentScript/methodHandlers/utils.ts b/apps/extension/src/contentScript/methodHandlers/utils.ts new file mode 100644 index 00000000000..ecbf2c59690 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/utils.ts @@ -0,0 +1,89 @@ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + DeprecatedEthMethods, + ExtensionEthMethods, + ProviderDirectMethods, + UniswapMethods, + UnsupportedEthMethods, +} from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { logger } from 'utilities/src/logger/logger' + +export function isProviderDirectMethod(method: string): boolean { + return Object.keys(ProviderDirectMethods).includes(method) +} + +export function isUniswapMethod(method: string): boolean { + return Object.keys(UniswapMethods).includes(method) +} + +export function isExtensionEthMethod(method: string): boolean { + return Object.keys(ExtensionEthMethods).includes(method) +} + +export function isDeprecatedMethod(method: string): boolean { + return Object.keys(DeprecatedEthMethods).includes(method) +} + +export function isUnsupportedMethod(method: string): boolean { + return Object.keys(UnsupportedEthMethods).includes(method) +} + +export function postDeprecatedMethodError(source: MessageEventSource | null, requestId: string, method: string): void { + source?.postMessage({ + requestId, + error: serializeError( + providerErrors.unsupportedMethod(`Uniswap Wallet does not support ${method} as it is deprecated`), + ), + }) +} + +export function postUnknownMethodError(source: MessageEventSource | null, requestId: string, method: string): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unsupportedMethod(`Uniswap Wallet does not support ${method}`)), + }) +} + +export function postUnauthorizedError(source: MessageEventSource | null, requestId: string): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unauthorized()), + }) +} + +export function postParsingError(source: MessageEventSource | null, requestId: string, method: string): void { + source?.postMessage({ + requestId, + error: serializeError( + providerErrors.unsupportedMethod(`Uniswap Wallet could not parse the ${method} request properly`), + ), + }) +} + +export function getPendingResponseInfo( + requestIdToSourceMap: Map, + requestId: string, + type: DappResponseType, +): PendingResponseInfo | undefined { + const pendingResponseInfo = requestIdToSourceMap.get(requestId) + if (pendingResponseInfo) { + requestIdToSourceMap.delete(requestId) + + if (type !== DappResponseType.ErrorResponse && type !== pendingResponseInfo.type) { + logger.error( + `Response type doesn't match expected type, expected: ${pendingResponseInfo.type}, actual: ${type}`, + { + tags: { + file: 'injected.ts', + function: 'validateResponse', + }, + }, + ) + } + return pendingResponseInfo + } + + return undefined +} diff --git a/apps/extension/src/contentScript/types.ts b/apps/extension/src/contentScript/types.ts new file mode 100644 index 00000000000..d55bff9b096 --- /dev/null +++ b/apps/extension/src/contentScript/types.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +/* eslint-disable no-restricted-syntax */ +const ExtensionResponseSchema = z + .object({ + requestId: z.string(), + result: z.any().optional(), + error: z.any().optional(), + }) + .refine((data) => data.result !== undefined || data.error !== undefined, { + message: 'Either result or error must be defined', + }) + +export type ExtensionResponse = z.infer + +export const isValidExtensionResponse = (response: unknown): response is ExtensionResponse => + ExtensionResponseSchema.safeParse(response).success + +export const WindowEthereumRequestSchema = z.object({ + method: z.string(), + params: z.any(), + requestId: z.string(), +}) +export type WindowEthereumRequest = z.infer + +export const isValidWindowEthereumRequest = (request: unknown): request is WindowEthereumRequest => + WindowEthereumRequestSchema.safeParse(request).success + +export const ContentScriptToProxyEmissionSchema = z.object({ + emitKey: z.string(), + emitValue: z.any(), +}) + +export type ContentScriptToProxyEmission = z.infer + +export const isValidContentScriptToProxyEmission = (request: unknown): request is ContentScriptToProxyEmission => + ContentScriptToProxyEmissionSchema.safeParse(request).success diff --git a/apps/extension/src/declarations.d.ts b/apps/extension/src/declarations.d.ts new file mode 100644 index 00000000000..d2ecf6ad63d --- /dev/null +++ b/apps/extension/src/declarations.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import React from 'react' + import { SvgProps } from 'react-native-svg' + const content: React.FC + export default content +} diff --git a/apps/extension/src/env.d.ts b/apps/extension/src/env.d.ts new file mode 100644 index 00000000000..fd6eabefe81 --- /dev/null +++ b/apps/extension/src/env.d.ts @@ -0,0 +1,8 @@ +import { config } from 'ui/src/tamagui.config' + +type Conf = typeof config + +declare module 'tamagui' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface TamaguiCustomConfig extends Conf {} +} diff --git a/apps/extension/src/logo.svg b/apps/extension/src/logo.svg new file mode 100644 index 00000000000..6b60c1042f5 --- /dev/null +++ b/apps/extension/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json new file mode 100644 index 00000000000..e107f435782 --- /dev/null +++ b/apps/extension/src/manifest.json @@ -0,0 +1,75 @@ +{ + "manifest_version": 3, + "name": "Uniswap Extension", + "description": "The Uniswap Extension is a self-custody crypto wallet that's built for swapping.", + "version": "1.2.1", + "minimum_chrome_version": "116", + "icons": { + "16": "assets/icon16.png", + "32": "assets/icon32.png", + "48": "assets/icon48.png", + "128": "assets/icon128.png" + }, + "action": { + "default_icon": { + "16": "assets/icon16.png", + "32": "assets/icon32.png", + "48": "assets/icon48.png", + "128": "assets/icon128.png" + } + }, + "side_panel": { + "default_path": "sidebar.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "permissions": [ + "alarms", + "notifications", + "sidePanel", + "storage", + "tabs" + ], + "content_scripts": [ + { + "id": "injected", + "run_at": "document_start", + "matches": [ + "http://127.0.0.1/*", + "http://localhost/*", + "https://*/*" + ], + "js": [ + "injected.js" + ] + }, + { + "id": "ethereum", + "run_at": "document_start", + "matches": [ + "http://127.0.0.1/*", + "http://localhost/*", + "https://*/*" + ], + "js": [ + "ethereum.js" + ], + "world": "MAIN" + } + ], + "externally_connectable": { + "ids": [], + "matches": [] + }, + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+U", + "mac": "Command+Shift+U" + }, + "description": "Toggles the sidebar" + } + } +} diff --git a/apps/extension/src/onboarding.html b/apps/extension/src/onboarding.html new file mode 100644 index 00000000000..67622884299 --- /dev/null +++ b/apps/extension/src/onboarding.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + Uniswap Extension + + + +
+ + + + diff --git a/apps/extension/src/onboarding/onboarding.tsx b/apps/extension/src/onboarding/onboarding.tsx new file mode 100644 index 00000000000..a583e37a96b --- /dev/null +++ b/apps/extension/src/onboarding/onboarding.tsx @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import { createRoot } from 'react-dom/client' +import { OptionalStrictMode } from 'src/app/components/OptionalStrictMode' +import OnboardingApp from 'src/app/OnboardingApp' +import { initializeSentry, SentryAppNameTag } from 'src/app/sentry' +import { getLocalUserId } from 'src/app/utils/storage' +import { initializeReduxStore } from 'src/store/store' +import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization' +import { logger } from 'utilities/src/logger/logger' +;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any +// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem +// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326 + +getLocalUserId() + .then((userId) => { + initializeSentry(SentryAppNameTag.Onboarding, userId) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'SidebarApp.tsx', function: 'getLocalUserId' }, + }) + }) +async function initOnboarding(): Promise { + await initializeReduxStore() + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = document.getElementById('onboarding-root')! + const root = createRoot(container) + + root.render( + + + , + ) +} + +StoreSynchronization.init(ExtensionAppLocation.Tab).catch((error) => { + logger.error(error, { + tags: { + file: 'onboarding.ts', + function: 'initPrimaryInstanceHandler', + }, + }) +}) + +initOnboarding().catch((error) => { + logger.error(error, { + tags: { + file: 'onboarding.ts', + function: 'initOnboarding', + }, + }) +}) diff --git a/apps/extension/src/popup.html b/apps/extension/src/popup.html new file mode 100644 index 00000000000..15cc3f3f894 --- /dev/null +++ b/apps/extension/src/popup.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + Uniswap Extension + + + + + + + + diff --git a/apps/extension/src/popup/popup.tsx b/apps/extension/src/popup/popup.tsx new file mode 100644 index 00000000000..8fff4d84eca --- /dev/null +++ b/apps/extension/src/popup/popup.tsx @@ -0,0 +1,45 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import { createRoot } from 'react-dom/client' +import { OptionalStrictMode } from 'src/app/components/OptionalStrictMode' +import PopupApp from 'src/app/PopupApp' +import { initializeSentry, SentryAppNameTag } from 'src/app/sentry' +import { getLocalUserId } from 'src/app/utils/storage' +import { initializeReduxStore } from 'src/store/store' +import { logger } from 'utilities/src/logger/logger' +;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any +// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem +// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326 + +getLocalUserId() + .then((userId) => { + initializeSentry(SentryAppNameTag.Popup, userId) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'popup.tsx', function: 'getLocalUserId' }, + }) + }) +async function initPopup(): Promise { + await initializeReduxStore({ readOnly: true }) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = document.getElementById('popup-root')! + const root = createRoot(container) + + root.render( + + + , + ) +} + +initPopup().catch((error) => { + logger.error(error, { + tags: { + file: 'popup.tsx', + function: 'initPopup', + }, + }) +}) diff --git a/apps/extension/src/sidebar.html b/apps/extension/src/sidebar.html new file mode 100644 index 00000000000..d918c44aa92 --- /dev/null +++ b/apps/extension/src/sidebar.html @@ -0,0 +1,98 @@ + + + + + + + + + + + + + Uniswap Extension + + +
+ + + diff --git a/apps/extension/src/sidebar/loadSidebar.ts b/apps/extension/src/sidebar/loadSidebar.ts new file mode 100644 index 00000000000..747be9aa8a9 --- /dev/null +++ b/apps/extension/src/sidebar/loadSidebar.ts @@ -0,0 +1,18 @@ +/** + * IMPORTANT: we should keep this file very light. Do not import anything here. + * + * The browser was taking too long to interpret the react JS bundle and initialize the react app, + * so we're now splitting this up and slightly delaying the react bundle execution. + * By doing this, the first render happens faster and there's no longer a flash of a different color background (the default "no background" color). + * Instead, the HTML is now rendered immediately, with the right background color from the inline style. + * + * For video comparison of the before and after, check out https://github.com/Uniswap/universe/pull/9294 + */ + +setTimeout(() => { + const script = document.createElement('script') + script.type = 'text/javascript' + script.async = true + script.src = './sidebar.js' + document.body.appendChild(script) +}, 10) diff --git a/apps/extension/src/sidebar/sidebar.tsx b/apps/extension/src/sidebar/sidebar.tsx new file mode 100644 index 00000000000..4d254f4c36e --- /dev/null +++ b/apps/extension/src/sidebar/sidebar.tsx @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import 'src/app/utils/devtools' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { createRoot } from 'react-dom/client' +import SidebarApp from 'src/app/SidebarApp' +import { OptionalStrictMode } from 'src/app/components/OptionalStrictMode' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { initializeReduxStore } from 'src/store/store' +import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization' +import { initializeScrollWatcher } from 'uniswap/src/components/modals/ScrollLock' +import { logger } from 'utilities/src/logger/logger' +;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any +// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem +// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326 + +async function initSidebar(): Promise { + await initializeReduxStore() + await onboardingMessageChannel.sendMessage({ + type: OnboardingMessageType.SidebarOpened, + }) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = window.document.querySelector('#root')! + const root = createRoot(container) + + root.render( + + + , + ) +} + +StoreSynchronization.init(ExtensionAppLocation.SidePanel).catch((error) => { + logger.error(error, { + tags: { + file: 'sidebar.ts', + function: 'initPrimaryInstanceHandler', + }, + }) +}) + +initSidebar().catch((error) => { + logger.error(error, { + tags: { + file: 'sidebar.ts', + function: 'initSidebar', + }, + }) +}) + +initializeScrollWatcher() diff --git a/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx b/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx new file mode 100644 index 00000000000..42bd081e5b5 --- /dev/null +++ b/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx @@ -0,0 +1,24 @@ +import { useIsPrimaryAppInstance } from 'src/store/storeSynchronization' + +// This is a dev-only component that renders a small green/red dot in the bottom right corner of the screen +// to indicate whether the current app instance is the primary one. +export default function PrimaryAppInstanceDebugger(): JSX.Element | null { + const isPrimaryAppInstance = useIsPrimaryAppInstance() + + return ( +
+ ) +} diff --git a/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx b/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx new file mode 100644 index 00000000000..42ff1f81cc5 --- /dev/null +++ b/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx @@ -0,0 +1,7 @@ +import { lazy } from 'react' + +const PrimaryAppInstanceDebugger = lazy(() => import('src/store/PrimaryAppInstanceDebugger')) + +export function PrimaryAppInstanceDebuggerLazy(): JSX.Element | null { + return __DEV__ ? : null +} diff --git a/apps/extension/src/store/constants.ts b/apps/extension/src/store/constants.ts new file mode 100644 index 00000000000..68d633df8a9 --- /dev/null +++ b/apps/extension/src/store/constants.ts @@ -0,0 +1,2 @@ +export const PERSIST_KEY = 'root' +export const STATE_STORAGE_KEY = `persist:${PERSIST_KEY}` diff --git a/apps/extension/src/store/enhancePersistReducer.ts b/apps/extension/src/store/enhancePersistReducer.ts new file mode 100644 index 00000000000..acb1910c4cc --- /dev/null +++ b/apps/extension/src/store/enhancePersistReducer.ts @@ -0,0 +1,46 @@ +import { Action, Reducer } from 'redux' +import { logger } from 'utilities/src/logger/logger' + +// We use `any` in a few places in this file because those values truly can be anything, so that's the proper type. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PersistPartial = { _persist: undefined } | any + +export function enhancePersistReducer( + reducer: Reducer, +): Reducer { + return forceRehydrationFromDiskWhenResumingPersistence(reducer) +} + +/** + * Whenever the `persist/PERSIST` action is dispatched, we reset the `_persist` state in order to trigger rehydration from disk + * regardless of whether it had already rehydrated during startup. + * + * Whenever another app becomes the primary instance, `storeSynchronization.ts` calls `persistor.pause()`, + * and then when this app becomes primary again we need to not only re-start persistance but also rehydrate from disk. + * We do this by calling `persistor.persist()`, which by default will just continue persisting and skip rehydration. + * This custom enhancer ensures that the `_persist` state is reset whenever the `persist/PERSIST` action is dispatched, + * so that the internal `redux-persist` logic will rehydrate from disk again. + * + * See relevat `redux-persist` code here: https://github.com/rt2zz/redux-persist/blob/9c0baee/src/persistReducer.ts#L110 + */ +function forceRehydrationFromDiskWhenResumingPersistence( + reducer: Reducer, +): Reducer { + return (state, action) => { + if (action.type !== 'persist/PERSIST') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return reducer(state, action) + } + + logger.debug('store-synchronization', 'enhancePersistReducer', 'Resetting redux _persist state') + + const newState = { + ...state, + _persist: undefined, + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return reducer(newState, action) + } +} diff --git a/apps/extension/src/store/extensionReducer.ts b/apps/extension/src/store/extensionReducer.ts new file mode 100644 index 00000000000..0e4e98d618a --- /dev/null +++ b/apps/extension/src/store/extensionReducer.ts @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux' +import { dappRequestReducer } from 'src/app/features/dappRequests/slice' +import { alertsReducer } from 'src/app/features/onboarding/alerts/slice' +import { popupsReducer } from 'src/app/features/popups/slice' +import { monitoredSagaReducers } from 'src/app/saga' +import { RootState } from 'wallet/src/state' +import { sharedReducers } from 'wallet/src/state/reducer' + +export const extensionReducers = { + ...sharedReducers, + saga: monitoredSagaReducers, + dappRequests: dappRequestReducer, + popups: popupsReducer, + alerts: alertsReducer, +} as const + +export const extensionReducer = combineReducers(extensionReducers) + +export type ExtensionState = ReturnType & RootState +export type ReducerNames = keyof typeof extensionReducers diff --git a/apps/extension/src/store/migrations.test.ts b/apps/extension/src/store/migrations.test.ts new file mode 100644 index 00000000000..2d2beac94bd --- /dev/null +++ b/apps/extension/src/store/migrations.test.ts @@ -0,0 +1,192 @@ +import { BigNumber } from 'ethers' +import { toIncludeSameMembers } from 'jest-extended' +import { EXTENSION_STATE_VERSION, migrations } from 'src/store/migrations' +import { getSchema, initialSchema, v0Schema, v1Schema, v2Schema, v3Schema, v4Schema, v5Schema } from 'src/store/schema' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' +import { initialFavoritesState } from 'wallet/src/features/favorites/slice' +import { initialFiatCurrencyState } from 'wallet/src/features/fiatCurrency/slice' +import { initialLanguageState } from 'wallet/src/features/language/slice' +import { initialNotificationsState } from 'wallet/src/features/notifications/slice' +import { initialSearchHistoryState } from 'wallet/src/features/search/searchHistorySlice' +import { initialTokensState } from 'wallet/src/features/tokens/tokensSlice' +import { initialTransactionsState } from 'wallet/src/features/transactions/slice' +import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' +import { initialWalletState } from 'wallet/src/features/wallet/slice' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { testActivatePendingAccounts } from 'wallet/src/state/sharedMigrationsTests' +import { getAllKeysOfNestedObject } from 'wallet/src/state/testUtils' + +expect.extend({ toIncludeSameMembers }) + +describe('Redux state migrations', () => { + it('is able to perform all migrations starting from the initial schema', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, EXTENSION_STATE_VERSION) + expect(typeof migratedSchema).toBe('object') + }) + + // If this test fails then it's likely a required property was added to the Redux state but a migration was not defined + it('migrates all the properties correctly', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, EXTENSION_STATE_VERSION) + + // Add new slices here! + const initialState = { + appearanceSettings: { selectedAppearanceSettings: 'system' }, + blocks: { byChainId: {} }, + chains: { + byChainId: { + '1': { isActive: true }, + '10': { isActive: true }, + '137': { isActive: true }, + '42161': { isActive: true }, + }, + }, + dapp: {}, + ens: { ensForAddress: {} }, + favorites: initialFavoritesState, + fiatCurrencySettings: initialFiatCurrencyState, + languageSettings: initialLanguageState, + notifications: initialNotificationsState, + behaviorHistory: initialBehaviorHistoryState, + providers: { isInitialized: false }, + saga: {}, + searchHistory: initialSearchHistoryState, + tokenLists: {}, + tokens: initialTokensState, + transactions: initialTransactionsState, + wallet: initialWalletState, + _persist: { + version: EXTENSION_STATE_VERSION, + rehydrated: true, + }, + } + + const migratedSchemaKeys = new Set(getAllKeysOfNestedObject(migratedSchema as Record)) + const latestSchemaKeys = new Set(getAllKeysOfNestedObject(getSchema())) + const initialStateKeys = new Set(getAllKeysOfNestedObject(initialState)) + + for (const key of initialStateKeys) { + if (latestSchemaKeys.has(key)) { + latestSchemaKeys.delete(key) + } + if (migratedSchemaKeys.has(key)) { + migratedSchemaKeys.delete(key) + } + initialStateKeys.delete(key) + } + + expect(migratedSchemaKeys.size).toBe(0) + expect(latestSchemaKeys.size).toBe(0) + expect(initialStateKeys.size).toBe(0) + }) + + // This is a precaution to ensure we do not attempt to access undefined properties during migrations + // If this test fails, make sure all property references to state are using optional chaining + it('uses optional chaining when accessing old state variables', async () => { + const emptyStub = { _persist: { version: -1, rehydrated: false } } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(emptyStub, EXTENSION_STATE_VERSION) + expect(typeof migratedSchema).toBe('object') + }) + + it('migrates from initial schema to v0', () => { + const stub = { ...initialSchema } + const v0 = migrations[0](stub) + + expect(v0.wallet.isUnlocked).toBe(undefined) + }) + + it('migrates from v0 to v1', () => { + const v0Stub = { ...v0Schema } + const v1 = migrations[1](v0Stub) + + expect(v1.behaviorHistory.hasViewedUniconV2IntroModal).toBe(undefined) + }) + + it('migrates from v1 to v2', () => { + const TEST_ADDRESS = '0xTestAddress' + const txDetails0 = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: '0xTestAddress', + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + + const txDetails1 = { + ...txDetails0, + chainId: UniverseChainId.Optimism, + id: '1', + } + + const transactions = { + [TEST_ADDRESS]: { + [UniverseChainId.Mainnet]: { + '0': txDetails0, + }, + [UniverseChainId.Optimism]: { + '1': txDetails1, + }, + }, + } + + const v0stub = { ...v1Schema, transactions } + + const v64 = migrations[2](v0stub) + + expect(v64.transactions[TEST_ADDRESS][UniverseChainId.Mainnet]['0'].routing).toBe('CLASSIC') + expect(v64.transactions[TEST_ADDRESS][UniverseChainId.Optimism]['1'].routing).toBe('CLASSIC') + }) + + it('migrates from v2 to v3', () => { + const v3 = migrations[3] + testActivatePendingAccounts(v3, v2Schema) + }) + + it('migrates from v3 to v4', async () => { + const v3Stub = { ...v3Schema } + const v4 = await migrations[4](v3Stub) + expect(v4.dapp).toBe(undefined) + }) + + it('migrates from v4 to v5', async () => { + const v4Stub = { ...v4Schema } + const v5 = await migrations[5](v4Stub) + expect(v5.behaviorHistory.extensionBetaFeedbackState).toBe(undefined) + }) + + it('migrates from v5 to v6', async () => { + const v5Stub = { ...v5Schema } + const v6 = await migrations[6](v5Stub) + expect(v6.behaviorHistory.extensionOnboardingState).toBe(undefined) + }) +}) diff --git a/apps/extension/src/store/migrations.ts b/apps/extension/src/store/migrations.ts new file mode 100644 index 00000000000..e18574b410c --- /dev/null +++ b/apps/extension/src/store/migrations.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { + activatePendingAccounts, + addRoutingFieldToTransactions, + deleteBetaOnboardingState, + deleteExtensionOnboardingState, + removeUniconV2BehaviorState, + removeWalletIsUnlockedState, +} from 'wallet/src/state/sharedMigrations' + +export const migrations = { + 0: removeWalletIsUnlockedState, + 1: removeUniconV2BehaviorState, + 2: addRoutingFieldToTransactions, + 3: activatePendingAccounts, + 4: function removeDappInfoToChromeLocalStorage({ dapp: _dapp, ...state }: any) { + return state + }, + 5: deleteBetaOnboardingState, + 6: deleteExtensionOnboardingState, +} + +export const EXTENSION_STATE_VERSION = 6 diff --git a/apps/extension/src/store/reduxedChromeStorageToReduxPersistMigration.ts b/apps/extension/src/store/reduxedChromeStorageToReduxPersistMigration.ts new file mode 100644 index 00000000000..a247847701b --- /dev/null +++ b/apps/extension/src/store/reduxedChromeStorageToReduxPersistMigration.ts @@ -0,0 +1,29 @@ +import { ExtensionState } from 'src/store/extensionReducer' + +// TODO(EXT-1028): remove this file once the migration is no longer needed. + +const REDUXED_STORAGE_KEY = 'reduxed' + +// These functions are used to migrate the redux state persistence from `reduxed-chrome-storage` to `redux-persist`. +// The actual migration happens when the sidebar initializes the redux store. See `initializeReduxStore` in `store.ts`. + +export async function readDeprecatedReduxedChromeStorage(): Promise { + const reduxedArray = (await chrome.storage.local.get(REDUXED_STORAGE_KEY))?.[REDUXED_STORAGE_KEY] + + if (!reduxedArray) { + return undefined + } + + // The `reduxed` storage is an array: [id, timestamp, state] + const [, , state] = reduxedArray + + if (!state) { + return undefined + } + + return state as ExtensionState +} + +export async function deleteDeprecatedReduxedChromeStorage(): Promise { + await chrome.storage.local.remove(REDUXED_STORAGE_KEY) +} diff --git a/apps/extension/src/store/schema.ts b/apps/extension/src/store/schema.ts new file mode 100644 index 00000000000..dec8032f629 --- /dev/null +++ b/apps/extension/src/store/schema.ts @@ -0,0 +1,110 @@ +// only add fields that are persisted +export const initialSchema = { + dapp: {}, + favorites: { + tokens: [], + watchedAddresses: [], + tokensVisibility: {}, + nftsVisibility: {}, + }, + notifications: { + notificationQueue: [], + notificationStatus: {}, + lastTxNotificationUpdate: {}, + }, + saga: {}, + tokens: { + dismissedWarningTokens: {}, + }, + transactions: {}, + wallet: { + accounts: {}, + activeAccountAddress: null, + hardwareDevices: [], + isUnlocked: false, + settings: { + swapProtection: 'on', + hideSmallBalances: true, + hideSpamTokens: true, + }, + }, + searchHistory: { + results: [], + }, + appearanceSettings: { + selectedAppearanceSettings: 'system', + }, + languageSettings: { + currentLanguage: 'en', + }, + fiatCurrencySettings: { + currentCurrency: 'USD', + }, + behaviorHistory: { + hasViewedReviewScreen: false, + hasSubmittedHoldToSwap: false, + hasSkippedUnitagPrompt: false, + hasCompletedUnitagsIntroModal: false, + extensionOnboardingState: 0, + }, +} + +const v0SchemaIntermediate = { + ...initialSchema, + wallet: { + ...initialSchema.wallet, + isUnlocked: undefined, + }, +} + +// We will no longer keep track of this in the redux state. +delete v0SchemaIntermediate.wallet.isUnlocked + +export const v0Schema = v0SchemaIntermediate + +const v1SchemaIntermediate = { + ...v0Schema, + behaviorHistory: { + ...v0Schema.behaviorHistory, + hasViewedUniconV2IntroModal: undefined, + }, +} + +delete v1SchemaIntermediate.behaviorHistory.hasViewedUniconV2IntroModal + +export const v1Schema = v1SchemaIntermediate +export const v2Schema = { ...v1Schema } +export const v3Schema = { ...v2Schema } + +const v4SchemaIntermediate = { + ...v3Schema, + dapp: undefined, +} + +delete v4SchemaIntermediate.dapp + +export const v4Schema = v4SchemaIntermediate + +const v5SchemaIntermediate = { + ...v4Schema, + behaviorHistory: { + ...v4Schema.behaviorHistory, + extensionBetaFeedbackState: undefined, + }, +} + +delete v5SchemaIntermediate.behaviorHistory.extensionBetaFeedbackState + +export const v5Schema = v5SchemaIntermediate + +const v6SchemaIntermediate = { + ...v5Schema, + behaviorHistory: { + ...v5Schema.behaviorHistory, + extensionOnboardingState: undefined, + }, +} +delete v6SchemaIntermediate.behaviorHistory.extensionOnboardingState +export const v6Schema = v6SchemaIntermediate + +export const getSchema = (): typeof v6Schema => v6Schema diff --git a/apps/extension/src/store/store.ts b/apps/extension/src/store/store.ts new file mode 100644 index 00000000000..f351b0f3729 --- /dev/null +++ b/apps/extension/src/store/store.ts @@ -0,0 +1,101 @@ +import { createReduxEnhancer } from '@sentry/react' +import { PreloadedState } from 'redux' +import { persistReducer, persistStore } from 'redux-persist' +import { localStorage } from 'redux-persist-webextension-storage' +import { webRootSaga } from 'src/app/saga' +import { loggerMiddleware } from 'src/background/utils/loggerMiddleware' +import { PERSIST_KEY } from 'src/store/constants' +import { enhancePersistReducer } from 'src/store/enhancePersistReducer' +import { ExtensionState, ReducerNames, extensionReducer } from 'src/store/extensionReducer' +import { EXTENSION_STATE_VERSION, migrations } from 'src/store/migrations' +import { + deleteDeprecatedReduxedChromeStorage, + readDeprecatedReduxedChromeStorage, +} from 'src/store/reduxedChromeStorageToReduxPersistMigration' +import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' +import { createStore } from 'wallet/src/state' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { RootReducerNames, sharedPersistedStateWhitelist } from 'wallet/src/state/reducer' + +// Only include here things that need to be persisted and shared between different instances of the sidebar. +// Only one sidebar can write to the storage at a time, so we need to be careful about what we persist. +// Things that only belong to a single instance of the sidebar (for example, dapp requests) should not be whitelisted. +const whitelist: Array = [...sharedPersistedStateWhitelist, 'dappRequests', 'alerts'] + +const persistConfig = { + key: PERSIST_KEY, + storage: localStorage, + whitelist, + version: EXTENSION_STATE_VERSION, + migrate: createMigrate(migrations), +} + +const persistedReducer = enhancePersistReducer(persistReducer(persistConfig, extensionReducer)) + +const sentryReduxEnhancer = createReduxEnhancer({ + // TODO(EXT-1022): uncomment this once we add an analytics opt-out setting. + // stateTransformer: (state: WebState): Maybe => { + // Do not log the state if a user has opted out of analytics. + // if (state.telemetry.allowAnalytics) { + // return state + // } else { + // return null + // } + // }, +}) + +const setupStore = (preloadedState?: PreloadedState): ReturnType => { + return createStore({ + reducer: persistedReducer, + preloadedState, + additionalSagas: [webRootSaga], + middlewareBefore: __DEV__ ? [loggerMiddleware] : [], + middlewareAfter: [fiatOnRampAggregatorApi.middleware], + enhancers: [sentryReduxEnhancer], + }) +} + +let store: ReturnType | undefined +let persistor: ReturnType | undefined + +export async function initializeReduxStore(args?: { readOnly?: boolean }): Promise<{ + store: ReturnType + persistor: ReturnType +}> { + // Migrate the old `reduxed-chrome-storage` persisted state to `redux-persist`. + // TODO(EXT-985): we might need to pass the old store through `createMigrations` when we implement migrations. + const oldStore = await readDeprecatedReduxedChromeStorage() + + store = setupStore(oldStore) + persistor = persistStore(store) + + if (args?.readOnly) { + // This means the store will be initialized with the persisted state from disk, but it won't persist any changes. + // Only useful for use cases where we don't want to modify the state (for example, a popup window instead of the sidebar). + persistor.pause() + } + + // We wait a few seconds to make sure the store is fully initialized and persisted before deleting the old storage. + // This is needed because otherwise the background script might think the user is not onboarded if it reads the storage while it's being migrated. + if (oldStore) { + setTimeout(deleteDeprecatedReduxedChromeStorage, 5000) + } + + return { store, persistor } +} + +export function getReduxStore(): ReturnType { + if (!store) { + throw new Error('Invalid call to `getReduxStore` before store has been initialized') + } + return store +} + +export function getReduxPersistor(): ReturnType { + if (!persistor) { + throw new Error('Invalid call to `getReduxPersistor` before store has been initialized') + } + return persistor +} + +export type AppStore = ReturnType diff --git a/apps/extension/src/store/storeSynchronization.ts b/apps/extension/src/store/storeSynchronization.ts new file mode 100644 index 00000000000..8c114e0a459 --- /dev/null +++ b/apps/extension/src/store/storeSynchronization.ts @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react' +import { getReduxPersistor, initializeReduxStore } from 'src/store/store' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuid } from 'uuid' +import { PersistedStorage } from 'wallet/src/utils/persistedStorage' + +/** + * We want only one instance of the app to be persisting the redux store to disk at a time. + * To accomplish this, we use the concept of "primary instance", which is the instance of the app that is currently being used. + * + * An instance of the app is the primary instance when: + * - It is the only instance of the app running. + * - There are multiple instances of the app running, and this is the instance of the sidebar that lives in the window that is currently (or was last) focused. + * - When there is a sidebar and an onboarding instance running on the same window, whichever is currently focused will be the primary. + */ + +const PRIMARY_APP_INSTANCE_ID_KEY = 'primaryAppInstanceId' + +const isInitialized = false +let isPrimaryAppInstance = false +const terminate: (() => Promise) | null = null + +const STORAGE_NAMESPACE = 'session' +const sessionStorage = new PersistedStorage(STORAGE_NAMESPACE) +const currentAppInstanceId = uuid() + +// These listeners are meant for `useIsPrimaryAppInstance()` to listen for changes. +const primaryAppInstanceListeners = new Set<(isPrimary: boolean) => void>() + +export enum ExtensionAppLocation { + SidePanel, + Tab, +} + +async function initPrimaryInstanceHandler(appLocation: ExtensionAppLocation): Promise { + if (isInitialized) { + // This is just to prevent bugs being introduced in the future. + logger.error(new Error('`initPrimaryInstanceHandler` called when already initialized'), { + tags: { + file: 'storeSynchronization.ts', + function: 'initPrimaryInstanceHandler', + }, + }) + return + } + + await initializeReduxStore() + + const onStorageChangedListener: Parameters[0] = async ( + changes, + namespace, + ) => { + if (namespace === STORAGE_NAMESPACE && changes[PRIMARY_APP_INSTANCE_ID_KEY]) { + const wasPrimaryAppInstance = isPrimaryAppInstance + isPrimaryAppInstance = currentAppInstanceId === changes[PRIMARY_APP_INSTANCE_ID_KEY].newValue + + if (wasPrimaryAppInstance === isPrimaryAppInstance) { + return + } + + const persistor = getReduxPersistor() + + if (isPrimaryAppInstance) { + logger.debug('store-synchronization', 'chrome.storage.onChanged', 'Resuming redux persistor') + + persistor.persist() + } else { + logger.debug('store-synchronization', 'chrome.storage.onChanged', 'Pausing redux persistor') + await persistor.flush() + persistor.pause() + } + + primaryAppInstanceListeners.forEach((listener) => listener(isPrimaryAppInstance)) + } + } + + const onFocusChangedListener: Parameters[0] = async ( + focusedWindowId, + ) => { + const { id: currentWindowId } = await chrome.windows.getCurrent() + + if (focusedWindowId === currentWindowId) { + logger.debug('store-synchronization', 'chrome.windows.onFocusChanged', 'Window focused') + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + } + } + + const onWindowFocusListener: Parameters[1] = async () => { + // We set a slight delay to ensure that the `chrome.windows.onFocusChanged` listener runs first. + // This is to handle the case where we have a sidebar and an onboarding instance running on the same window. + setTimeout(async () => { + logger.debug('store-synchronization', 'window.onFocus', 'Window focused') + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + }, 25) + } + + chrome.storage.onChanged.addListener(onStorageChangedListener) + + if (appLocation === ExtensionAppLocation.SidePanel) { + chrome.windows.onFocusChanged.addListener(onFocusChangedListener) + } + + window.addEventListener('focus', onWindowFocusListener) + + // We always set the current app instance as the primary when it first launches. + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + + // This will be used in the onboarding flow when the user completes onboarding but the tab remains open. + // We don't want this tab to become the primary ever again when it's focused. + StoreSynchronization.terminate = async (): Promise => { + chrome.storage.onChanged.removeListener(onStorageChangedListener) + chrome.windows.onFocusChanged.removeListener(onFocusChangedListener) + window.removeEventListener('focus', onWindowFocusListener) + + const persistor = getReduxPersistor() + await persistor.flush() + persistor.pause() + + isPrimaryAppInstance = false + primaryAppInstanceListeners.forEach((listener) => listener(isPrimaryAppInstance)) + } +} + +export function useIsPrimaryAppInstance(): boolean { + const [isPrimary, setIsPrimary] = useState(isPrimaryAppInstance) + + useEffect(() => { + const listener = (_isPrimary: boolean): void => { + setIsPrimary(_isPrimary) + } + + primaryAppInstanceListeners.add(listener) + + return () => { + primaryAppInstanceListeners.delete(listener) + } + }, []) + + return isPrimary +} + +export function terminateStoreSynchronization(): void { + StoreSynchronization.terminate?.().catch((error) => { + logger.error(error, { + tags: { file: 'storeSynchronization.ts', function: 'useTerminateStoreSynchronization' }, + }) + }) +} + +export const StoreSynchronization: { + init: typeof initPrimaryInstanceHandler + terminate: (() => Promise) | null +} = { + init: initPrimaryInstanceHandler, + terminate, +} diff --git a/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts b/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts new file mode 100644 index 00000000000..66e67ac38c1 --- /dev/null +++ b/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts @@ -0,0 +1,13 @@ +import React, { PropsWithChildren, ReactNode } from 'react' +import { View, ViewProps } from 'react-native' + +// react-native-masked-view for Storybook web +// https://github.com/react-native-masked-view/masked-view/issues/70#issuecomment-1171801526 +function MaskedViewWeb({ + maskElement, + ...props +}: PropsWithChildren<{ maskElement: ReactNode }>): React.CElement { + return React.createElement(View, props, maskElement) +} + +export default MaskedViewWeb diff --git a/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts b/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts new file mode 100644 index 00000000000..766d3d19967 --- /dev/null +++ b/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts @@ -0,0 +1,19 @@ +import React, { PropsWithChildren } from 'react' +import { View, ViewProps } from 'react-native' + +// Source: https://github.com/Shopify/react-native-skia/issues/548#issuecomment-1157609472 + +const PlainView = ({ children, ...props }: PropsWithChildren): React.CElement => { + return React.createElement(View, props, children) +} +const noop = (): null => null + +export const BlurMask = PlainView +export const Canvas = PlainView +export const Circle = PlainView +export const Group = PlainView +export const LinearGradient = PlainView +export const Mask = PlainView +export const Path = PlainView +export const Rect = PlainView +export const vec = noop diff --git a/apps/extension/src/test/babel.config.js b/apps/extension/src/test/babel.config.js new file mode 100644 index 00000000000..7d99c5aa06b --- /dev/null +++ b/apps/extension/src/test/babel.config.js @@ -0,0 +1,25 @@ +// This file is used only by jest in the test environment. To check the extension +// build set up, see the webpack.config.js file. + +module.exports = function (api) { + api.cache.using(() => process.env.NODE_ENV) + var plugins = [ + "react-native-web", + [ + 'module:react-native-dotenv', + { + moduleName: 'react-native-dotenv', + path: '../../.env.defaults', + safe: true, + allowUndefined: false, + }, + ], + // https://github.com/software-mansion/react-native-reanimated/issues/3364#issuecomment-1268591867 + '@babel/plugin-proposal-export-namespace-from', + ].filter(Boolean) + + return { + presets: ['module:@react-native/babel-preset'], + plugins, + } +} diff --git a/apps/extension/src/test/fixtures/redux.ts b/apps/extension/src/test/fixtures/redux.ts new file mode 100644 index 00000000000..6c8ad51cfea --- /dev/null +++ b/apps/extension/src/test/fixtures/redux.ts @@ -0,0 +1,13 @@ +import { PreloadedState } from 'redux' +import { ExtensionState } from 'src/store/extensionReducer' +import { createFixture } from 'uniswap/src/test/utils' +import { SharedState } from 'wallet/src/state/reducer' +import { preloadedSharedState } from 'wallet/src/test/fixtures' + +type PreloadedExtensionStateOptions = Record + +export const preloadedExtensionState = createFixture, PreloadedExtensionStateOptions>( + {}, +)(() => ({ + ...(preloadedSharedState() as PreloadedState), +})) diff --git a/apps/extension/src/test/jest-resolver.js b/apps/extension/src/test/jest-resolver.js new file mode 100644 index 00000000000..c7a1c69072d --- /dev/null +++ b/apps/extension/src/test/jest-resolver.js @@ -0,0 +1,33 @@ +const fs = require('fs') +const path = require('path') + +const platformExtensions = ['native', 'ios', 'android'] +const targetExtensions = ['web', ''] + +module.exports = (request, options) => { + const { defaultResolver } = options + const resolvedPath = defaultResolver(request, options) + + const parsedPath = path.parse(resolvedPath) + const isPlatformSpecific = platformExtensions.some((ext) => parsedPath.name.endsWith(`.${ext}`)) + + if (isPlatformSpecific) { + const index = parsedPath.name.lastIndexOf('.') + const strippedName = parsedPath.name.slice(0, index) + + for (const targetExt of targetExtensions) { + const candidatePath = path.format({ + dir: parsedPath.dir, + name: targetExt ? `${strippedName}.${targetExt}` : strippedName, + ext: parsedPath.ext, + }) + + if (fs.existsSync(candidatePath)) { + return candidatePath + } + } + } + + // Return default resolved path if no replacement is found + return resolvedPath +} diff --git a/apps/extension/src/test/render.tsx b/apps/extension/src/test/render.tsx new file mode 100644 index 00000000000..3cf8d43db20 --- /dev/null +++ b/apps/extension/src/test/render.tsx @@ -0,0 +1,132 @@ +import type { EnhancedStore, PreloadedState } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' +import { + render as ReactRender, + renderHook as ReactRenderHook, + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from '@testing-library/react' +import React, { PropsWithChildren } from 'react' +import { ExtensionState, extensionReducer } from 'src/store/extensionReducer' +import { AppStore } from 'src/store/store' +import { Resolvers } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' +import { SharedProvider } from 'wallet/src/provider' +import { AutoMockedApolloProvider } from 'wallet/src/test/mocks' + +// This type extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +type ExtendedRenderOptions = RenderOptions & { + resolvers?: Resolvers + preloadedState?: PreloadedState + store?: AppStore +} + +/** + * + * @param ui Component to render + * @param resolvers Custom resolvers that override the default ones + * @param preloadedState and store + * @returns `ui` wrapped with providers + */ +export function renderWithProviders( + ui: React.ReactElement, + { + resolvers, + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ + reducer: extensionReducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), + }), + ...renderOptions + }: ExtendedRenderOptions = {}, +): RenderResult & { + store: EnhancedStore +} { + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + + {children} + + + ) + } + + // Return an object with the store and all of RTL's query functions + return { store, ...ReactRender(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +// This type extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +type ExtendedRenderHookOptions

= RenderHookOptions

& { + resolvers?: Resolvers + preloadedState?: PreloadedState + store?: AppStore +} + +type RenderHookWithProvidersResult = Omit, 'rerender'> & { + store: EnhancedStore + rerender: (args?: P) => void +} + +// Don't require hookOptions if hook doesn't take any arguments +export function renderHookWithProviders( + hook: () => R, + hookOptions?: ExtendedRenderHookOptions, +): RenderHookWithProvidersResult + +// Require hookOptions if hook takes arguments +export function renderHookWithProviders( + hook: (args: P) => R, + hookOptions: ExtendedRenderHookOptions

, +): RenderHookWithProvidersResult + +/** + * + * @param hook Hook to render + * @param resolvers Custom resolvers that override the default ones + * @param preloadedState and store + * @returns `hook` wrapped with providers + */ +export function renderHookWithProviders( + hook: (args: P) => R, + hookOptions?: ExtendedRenderHookOptions

, +): RenderHookWithProvidersResult { + const { + resolvers, + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ + reducer: extensionReducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), + }), + ...renderOptions + } = (hookOptions ?? {}) as ExtendedRenderHookOptions

+ + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) + } + + const options: RenderHookOptions

= { + wrapper: Wrapper, + ...(renderOptions as RenderHookOptions

), + } + + const { ...rest } = ReactRenderHook((args: P) => hook(args), options) + + // Return an object with the store and all of RTL's query functions + return { + store, + ...rest, + } +} diff --git a/apps/extension/src/test/test-utils.ts b/apps/extension/src/test/test-utils.ts new file mode 100644 index 00000000000..2abe0a1491a --- /dev/null +++ b/apps/extension/src/test/test-utils.ts @@ -0,0 +1,6 @@ +import { renderHookWithProviders, renderWithProviders } from 'src/test/render' + +// re-export everything +export * from '@testing-library/react' +// override render method +export { renderWithProviders as render, renderHookWithProviders as renderHook } diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 00000000000..c87e5f89d74 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Web App", + "extends": "tsconfig/nextjs.json", + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.json", + "../../declarations.d.ts", + ], + "exclude": [ + "node_modules" + ], + "references": [ + { + "path": "../../packages/ui" + }, + { + "path": "../../packages/utilities" + }, + { + "path": "../../packages/wallet" + } + ], + "compilerOptions": { + "baseUrl": "./", + "types": [ + "chrome", + "jest" + ] + } +} diff --git a/apps/extension/webpack.config.js b/apps/extension/webpack.config.js new file mode 100644 index 00000000000..c94eea912e1 --- /dev/null +++ b/apps/extension/webpack.config.js @@ -0,0 +1,362 @@ +const { CleanWebpackPlugin } = require('clean-webpack-plugin') +const { ProgressPlugin, ProvidePlugin, DefinePlugin } = require('webpack') +const CopyWebpackPlugin = require('copy-webpack-plugin') +const MiniCssExtractPlugin = require('mini-css-extract-plugin') +const path = require('path') +const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') +const fs = require('fs') +const DotenvPlugin = require('dotenv-webpack') +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') +const { sentryWebpackPlugin } = require('@sentry/webpack-plugin') + +const NODE_ENV = process.env.NODE_ENV || 'development' + +// if not set tamagui wont add nice data-at, data-in etc debug attributes +process.env.NODE_ENV = NODE_ENV + +const isDevelopment = NODE_ENV === 'development' +const appDirectory = path.resolve(__dirname) +const manifest = require('./src/manifest.json') + +// Add all node modules that have to be compiled +const compileNodeModules = [ + // These libraries export JSX code from files with .js extension, which aren't transpiled + // in the library to code that doesn't use JSX syntax. This file extension is not automatically + // recognized as extension for files containing JSX, so we have to manually add them to + // the build proess (to the appropriate loader) and don't exclude them with other node_modules + 'expo-clipboard', + 'expo-linear-gradient', +] + +// This is needed for webpack to compile JavaScript. +// Many OSS React Native packages are not compiled to ES5 before being +// published. If you depend on uncompiled packages they may cause webpack build +// errors. To fix this webpack can be configured to compile to the necessary +// `node_module`. +const babelLoaderConfiguration = { + test: /\.js$/, + // Add every directory that needs to be compiled by Babel during the build. + include: [ + // path.resolve(appDirectory, "index.web.js"), + // path.resolve(appDirectory, "src"), + path.resolve(appDirectory, 'node_modules/react-native-uncompiled'), + ], + use: { + loader: 'babel-loader', + options: { + cacheDirectory: true, + // The 'metro-react-native-babel-preset' preset is recommended to match React Native's packager + presets: ['module:@react-native/babel-preset'], + // Re-write paths to import only the modules needed by the app + plugins: ['react-native-web'], + }, + }, +} + +const swcLoader = { + loader: 'swc-loader', + options: { + // parseMap: true, // required when using with babel-loader + env: { + targets: require('./package.json').browserslist, + }, + sourceMap: isDevelopment, + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + dynamicImport: true, + }, + transform: { + react: { + development: isDevelopment, + refresh: isDevelopment, + }, + }, + }, + }, +} + +const swcLoaderConfiguration = { + test: ['.jsx', '.js', '.tsx', '.ts'].map((ext) => new RegExp(`${ext}$`)), + exclude: new RegExp(`node_modules/(?!(${compileNodeModules.join('|')})/)`), + use: swcLoader, +} + +const fileExtensions = ['eot', 'gif', 'jpeg', 'jpg', 'otf', 'png', 'ttf', 'woff', 'woff2', 'mp4'] + +const { + dir, + plugins = [], + ...extras +} = isDevelopment + ? { + dir: 'dev', + devServer: { + // watchFiles: ['src/**/*', 'webpack.config.js'], + host: '127.0.0.1', + port: 9997, + server: fs.existsSync('localhost.pem') + ? { + type: 'https', + options: { + key: 'localhost-key.pem', + cert: 'localhost.pem', + }, + } + : {}, + compress: false, + static: { + directory: path.join(__dirname, '../dev'), + }, + client: { + // logging: "info", + progress: true, + reconnect: false, + overlay: { + errors: true, + warnings: false, + // disable resize observer error + // NOTE: ideally would use the function format (error) => boolean + // however, I was not able to get past CSP with that solution + runtimeErrors: false, + }, + }, + devMiddleware: { + writeToDisk: true, + }, + }, + devtool: 'cheap-module-source-map', + plugins: [new ReactRefreshWebpackPlugin()], + } + : { + dir: 'build', + plugins: [], + } + +module.exports = (env) => { + // Build env is either 'dev', 'beta', or 'prod' + if (!isDevelopment && env.BUILD_ENV !== 'prod' && env.BUILD_ENV !== 'beta' && env.BUILD_ENV !== 'dev') { + throw new Error('Must set BUILD_ENV env variable to either prod, beta or dev') + } + + // Build num is the fourth number in the extension version (...). It will come from GH actions when building this to publish + if (!isDevelopment && (env.BUILD_NUM === undefined || env.BUILD_NUM < 0)) { + throw new Error('Must set BUILD_NUM env variable to a number >= 0') + } + + const BUILD_ENV = env.BUILD_ENV + const BUILD_NUM = env.BUILD_NUM || 0 + + // Title Postfix + const EXTENSION_NAME_POSTFIX = BUILD_ENV === 'dev' ? 'DEV' : BUILD_ENV === 'beta' ? 'BETA' : '' + + // Description + let EXTENSION_DESCRIPTION = manifest.description + if (BUILD_ENV === 'beta') { + EXTENSION_DESCRIPTION = 'THIS EXTENSION IS FOR BETA TESTING' + } + if (BUILD_ENV === 'dev') { + EXTENSION_DESCRIPTION = 'THIS EXTENSION IS FOR DEV TESTING' + } + + // Version + const EXTENSION_VERSION = manifest.version + '.' + BUILD_NUM + + return { + mode: NODE_ENV, + entry: { + background: './src/background/background.ts', + onboarding: './src/onboarding/onboarding.tsx', + loadSidebar: './src/sidebar/loadSidebar.ts', + sidebar: './src/sidebar/sidebar.tsx', + injected: './src/contentScript/injected.ts', + ethereum: './src/contentScript/ethereum.ts', + popup: './src/popup/popup.tsx', + }, + output: { + filename: '[name].js', + chunkFilename: '[name].js', + path: path.resolve(__dirname, dir), + clean: true, + publicPath: '', + }, + // https://webpack.js.org/configuration/other-options/#level + infrastructureLogging: { level: 'warn' }, + module: { + rules: [ + // Use this rule together with other rules specified for the same pattern + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, // disable the behaviour + }, + }, + { + oneOf: [ + { + test: /\.(woff|woff2)$/, + use: { loader: 'file-loader' }, + }, + + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + ], + }, + + { + type: 'javascript/auto', + test: /\.json$/, + use: ['file-loader'], + include: /tokenlist/, + }, + + // Used for creating SVG React components (similar to react=native-svg-transformer on mobile) + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + + { + test: new RegExp('.(' + fileExtensions.join('|') + ')$'), + type: 'asset/resource', + }, + + { + test: /.tsx?$/, + exclude: (file) => file.includes('node_modules'), + use: [ + // one after to remove the jsx + swcLoader, + + // tamagui optimizes the jsx + { + loader: 'tamagui-loader', + options: { + config: '../../packages/ui/src/tamagui.config.ts', + components: ['ui'], + // add files here that should be parsed by the compiler from within any of the apps/* + // for example if you have constants.ts then constants.js goes here and it will eval them + // at build time and if it can flatten views even if they use imports from that file + importsWhitelist: ['constants.js'], + disableExtraction: process.env.NODE_ENV === 'development', + }, + }, + + // one before to remove types + { + loader: 'esbuild-loader', + options: { + target: 'es2022', + jsx: 'preserve', + minify: false, + }, + }, + ], + }, + + babelLoaderConfiguration, + swcLoaderConfiguration, + ], + }, + ], + }, + resolve: { + alias: { + 'react-native$': 'react-native-web', + 'react-native-reanimated$': require.resolve('react-native-reanimated'), + 'react-native-vector-icons$': 'react-native-vector-icons/dist', + src: path.resolve(__dirname, 'src'), // absolute imports in apps/web + 'react-native-gesture-handler$': require.resolve('react-native-gesture-handler'), + }, + // Add support for web-based extensions so we can share code between mobile/extension + extensions: [ + '.web.js', + '.web.jsx', + '.web.ts', + '.web.tsx', + ...fileExtensions.map((e) => `.${e}`), + ...['.js', '.jsx', '.ts', '.tsx', '.css'], + ], + fallback: { + fs: false, + }, + }, + devtool: 'source-map', + plugins: [ + new DotenvPlugin({ + path: '../../.env', + defaults: true, + }), + new DefinePlugin({ + __DEV__: NODE_ENV === 'development' ? 'true' : 'false', + 'process.env.IS_STATIC': '""', + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'process.env.DEBUG': JSON.stringify(process.env.DEBUG || '0'), + 'process.env.VERSION': JSON.stringify(EXTENSION_VERSION), + 'process.env.IS_UNISWAP_EXTENSION': '"true"', + }), + new CleanWebpackPlugin(), + new NodePolyfillPlugin(), // necessary to compile with reactnative-dotenv + ...plugins, + new MiniCssExtractPlugin(), + new ProgressPlugin(), + new ProvidePlugin({ + process: 'process/browser', + React: 'react', + Buffer: ['buffer', 'Buffer'], + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: 'src/manifest.json', + force: true, + transform(content) { + return Buffer.from( + JSON.stringify( + { + ...manifest, + description: EXTENSION_DESCRIPTION, + version: EXTENSION_VERSION, + name: EXTENSION_NAME_POSTFIX ? manifest.name + ' ' + EXTENSION_NAME_POSTFIX : manifest.name, + }, + null, + 2, + ), + ) + }, + }, + { + from: 'src/assets/fonts/*.{woff,woff2,ttf}', + to: 'assets/fonts/[name][ext]', + force: true, + }, + { + from: 'src/assets/*.{html,png,svg}', + to: 'assets/[name][ext]', + force: true, + }, + { + from: 'src/*.{html,png,svg}', + to: '[name][ext]', + force: true, + }, + ], + }), + sentryWebpackPlugin({ + authToken: env.SENTRY_AUTH_TOKEN, + org: 'uniswap-labs', + project: 'extension-wallet', + telemetry: process.env.NODE_ENV === 'production', + }), + ], + ...extras, + } +} diff --git a/apps/mobile/.depcheckrc b/apps/mobile/.depcheckrc index ff35441d39b..ee024eae1b6 100644 --- a/apps/mobile/.depcheckrc +++ b/apps/mobile/.depcheckrc @@ -1,31 +1,32 @@ ignores: [ # Dependencies that depcheck thinks are unused but are actually used - "@uniswap/ethers-rs-mobile", - "babel-loader", - "babel-jest", - "babel-plugin-react-native-web", - "babel-plugin-transform-remove-console", - "cross-fetch", - "expo-localization", - "expo-linking", - "madge", - "postinstall-postinstall", + '@uniswap/ethers-rs-mobile', + 'babel-loader', + 'babel-jest', + 'babel-plugin-react-native-web', + 'babel-plugin-transform-remove-console', + 'cross-fetch', + '@datadog/datadog-ci', + 'expo-localization', + 'expo-linking', + 'madge', + 'postinstall-postinstall', ## React Native Usage - "@amplitude/analytics-react-native", - "@react-native-masked-view/masked-view", - "@react-native-firebase/app-check", - "@shopify/react-native-skia", - "react-native-image-colors", - "react-native-restart", + '@amplitude/analytics-react-native', + '@react-native-masked-view/masked-view', + '@react-native-firebase/app-check', + '@shopify/react-native-skia', + 'react-native-image-colors', + 'react-native-restart', # Dependencies that depcheck thinks are missing but are actually present or never used ## Internal packages / workspaces - "e2e", - "src", - "ui", - "tsconfig", + 'e2e', + 'src', + 'ui', + 'tsconfig', ## Subpackages of installed packages - "@redux-saga/core", - "@ethersproject/constants", - "@react-navigation/elements", - "metro-config", + '@redux-saga/core', + '@ethersproject/constants', + '@react-navigation/elements', + 'metro-config', ] diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index c3e74e19db2..9ac70f14215 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -84,6 +84,12 @@ if (isCI && sentryPropertiesAvailable && !isDetox) { } } +boolean datadogPropertiesAvailable = System.getenv('DATADOG_API_KEY') != null + +if (isCI && datadogPropertiesAvailable && !isDetox) { + apply from: "../../../../node_modules/@datadog/mobile-react-native/datadog-sourcemaps.gradle" +} + android { ndkVersion rootProject.ext.ndkVersion @@ -131,17 +137,17 @@ android { dev { isDefault(true) applicationIdSuffix ".dev" - versionName "1.31.1" + versionName "1.32" dimension "variant" } beta { applicationIdSuffix ".beta" - versionName "1.31.1" + versionName "1.32" dimension "variant" } prod { dimension "variant" - versionName "1.31.1" + versionName "1.32" } } diff --git a/apps/mobile/e2e/TokenDetails.e2e.ts b/apps/mobile/e2e/TokenDetails.e2e.ts new file mode 100644 index 00000000000..6ffac7d6952 --- /dev/null +++ b/apps/mobile/e2e/TokenDetails.e2e.ts @@ -0,0 +1,11 @@ +import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet' +import { TokenDetailsBasicInteractions } from 'e2e/usecases/tokenDetails/TokenDetailsBasicInteractions' + +describe('TokenDetails', () => { + beforeAll(async () => { + await device.launchApp({ newInstance: true }) + await WatchWallet() + }) + + it('tests token details screen interactions', TokenDetailsBasicInteractions) +}) diff --git a/apps/mobile/e2e/usecases/tokenDetails/TokenDetailsBasicInteractions.ts b/apps/mobile/e2e/usecases/tokenDetails/TokenDetailsBasicInteractions.ts new file mode 100644 index 00000000000..9160fdddcb5 --- /dev/null +++ b/apps/mobile/e2e/usecases/tokenDetails/TokenDetailsBasicInteractions.ts @@ -0,0 +1,92 @@ +import { by, element, expect } from 'detox' +import { TestWatchedWallet } from 'e2e/utils/fixtures' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { UniverseChainId } from 'uniswap/src/types/chains' + +export async function TokenDetailsBasicInteractions(): Promise { + // Opens "explore" modal + await element(by.id(TestID.SearchTokensAndWallets)).tap() + + // Types "Uniswap" into "explore" screen search bar + await element(by.id(TestID.ExploreSearchInput)).typeText('Uniswap') + + // Opnes "Uniswap" Mainnet token details screen + await element(by.id(`${TestID.SearchTokenItem}-Uniswap-${UniverseChainId.Mainnet}`)).tap() + + // checks if ethereum title is displayed + await expect(element(by.id(TestID.TokenDetailsHeaderText))).toHaveText('Uniswap') + + // checks if portfolio balance is visible + await expect(element(by.id(TestID.PriceExplorerAnimatedNumber))).toBeVisible() + + // checks if relative price indicator is visible + await expect(element(by.id(TestID.RelativePriceChange))).toBeVisible() + + // opens header "more" button dropdown menu + await expect(element(by.id(TestID.TokenDetailsMoreButton))).toBeVisible() + + // checks if send button is not available + await expect(element(by.id(TestID.Send))).not.toBeVisible() + + // checks if price exploerer chart is rendered + await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible() + + // checks if all time ranges renders properly + await element(by.id('token-details-chart-time-range-button-1H')).tap() + await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible() + + await element(by.id('token-details-chart-time-range-button-1W')).tap() + await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible() + + await element(by.id('token-details-chart-time-range-button-1M')).tap() + await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible() + + await element(by.id('token-details-chart-time-range-button-1Y')).tap() + await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible() + + await element(by.id('token-details-chart-time-range-button-1D')).tap() + await expect(element(by.id(TestID.PriceExplorerChart))).toBeVisible() + + // checks if sell and buy buttons are visible + await expect(element(by.id(TestID.TokenDetailsBuyButton))).toBeVisible() + await expect(element(by.id(TestID.TokenDetailsSellButton))).not.toBeVisible() + + // scrolls to the bottom of the token details screen + await element(by.id(TestID.PriceExplorerChart)).swipe('up') + + // cheks if token detels share links are available + await expect(element(by.id(TestID.TokenLinkEtherscan))).toBeVisible() + await expect(element(by.id(TestID.TokenLinkWebsite))).toBeVisible() + await expect(element(by.id(TestID.TokenLinkTwitter))).toBeVisible() + + // taps on buy button + await element(by.id(TestID.TokenDetailsBuyButton)).tap() + + // checks if it is displayed as expected + await expect(element(by.id(`${TestID.ChooseInputToken}-label`))).toHaveText('ETH') + await expect(element(by.id(`${TestID.ChooseOutputToken}-label`))).toHaveText('UNI') + await expect(element(by.id(TestID.ChooseInputToken))).toBeVisible() + await expect(element(by.id(TestID.AmountInputOut))).toBeFocused() + await expect(element(by.id(TestID.AmountInputOut))).toHaveText('') + + // closes swap modal + await element(by.id(TestID.SwapFormHeader)).swipe('down') + + // tests descreption read more button + await expect(element(by.id(TestID.ReadMoreButton))).toHaveText('Read more') + await element(by.id(TestID.ReadMoreButton)).tap() + + await element(by.id(TestID.TokenDetailsAboutHeader)).swipe('up') + + // tests descreption read less button + await expect(element(by.id(TestID.ReadMoreButton))).toHaveText('Read less') + await element(by.id(TestID.ReadMoreButton)).tap() + + // navigates back to home screen + await element(by.id(TestID.Back)).tap() + + // checks if home screen is rendered + await expect(element(by.text(TestWatchedWallet.displayName))).toBeVisible() + await expect(element(by.id(TestID.Swap))).toBeVisible() + await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible() +} diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 73032cf8af8..eada28f99d3 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -644,6 +644,29 @@ PODS: - BoringSSL-GRPC/Implementation (0.0.24): - BoringSSL-GRPC/Interface (= 0.0.24) - BoringSSL-GRPC/Interface (0.0.24) + - DatadogCore (2.13.0): + - DatadogInternal (= 2.13.0) + - DatadogCrashReporting (2.13.0): + - DatadogInternal (= 2.13.0) + - PLCrashReporter (~> 1.11.2) + - DatadogInternal (2.13.0) + - DatadogLogs (2.13.0): + - DatadogInternal (= 2.13.0) + - DatadogRUM (2.13.0): + - DatadogInternal (= 2.13.0) + - DatadogSDKReactNative (2.4.1): + - DatadogCore (~> 2.13.0) + - DatadogCrashReporting (~> 2.13.0) + - DatadogLogs (~> 2.13.0) + - DatadogRUM (~> 2.13.0) + - DatadogTrace (~> 2.13.0) + - DatadogWebViewTracking (~> 2.13.0) + - React-Core + - DatadogTrace (2.13.0): + - DatadogInternal (= 2.13.0) + - OpenTelemetrySwiftApi (= 1.6.0) + - DatadogWebViewTracking (2.13.0): + - DatadogInternal (= 2.13.0) - DoubleConversion (1.1.6) - EthersRS (0.0.5) - EXBarCodeScanner (12.9.3): @@ -873,10 +896,12 @@ PODS: - OneSignalXCFramework/OneSignalOutcomes - OneSignalXCFramework/OneSignalOutcomes (3.12.6): - OneSignalXCFramework/OneSignalCore + - OpenTelemetrySwiftApi (1.6.0) - Permission-FaceID (3.6.0): - RNPermissions - Permission-Notifications (3.6.0): - RNPermissions + - PLCrashReporter (1.11.2) - PromisesObjC (2.4.0) - RCT-Folly (2022.05.16.00): - boost @@ -2037,6 +2062,7 @@ DEPENDENCIES: - Apollo (= 1.2.1) - Argon2Swift (= 1.0.3) - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) + - "DatadogSDKReactNative (from `../../../node_modules/@datadog/mobile-react-native`)" - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - "EthersRS (from `../../../node_modules/@uniswap/ethers-rs-mobile`)" - EXBarCodeScanner (from `../../../node_modules/expo-barcode-scanner/ios`) @@ -2153,6 +2179,13 @@ SPEC REPOS: - AppsFlyerFramework - Argon2Swift - BoringSSL-GRPC + - DatadogCore + - DatadogCrashReporting + - DatadogInternal + - DatadogLogs + - DatadogRUM + - DatadogTrace + - DatadogWebViewTracking - Firebase - FirebaseAppCheck - FirebaseAppCheckInterop @@ -2172,6 +2205,8 @@ SPEC REPOS: - MMKVCore - nanopb - OneSignalXCFramework + - OpenTelemetrySwiftApi + - PLCrashReporter - PromisesObjC - RecaptchaInterop - RiveRuntime @@ -2188,6 +2223,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@amplitude/analytics-react-native" boost: :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec" + DatadogSDKReactNative: + :path: "../../../node_modules/@datadog/mobile-react-native" DoubleConversion: :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" EthersRS: @@ -2400,6 +2437,14 @@ SPEC CHECKSUMS: Argon2Swift: 99482c1b8122a03524b61e41c4903a9548e7c33b boost: d3f49c53809116a5d38da093a8aa78bf551aed09 BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33 + DatadogCore: 9390fd07a89f57a23983de66fbec5bf3c2034f7a + DatadogCrashReporting: 79b67b790df186524fc76d45c0b8ce751c36ef41 + DatadogInternal: 61ab12516d2faad79e35973534c29a72b0d44382 + DatadogLogs: 912d7b3fd3d75df856de060082b785f92f7cefe6 + DatadogRUM: b5629479d4553d80f2a57ef9db44ce37e56f8b97 + DatadogSDKReactNative: 0101462ddda14f13470dfe05cca244c190bb1fac + DatadogTrace: 1f40893de00c9a9b87be46fa7016fa0d50c4e66b + DatadogWebViewTracking: d1e2e755bb2ed7c18208471b9cbcfc7cb920aa45 DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 EthersRS: 56b70e73d22d4e894b7e762eef1129159bcd3135 EXBarCodeScanner: d59fd943cebee3f913ebf4ffde0d05d344da8b78 @@ -2442,8 +2487,10 @@ SPEC CHECKSUMS: MMKVCore: a67a1cede26175c413176f404a7cedec43f96a0b nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 OneSignalXCFramework: ff1c970b7aeb4ac0fe48fb35393eb5d8bf378135 + OpenTelemetrySwiftApi: 657da8071c2908caecce11548e006f779924ff9c Permission-FaceID: aaf43b353c25aaa2c1a501f93fa33dcb0f76e6c8 Permission-Notifications: 7f2a467ab97a130a847519705340830d52cfc5f0 + PLCrashReporter: 499c53b0104f95c302d94fd723ebb03c56d9bac8 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 RCT-Folly: 7169b2b1c44399c76a47b5deaaba715eeeb476c0 RCTRequired: ca1d7414aba0b27efcfa2ccd37637edb1ab77d96 diff --git a/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj b/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj index 73dee9e72f6..51c789a00bd 100644 --- a/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Uniswap.xcodeproj/project.pbxproj @@ -515,6 +515,7 @@ A7B8EFCA2BF68F0D00CA4A1C /* FeeData.graphql.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FeeData.graphql.swift; path = MobileSchema/Schema/Objects/FeeData.graphql.swift; sourceTree = ""; }; A7C9F415D0E128A43003E071 /* Pods-Uniswap.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Uniswap.debug.xcconfig"; path = "Target Support Files/Pods-Uniswap/Pods-Uniswap.debug.xcconfig"; sourceTree = ""; }; AC0EE0972BD826E700BCCF07 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Uniswap/PrivacyInfo.xcprivacy; sourceTree = ""; }; + AC2794442C51541E00F9AF68 /* sourcemaps-datadog.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "sourcemaps-datadog.sh"; sourceTree = ""; }; AEE498F52A85AD86000DDF8E /* Basel-Book.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Basel-Book.ttf"; path = "../src/assets/fonts/Basel-Book.ttf"; sourceTree = ""; }; AEE498F62A85AD86000DDF8E /* Basel-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Basel-Medium.ttf"; path = "../src/assets/fonts/Basel-Medium.ttf"; sourceTree = ""; }; B0DA4D39B1A6D74A1D05B99F /* Pods-WidgetsCore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetsCore.debug.xcconfig"; path = "Target Support Files/Pods-WidgetsCore/Pods-WidgetsCore.debug.xcconfig"; sourceTree = ""; }; @@ -1041,6 +1042,7 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + AC2794442C51541E00F9AF68 /* sourcemaps-datadog.sh */, 074321872A82BA2700F8518D /* Fonts */, FD54D51C296C79A4007A37E9 /* GoogleServiceInfo */, 13B07FAE1A68108700A75B9A /* Uniswap */, @@ -1319,6 +1321,7 @@ 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + AC2794462C51756600F9AF68 /* Bundle and upload JS source maps for Datadog */, 0312A12D2A77D07B008CAAFD /* Upload Debug Symbols to Sentry */, F35AFD4627EE49990011A725 /* Embed App Extensions */, 9F7898182A819D62004D5A98 /* Embed Frameworks */, @@ -1757,6 +1760,24 @@ shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n"; }; + AC2794462C51756600F9AF68 /* Bundle and upload JS source maps for Datadog */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Bundle and upload JS source maps for Datadog"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\nexport SOURCEMAP_FILE=$DERIVED_FILE_DIR/main.jsbundle.map\n\nWITH_ENVIRONMENT=\"../../../node_modules/react-native/scripts/xcode/with-environment.sh\"\nDATADOG_XCODE=\"./sourcemaps-datadog.sh\"\n\nif [[ -n \"$DATADOG_API_KEY\" ]]; then\n # JS source maps\n /bin/sh -c \"$WITH_ENVIRONMENT $DATADOG_XCODE\"\n # iOS dSYM\n ../../../node_modules/.bin/datadog-ci dsyms upload $DWARF_DSYM_FOLDER_PATH\nelse\n echo \"Ignoring upload step for local, API key is missing.\"\nfi\n"; + }; F5C7F44CBF58F052A43EB4AA /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2534,7 +2555,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -2580,7 +2601,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.widgets; @@ -2626,7 +2647,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.widgets; @@ -2672,7 +2693,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.widgets; @@ -2714,7 +2735,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -2757,7 +2778,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.WidgetIntentExtension; @@ -2800,7 +2821,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.WidgetIntentExtension; @@ -2843,7 +2864,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.WidgetIntentExtension; @@ -2879,7 +2900,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2917,7 +2938,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -2997,11 +3018,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - "-Wl", - "-ld_classic", - ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -3064,11 +3081,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - "-Wl", - "-ld_classic", - ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -3103,7 +3116,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3147,7 +3160,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.OneSignalNotificationServiceExtension; @@ -3218,11 +3231,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - "-Wl", - "-ld_classic", - ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -3251,7 +3260,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -3322,7 +3331,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.beta.OneSignalNotificationServiceExtension; @@ -3393,11 +3402,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - "-Wl", - "-ld_classic", - ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; @@ -3426,7 +3431,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -3497,7 +3502,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.31.1; + MARKETING_VERSION = 1.32; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.uniswap.mobile.dev.OneSignalNotificationServiceExtension; diff --git a/apps/mobile/ios/sourcemaps-datadog.sh b/apps/mobile/ios/sourcemaps-datadog.sh new file mode 100755 index 00000000000..b182975a380 --- /dev/null +++ b/apps/mobile/ios/sourcemaps-datadog.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +REACT_NATIVE_XCODE="../../../node_modules/react-native/scripts/react-native-xcode.sh" +DATADOG_XCODE="../../../node_modules/.bin/datadog-ci react-native xcode" + +/bin/sh -c "$DATADOG_XCODE $REACT_NATIVE_XCODE" diff --git a/apps/mobile/jest-setup.js b/apps/mobile/jest-setup.js index eead5ec6431..8fdc2757570 100644 --- a/apps/mobile/jest-setup.js +++ b/apps/mobile/jest-setup.js @@ -122,3 +122,5 @@ jest.mock('wallet/src/features/appearance/hooks', () => { useSelectedColorScheme: () => 'light', } }) + +jest.mock('openai') diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 26f39ae3b5a..38d1d3506d7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -33,7 +33,7 @@ "link:assets": "react-native-asset", "graphql:generate:swift": "cd ios && ./Pods/Apollo/apollo-ios-cli generate", "hardhat": "hardhat node", - "check:circular": "../../scripts/check-circular-imports.sh ./src/app/App.tsx 5", + "check:circular": "../../scripts/check-circular-imports.sh ./src/app/App.tsx 4", "ios": "yarn ios:prebuild && SKIP_BUNDLING=1 react-native run-ios", "ios:prebuild": "yarn graphql:generate:swift && cd ios/WidgetsCore/MobileSchema && rm -rf !(README.md) && cd ../../.. && yarn graphql:generate:swift && yarn env:local:copy:swift", "ios:smol": "SKIP_BUNDLING=1 react-native run-ios --simulator=\"iPhone SE (3rd generation)\"", @@ -55,6 +55,8 @@ "dependencies": { "@amplitude/analytics-react-native": "1.4.0", "@apollo/client": "3.10.4", + "@datadog/mobile-react-native": "2.4.1", + "@datadog/mobile-react-navigation": "2.4.1", "@ethersproject/shims": "5.6.0", "@formatjs/intl-datetimeformat": "4.5.1", "@formatjs/intl-getcanonicallocales": "1.9.0", @@ -95,6 +97,7 @@ "babel-plugin-transform-inline-environment-variables": "0.4.4", "babel-plugin-transform-remove-console": "6.9.4", "cross-fetch": "3.1.5", + "d3-shape": "3.2.0", "dayjs": "1.11.7", "ethers": "5.7.2", "expo": "50.0.15", @@ -111,7 +114,7 @@ "fuse.js": "6.5.3", "i18next": "23.10.0", "lodash": "4.17.21", - "no-yolo-signatures": "0.0.2", + "openai": "4.40.0", "react": "18.2.0", "react-freeze": "1.0.3", "react-i18next": "14.1.0", @@ -158,6 +161,7 @@ "@babel/plugin-proposal-logical-assignment-operators": "7.16.7", "@babel/plugin-proposal-numeric-separator": "7.16.7", "@babel/runtime": "7.18.9", + "@datadog/datadog-ci": "2.39.0", "@faker-js/faker": "7.6.0", "@storybook/react": "7.0.2", "@tamagui/babel-plugin": "1.95.1", diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx index 95b75963cda..46640688bb3 100644 --- a/apps/mobile/src/app/App.tsx +++ b/apps/mobile/src/app/App.tsx @@ -1,4 +1,5 @@ import { ApolloProvider } from '@apollo/client' +import { DatadogProvider, DatadogProviderConfiguration, SdkVerbosity } from '@datadog/mobile-react-native' import { BottomSheetModalProvider } from '@gorhom/bottom-sheet' import * as Sentry from '@sentry/react-native' import { PerformanceProfiler, RenderPassReport } from '@shopify/react-native-performance' @@ -12,10 +13,9 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler' import { MMKV } from 'react-native-mmkv' import { SafeAreaProvider } from 'react-native-safe-area-context' import { enableFreeze } from 'react-native-screens' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider' -import { useAppSelector } from 'src/app/hooks' import { AppModals } from 'src/app/modals/AppModals' import { NavigationContainer } from 'src/app/navigation/NavigationContainer' import { useIsPartOfNavigationTree } from 'src/app/navigation/hooks' @@ -28,6 +28,8 @@ import { LockScreenContextProvider } from 'src/features/authentication/lockScree import { BiometricContextProvider } from 'src/features/biometrics/context' import { NotificationToastWrapper } from 'src/features/notifications/NotificationToastWrapper' import { initOneSignal } from 'src/features/notifications/Onesignal' +import { AIAssistantScreen } from 'src/features/openai/AIAssistantScreen' +import { OpenAIContextProvider } from 'src/features/openai/OpenAIContext' import { shouldLogScreen } from 'src/features/telemetry/directLogScreens' import { selectCustomEndpoint } from 'src/features/tweaks/selectors' import { @@ -43,7 +45,8 @@ import { config } from 'uniswap/src/config' import { uniswapUrls } from 'uniswap/src/constants/urls' import { DUMMY_STATSIG_SDK_KEY, StatsigCustomAppValue } from 'uniswap/src/features/gating/constants' import { Experiments } from 'uniswap/src/features/gating/experiments' -import { WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags' +import { FeatureFlags, WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { loadStatsigOverrides } from 'uniswap/src/features/gating/overrides/customPersistedOverrides' import { Statsig, StatsigOptions, StatsigProvider, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig' import Trace from 'uniswap/src/features/telemetry/Trace' @@ -52,7 +55,7 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' import i18n from 'uniswap/src/i18n/i18n' import { CurrencyId } from 'uniswap/src/types/currency' -import { isDetoxBuild } from 'utilities/src/environment/constants' +import { isDetoxBuild, isJestRun } from 'utilities/src/environment/constants' import { registerConsoleOverrides } from 'utilities/src/logger/console' import { logger } from 'utilities/src/logger/logger' import { useAsyncData } from 'utilities/src/react/hooks' @@ -90,7 +93,8 @@ if (!__DEV__ && !isDetoxBuild) { environment: getSentryEnvironment(), dsn: config.sentryDsn, attachViewHierarchy: true, - enableCaptureFailedRequests: true, + // DataDog would do this for us now + enableCaptureFailedRequests: false, tracesSampleRate: getSentryTracesSamplingRate(), integrations: [ new Sentry.ReactNativeTracing({ @@ -108,6 +112,20 @@ if (!__DEV__ && !isDetoxBuild) { }) } +// Datadog +const datadogConfig = new DatadogProviderConfiguration( + config.datadogClientToken, + getSentryEnvironment(), + config.datadogProjectId, + !__DEV__, // trackInteractions + !__DEV__, // trackResources + !__DEV__, // trackErrors +) +datadogConfig.site = 'US1' +datadogConfig.longTaskThresholdMs = 100 +datadogConfig.nativeCrashReportEnabled = true +datadogConfig.verbosity = SdkVerbosity.WARN + // Log boxes on simulators can block detox tap event when they cover buttons placed at // the bottom of the screen and cause tests to fail. if (isDetoxBuild) { @@ -156,29 +174,40 @@ function App(): JSX.Element | null { } return ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ) } +function DatadogProviderWrapper({ children }: PropsWithChildren): JSX.Element { + const datadogEnabled = useFeatureFlag(FeatureFlags.Datadog) + + if (isDetoxBuild || isJestRun || !datadogEnabled) { + return <>{children} + } + return {children} +} + function SentryTags({ children }: PropsWithChildren): JSX.Element { useEffect(() => { for (const [_, flagKey] of WALLET_FEATURE_FLAG_NAMES.entries()) { @@ -200,7 +229,7 @@ const MAX_CACHE_SIZE_IN_BYTES = 1024 * 1024 * 25 // 25 MB // Ensures redux state is available inside usePersistedApolloClient for the custom endpoint function AppOuter(): JSX.Element | null { - const customEndpoint = useAppSelector(selectCustomEndpoint) + const customEndpoint = useSelector(selectCustomEndpoint) const client = usePersistedApolloClient({ storageWrapper: new MMKVWrapper(new MMKV()), maxCacheSizeInBytes: MAX_CACHE_SIZE_IN_BYTES, @@ -233,13 +262,15 @@ function AppOuter(): JSX.Element | null { }} > - - - - - - - + + + + + + + + + @@ -259,7 +290,7 @@ function AppInner(): JSX.Element { const dispatch = useDispatch() const isDarkMode = useIsDarkMode() const themeSetting = useCurrentAppearanceSetting() - const allowAnalytics = useAppSelector(selectAllowAnalytics) + const allowAnalytics = useSelector(selectAllowAnalytics) useEffect(() => { if (allowAnalytics) { @@ -287,8 +318,11 @@ function AppInner(): JSX.Element { NativeModules.ThemeModule.setColorScheme(themeSetting) }, [themeSetting]) + const openAIAssistantEnabled = useFeatureFlag(FeatureFlags.OpenAIAssistant) + return ( <> + {openAIAssistantEnabled && } @@ -297,7 +331,7 @@ function AppInner(): JSX.Element { } function DataUpdaters(): JSX.Element { - const favoriteTokens: CurrencyId[] = useAppSelector(selectFavoriteTokens) + const favoriteTokens: CurrencyId[] = useSelector(selectFavoriteTokens) const accountsMap: Record = useAccounts() const { locale } = useCurrentLanguageInfo() const { code } = useAppFiatCurrencyInfo() diff --git a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx index 567bc98e84b..9c799dc0b47 100644 --- a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx +++ b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx @@ -14,6 +14,7 @@ import { ShareableEntity } from 'uniswap/src/types/sharing' import { logger } from 'utilities/src/logger/logger' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' import { + NavigateToFiatOnRampArgs, NavigateToNftCollectionArgs, NavigateToNftItemArgs, NavigateToSendFlowArgs, @@ -38,6 +39,7 @@ export function MobileWalletNavigationProvider({ children }: PropsWithChildren): const navigateToSend = useNavigateToSend() const navigateToSwapFlow = useNavigateToSwapFlow() const navigateToTokenDetails = useNavigateToTokenDetails() + const navigateToFiatOnRamp = useNavigateToFiatOnRamp() return ( void { } }, [dispatch, forAggregatorEnabled]) } + +function useNavigateToFiatOnRamp(): (args: NavigateToFiatOnRampArgs) => void { + const dispatch = useDispatch() + + return useCallback( + ({ prefilledCurrency }: NavigateToFiatOnRampArgs): void => { + dispatch(openModal({ name: ModalName.FiatOnRampAggregator, initialState: { prefilledCurrency } })) + }, + [dispatch], + ) +} diff --git a/apps/mobile/src/app/hooks.ts b/apps/mobile/src/app/hooks.ts index 639606da04f..463915935cd 100644 --- a/apps/mobile/src/app/hooks.ts +++ b/apps/mobile/src/app/hooks.ts @@ -1,21 +1,8 @@ import { useFocusEffect } from '@react-navigation/core' import { useCallback, useRef, useState } from 'react' import { LayoutChangeEvent } from 'react-native' -import { TypedUseSelectorHook, useSelector } from 'react-redux' -import type { MobileState } from 'src/app/reducer' -import { SagaGenerator, select } from 'typed-redux-saga' import { spacing } from 'ui/src/theme' -// Use throughout the app instead of plain `useDispatch` and `useSelector` - -export const useAppSelector: TypedUseSelectorHook = useSelector - -// Use in sagas for better typing when selecting from redux state -export function* appSelect(fn: (state: MobileState) => T): SagaGenerator { - const state = yield* select(fn) - return state -} - const MIN_INPUT_DECIMAL_PAD_GAP = spacing.spacing8 export function useShouldShowNativeKeyboard(): { diff --git a/apps/mobile/src/app/migrations.test.ts b/apps/mobile/src/app/migrations.test.ts index aa9978d3a7c..a050aa1a1b5 100644 --- a/apps/mobile/src/app/migrations.test.ts +++ b/apps/mobile/src/app/migrations.test.ts @@ -67,6 +67,8 @@ import { v64Schema, v65Schema, v66Schema, + v67Schema, + v68Schema, v6Schema, v7Schema, v8Schema, @@ -82,7 +84,7 @@ import { initialWalletConnectState } from 'src/features/walletConnect/walletConn import { ModalName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' -import { ExtensionOnboardingState, initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' +import { initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' import { initialFavoritesState } from 'wallet/src/features/favorites/slice' import { initialFiatCurrencyState } from 'wallet/src/features/fiatCurrency/slice' import { initialLanguageState } from 'wallet/src/features/language/slice' @@ -1337,7 +1339,8 @@ describe('Redux state migrations', () => { const v61Stub = { ...v61Schema } const v62 = migrations[62](v61Stub) - expect(v62.behaviorHistory.extensionOnboardingState).toBe(ExtensionOnboardingState.Undefined) + // Removed in schema 69 + expect(v62.behaviorHistory.extensionOnboardingState).toBe('Undefined') }) it('migrates from v62 to 63', () => { @@ -1413,6 +1416,20 @@ describe('Redux state migrations', () => { const v66Stub = { ...v66Schema } const v67 = migrations[67](v66Stub) - expect(v67.behaviorHistory.extensionOnboardingState).toBe(ExtensionOnboardingState.Undefined) + // Removed in migration 69 + expect(v67.behaviorHistory.extensionOnboardingState).toBe('Undefined') + }) + + it('migrates from v67 to v68', () => { + const v67Stub = { ...v67Schema } + const v68 = migrations[68](v67Stub) + + expect(v68.behaviorHistory.extensionBetaFeedbackState).toBe(undefined) + }) + + it('migrates from v68 to v69', async () => { + const v68Stub = { ...v68Schema } + const v69 = await migrations[69](v68Stub) + expect(v69.behaviorHistory.extensionBetaFeedbackState).toBe(undefined) }) }) diff --git a/apps/mobile/src/app/migrations.ts b/apps/mobile/src/app/migrations.ts index c86ab986d7d..a0a01bfdc2f 100644 --- a/apps/mobile/src/app/migrations.ts +++ b/apps/mobile/src/app/migrations.ts @@ -7,7 +7,6 @@ import dayjs from 'dayjs' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' -import { ExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/slice' import { initialFiatCurrencyState } from 'wallet/src/features/fiatCurrency/slice' import { initialLanguageState } from 'wallet/src/features/language/slice' import { getNFTAssetKey } from 'wallet/src/features/nfts/utils' @@ -18,6 +17,8 @@ import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' import { activatePendingAccounts, addRoutingFieldToTransactions, + deleteBetaOnboardingState, + deleteExtensionOnboardingState, removeUniconV2BehaviorState, removeWalletIsUnlockedState, } from 'wallet/src/state/sharedMigrations' @@ -864,7 +865,8 @@ export const migrations = { newState.behaviorHistory = { ...state.behaviorHistory, - extensionOnboardingState: ExtensionOnboardingState.Undefined, + // Removed in schema 69 + extensionOnboardingState: 'Undefined', } return newState @@ -884,11 +886,16 @@ export const migrations = { // Reset state so that everyone gets the new promo banner even if theyve dismissed the beta version. newState.behaviorHistory = { ...state.behaviorHistory, - extensionOnboardingState: ExtensionOnboardingState.Undefined, + // Removed in schema 69 + extensionOnboardingState: 'Undefined', } return newState }, + + 68: deleteBetaOnboardingState, + + 69: deleteExtensionOnboardingState, } -export const MOBILE_STATE_VERSION = 67 +export const MOBILE_STATE_VERSION = 69 diff --git a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx index deedeec4b0c..563f1e1edb0 100644 --- a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx +++ b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Action } from 'redux' -import { useAppSelector } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { AccountList } from 'src/components/accounts/AccountList' import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' @@ -62,12 +61,12 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme const activeAccountAddress = useActiveAccountAddress() const dispatch = useDispatch() const hasImportedSeedPhrase = useNativeAccountExists() - const modalState = useAppSelector(selectModalState(ModalName.AccountSwitcher)) - const sortedMnemonicAccounts = useAppSelector(selectSortedSignerMnemonicAccounts) + const modalState = useSelector(selectModalState(ModalName.AccountSwitcher)) + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) const [showAddWalletModal, setShowAddWalletModal] = useState(false) - const accounts = useAppSelector(selectAllAccountsSorted) + const accounts = useSelector(selectAllAccountsSorted) const onPressAccount = useCallback( (address: Address) => { diff --git a/apps/mobile/src/app/modals/ExperimentsModal.tsx b/apps/mobile/src/app/modals/ExperimentsModal.tsx index 55d42893e1a..201347e2a80 100644 --- a/apps/mobile/src/app/modals/ExperimentsModal.tsx +++ b/apps/mobile/src/app/modals/ExperimentsModal.tsx @@ -1,9 +1,8 @@ import { useApolloClient } from '@apollo/client' import React, { useState } from 'react' import { ScrollView } from 'react-native-gesture-handler' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Action } from 'redux' -import { useAppSelector } from 'src/app/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { selectCustomEndpoint } from 'src/features/tweaks/selectors' import { setCustomEndpoint } from 'src/features/tweaks/slice' @@ -17,7 +16,7 @@ import { AccordionHeader, GatingOverrides } from 'wallet/src/components/gating/G export function ExperimentsModal(): JSX.Element { const insets = useDeviceInsets() const dispatch = useDispatch() - const customEndpoint = useAppSelector(selectCustomEndpoint) + const customEndpoint = useSelector(selectCustomEndpoint) const apollo = useApolloClient() diff --git a/apps/mobile/src/app/modals/ExtensionPromoModal.tsx b/apps/mobile/src/app/modals/ExtensionPromoModal.tsx deleted file mode 100644 index 90892bb3e17..00000000000 --- a/apps/mobile/src/app/modals/ExtensionPromoModal.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { StyleSheet } from 'react-native' -import 'react-native-reanimated' -import { Button, Flex, Image, Text, useIsDarkMode } from 'ui/src' -import { - EXTENSION_PROMO_BANNER_DARK, - EXTENSION_PROMO_BANNER_DARK_GA, - EXTENSION_PROMO_BANNER_LIGHT, - EXTENSION_PROMO_BANNER_LIGHT_GA, -} from 'ui/src/assets' -import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' -import { ModalName } from 'uniswap/src/features/telemetry/constants' - -export function ExtensionPromoModal({ onClose }: { onClose: () => void }): JSX.Element { - const { t } = useTranslation() - const isDarkMode = useIsDarkMode() - const isExtensionGAPromotionEnabled = useFeatureFlag(FeatureFlags.ExtensionPromotionGA) - - const bannerImageGA = isDarkMode ? EXTENSION_PROMO_BANNER_DARK_GA : EXTENSION_PROMO_BANNER_LIGHT_GA - - const bannerImageBeta = isDarkMode ? EXTENSION_PROMO_BANNER_DARK : EXTENSION_PROMO_BANNER_LIGHT - - const imageUri = isExtensionGAPromotionEnabled ? bannerImageGA : bannerImageBeta - - return ( - - - - - - {isExtensionGAPromotionEnabled - ? t('home.modal.getExtension.ga.title') - : t('home.modal.getExtension.beta.title')} - - - - , - }} - i18nKey="home.modal.getExtension.ga.step1" - /> - - - {t('home.modal.getExtension.ga.step2')} - - - {isExtensionGAPromotionEnabled - ? t('home.modal.getExtension.ga.step3') - : t('home.modal.getExtension.beta.step3')} - - - - - - - ) -} - -const ImageStyles = StyleSheet.create({ - responsiveImage: { - aspectRatio: 686 / 430, - height: undefined, - width: '100%', - }, -}) diff --git a/apps/mobile/src/app/modals/SwapModal.tsx b/apps/mobile/src/app/modals/SwapModal.tsx index a54f83c9486..15c9ba243da 100644 --- a/apps/mobile/src/app/modals/SwapModal.tsx +++ b/apps/mobile/src/app/modals/SwapModal.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { BiometricsIcon } from 'src/components/icons/BiometricsIcon' import { useBiometricAppSettings, useBiometricPrompt, useOsBiometricAuthEnabled } from 'src/features/biometrics/hooks' import { closeModal } from 'src/features/modals/modalSlice' @@ -13,7 +12,7 @@ import { useSwapPrefilledState } from 'wallet/src/features/transactions/swap/hoo export function SwapModal(): JSX.Element { const appDispatch = useDispatch() - const { initialState } = useAppSelector(selectModalState(ModalName.Swap)) + const { initialState } = useSelector(selectModalState(ModalName.Swap)) const onClose = useCallback((): void => { appDispatch(closeModal({ name: ModalName.Swap })) diff --git a/apps/mobile/src/app/modals/TransferTokenModal.tsx b/apps/mobile/src/app/modals/TransferTokenModal.tsx index 95bc22c9265..fe78578df73 100644 --- a/apps/mobile/src/app/modals/TransferTokenModal.tsx +++ b/apps/mobile/src/app/modals/TransferTokenModal.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' import { TransferFlow } from 'src/features/transactions/transfer/TransferFlow' @@ -14,7 +13,7 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' export function TransferTokenModal(): JSX.Element { const colors = useSporeColors() const appDispatch = useDispatch() - const modalState = useAppSelector(selectModalState(ModalName.Send)) + const modalState = useSelector(selectModalState(ModalName.Send)) const onClose = useCallback((): void => { appDispatch(closeModal({ name: ModalName.Send })) diff --git a/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap b/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap index 5d3f18dd6c1..076e03a3860 100644 --- a/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap +++ b/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap @@ -347,9 +347,9 @@ exports[`AccountSwitcher renders correctly 1`] = ` { "color": "#000000", "fontFamily": "Basel-Medium", - "fontSize": 17, + "fontSize": 15, "fontWeight": "500", - "lineHeight": 24, + "lineHeight": 20, } } suppressHighlighting={true} @@ -579,9 +579,9 @@ exports[`AccountSwitcher renders correctly 1`] = ` { "color": "#7D7D7D", "fontFamily": "Basel-Medium", - "fontSize": 17, + "fontSize": 15, "fontWeight": "500", - "lineHeight": 24, + "lineHeight": 20, } } suppressHighlighting={true} diff --git a/apps/mobile/src/app/modals/utils.tsx b/apps/mobile/src/app/modals/utils.tsx index 19d2b4430ed..65328ec0c50 100644 --- a/apps/mobile/src/app/modals/utils.tsx +++ b/apps/mobile/src/app/modals/utils.tsx @@ -1,4 +1,4 @@ -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { ModalsState } from 'src/features/modals/ModalsState' import { selectModalState } from 'src/features/modals/selectModalState' @@ -14,7 +14,7 @@ export function LazyModalRenderer({ name: keyof ModalsState children: JSX.Element }): JSX.Element | null { - const modalState = useAppSelector(selectModalState(name)) + const modalState = useSelector(selectModalState(name)) if (!modalState.isOpen) { // avoid doing any work until the modal needs to be open diff --git a/apps/mobile/src/app/navigation/NavBar.tsx b/apps/mobile/src/app/navigation/NavBar.tsx index ca600672974..fe7ba6c83b3 100644 --- a/apps/mobile/src/app/navigation/NavBar.tsx +++ b/apps/mobile/src/app/navigation/NavBar.tsx @@ -28,13 +28,14 @@ import { import { Search } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { borderRadii, fonts } from 'ui/src/theme' +import { useHighestBalanceNativeCurrencyId } from 'uniswap/src/features/dataApi/balances' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { opacify } from 'uniswap/src/utils/colors' import { isAndroid, isIOS } from 'utilities/src/platform' -import { useHighestBalanceNativeCurrencyId } from 'wallet/src/features/dataApi/balances' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { prepareSwapFormState } from 'wallet/src/features/transactions/swap/utils' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' @@ -109,9 +110,9 @@ const SwapFAB = memo(function _SwapFAB({ activeScale = 0.96 }: SwapTabBarButtonP const dispatch = useDispatch() const isDarkMode = useIsDarkMode() - const activeAccountAddress = useActiveAccountAddressWithThrow() - const inputCurrencyId = useHighestBalanceNativeCurrencyId(activeAccountAddress) + const valueModifiers = usePortfolioValueModifiers(activeAccountAddress) ?? [] + const inputCurrencyId = useHighestBalanceNativeCurrencyId(activeAccountAddress, valueModifiers) const onPress = useCallback(async () => { dispatch( diff --git a/apps/mobile/src/app/navigation/NavigationContainer.tsx b/apps/mobile/src/app/navigation/NavigationContainer.tsx index f2bbb93034b..337b6ce963e 100644 --- a/apps/mobile/src/app/navigation/NavigationContainer.tsx +++ b/apps/mobile/src/app/navigation/NavigationContainer.tsx @@ -1,3 +1,4 @@ +import { DdRumReactNavigationTracking } from '@datadog/mobile-react-navigation' import { createNavigationContainerRef, DefaultTheme, @@ -52,6 +53,8 @@ export const NavigationContainer: FC> = ({ children, on // setting initial route name for telemetry const initialRoute = navigationRef.getCurrentRoute()?.name as MobileAppScreen setRouteName(initialRoute) + + DdRumReactNavigationTracking.startTrackingViews(navigationRef.current) }} onStateChange={(): void => { const previousRouteName = routeName diff --git a/apps/mobile/src/app/navigation/navigation.tsx b/apps/mobile/src/app/navigation/navigation.tsx index cbb449b805b..1a1f457d95f 100644 --- a/apps/mobile/src/app/navigation/navigation.tsx +++ b/apps/mobile/src/app/navigation/navigation.tsx @@ -2,7 +2,7 @@ import { createNavigationContainerRef, NavigationContainer } from '@react-naviga import { createNativeStackNavigator } from '@react-navigation/native-stack' import { createStackNavigator, TransitionPresets } from '@react-navigation/stack' import React from 'react' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { renderHeaderBackButton, renderHeaderBackImage } from 'src/app/navigation/components' import { AppStackParamList, @@ -36,7 +36,6 @@ import { RestoreCloudBackupLoadingScreen } from 'src/screens/Import/RestoreCloud import { RestoreCloudBackupPasswordScreen } from 'src/screens/Import/RestoreCloudBackupPasswordScreen' import { RestoreCloudBackupScreen } from 'src/screens/Import/RestoreCloudBackupScreen' import { SeedPhraseInputScreen } from 'src/screens/Import/SeedPhraseInputScreen' -import { SeedPhraseInputScreenV2 } from 'src/screens/Import/SeedPhraseInputScreenV2' import { SelectWalletScreen } from 'src/screens/Import/SelectWalletScreen' import { WatchWalletScreen } from 'src/screens/Import/WatchWalletScreen' import { NFTCollectionScreen } from 'src/screens/NFTCollectionScreen' @@ -202,8 +201,6 @@ export function FiatOnRampStackNavigator(): JSX.Element { export function OnboardingStackNavigator(): JSX.Element { const colors = useSporeColors() - const seedPhraseRefactorEnabled = useFeatureFlag(FeatureFlags.SeedPhraseRefactorNative) - const SeedPhraseInputComponent = seedPhraseRefactorEnabled ? SeedPhraseInputScreenV2 : SeedPhraseInputScreen const isOnboardingKeyringEnabled = useFeatureFlag(FeatureFlags.OnboardingKeyring) @@ -276,7 +273,7 @@ export function OnboardingStackNavigator(): JSX.Element { component={RestoreCloudBackupPasswordScreen} name={OnboardingScreens.RestoreCloudBackupPassword} /> - + @@ -327,7 +324,7 @@ export function UnitagStackNavigator(): JSX.Element { } export function AppStackNavigator(): JSX.Element { - const finishedOnboarding = useAppSelector(selectFinishedOnboarding) + const finishedOnboarding = useSelector(selectFinishedOnboarding) useBiometricCheck() return ( diff --git a/apps/mobile/src/app/schema.ts b/apps/mobile/src/app/schema.ts index 75417236876..ea3e835e972 100644 --- a/apps/mobile/src/app/schema.ts +++ b/apps/mobile/src/app/schema.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { ExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/slice' import { initialFiatCurrencyState } from 'wallet/src/features/fiatCurrency/slice' import { initialLanguageState } from 'wallet/src/features/language/slice' import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' @@ -472,7 +471,8 @@ export const v62Schema = { ...v61Schema, behaviorHistory: { ...v61Schema.behaviorHistory, - extensionOnboardingState: ExtensionOnboardingState.Undefined, + // Removed in schema 69 + extensionOnboardingState: 'Undefined', }, } @@ -507,6 +507,28 @@ export const v66Schema = { ...v65Schema } export const v67Schema = { ...v66Schema } +const v68SchemaIntermediate = { + ...v67Schema, + behaviorHistory: { + ...v67Schema.behaviorHistory, + extensionBetaFeedbackState: undefined, + }, +} + +delete v68SchemaIntermediate.behaviorHistory.extensionBetaFeedbackState + +export const v68Schema = v68SchemaIntermediate + +const v69SchemaIntermediate = { + ...v68Schema, + behaviorHistory: { + ...v68Schema.behaviorHistory, + extensionOnboardingState: undefined, + }, +} +delete v69SchemaIntermediate.behaviorHistory.extensionOnboardingState +export const v69Schema = v69SchemaIntermediate + // TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer // export const getSchema = (): RootState => v0Schema -export const getSchema = (): typeof v67Schema => v67Schema +export const getSchema = (): typeof v69Schema => v69Schema diff --git a/apps/mobile/src/app/store.ts b/apps/mobile/src/app/store.ts index 9d0cc056850..9954fb93843 100644 --- a/apps/mobile/src/app/store.ts +++ b/apps/mobile/src/app/store.ts @@ -1,5 +1,4 @@ -import type { Middleware, PayloadAction, PreloadedState } from '@reduxjs/toolkit' -import { isRejectedWithValue } from '@reduxjs/toolkit' +import type { Middleware, PreloadedState } from '@reduxjs/toolkit' import * as Sentry from '@sentry/react' import { MMKV } from 'react-native-mmkv' import { Storage, persistReducer, persistStore } from 'redux-persist' @@ -8,7 +7,6 @@ import { MobileState, ReducerNames, mobileReducer } from 'src/app/reducer' import { mobileSaga } from 'src/app/saga' import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' import { isNonJestDev } from 'utilities/src/environment/constants' -import { logger } from 'utilities/src/logger/logger' import { createStore } from 'wallet/src/state' import { createMigrate } from 'wallet/src/state/createMigrate' import { RootReducerNames, sharedPersistedStateWhitelist } from 'wallet/src/state/reducer' @@ -30,28 +28,6 @@ export const reduxStorage: Storage = { }, } -const rtkQueryErrorLogger: Middleware = () => (next) => (action: PayloadAction) => { - if (!isRejectedWithValue(action)) { - return next(action) - } - - logger.error(action.error, { - tags: { - file: 'store', - function: 'rtkQueryErrorLogger', - }, - extra: { - type: action.type, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - endpointName: (action.meta as any)?.arg?.endpointName, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - status: (action.payload as any)?.status, - }, - }) - - return next(action) -} - const whitelist: Array = [ ...sharedPersistedStateWhitelist, 'biometricSettings', @@ -95,7 +71,7 @@ export const setupStore = ( reducer: persistedReducer, preloadedState, additionalSagas: [mobileSaga], - middlewareAfter: [rtkQueryErrorLogger, ...middlewares], + middlewareAfter: [...middlewares], enhancers: [sentryReduxEnhancer], }) } diff --git a/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx b/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx index a5db23afa17..8e4bf51f67f 100644 --- a/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx +++ b/apps/mobile/src/components/PriceExplorer/AnimatedDecimalNumber.tsx @@ -6,6 +6,7 @@ import { AnimatedText } from 'src/components/text/AnimatedText' import { Flex, useSporeColors } from 'ui/src' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { TextVariantTokens, fonts } from 'ui/src/theme' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' type AnimatedDecimalNumberProps = { number: ValueAndFormatted @@ -14,7 +15,7 @@ type AnimatedDecimalNumberProps = { wholePartColor?: string decimalPartColor?: string decimalThreshold?: number // below this value (not including) decimal part would have wholePartColor too - testID?: string + testID?: TestIDType maxWidth?: number maxCharPixelWidth?: number } diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx index 868c2ee927e..0f911a379b6 100644 --- a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx @@ -14,7 +14,9 @@ import { Loader } from 'src/components/loading' import { Flex, HapticFeedback } from 'ui/src' import { spacing } from 'ui/src/theme' import { HistoryDuration } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { CurrencyId } from 'uniswap/src/types/currency' +import { isDetoxBuild } from 'utilities/src/environment/constants' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -61,7 +63,8 @@ export const PriceExplorer = memo(function PriceExplorer({ const { convertFiatAmount } = useLocalizationContext() const conversionRate = convertFiatAmount(1).amount - const shouldShowAnimatedDot = selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour + const shouldShowAnimatedDot = + (selectedDuration === HistoryDuration.Day || selectedDuration === HistoryDuration.Hour) && !isDetoxBuild const additionalPadding = shouldShowAnimatedDot ? 40 : 0 const { lastPricePoint, convertedPriceHistory } = useMemo(() => { @@ -154,7 +157,12 @@ function PriceExplorerChart({ return ( // TODO(MOB-2166): remove forced LTR direction + scaleX horizontal flip technique once react-native-wagmi-charts fixes this: https://github.com/coinjar/react-native-wagmi-charts/issues/136 - + {shouldShowAnimatedDot && ( diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx index 8e141f71ac3..a1b303195d8 100644 --- a/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx @@ -17,6 +17,7 @@ import { useSporeColors } from 'ui/src' import { TextLoaderWrapper } from 'ui/src/components/text/Text' import { fonts } from 'ui/src/theme' import { FiatCurrencyInfo } from 'uniswap/src/features/fiatOnRamp/types' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { ADDITIONAL_WIDTH_FOR_ANIMATIONS, AnimatedCharStyles, @@ -370,7 +371,7 @@ const PriceExplorerAnimatedNumber = ({ - + {lessThanSymbol} {currency.symbolAtFront && currencySymbol} diff --git a/apps/mobile/src/components/PriceExplorer/Text.tsx b/apps/mobile/src/components/PriceExplorer/Text.tsx index 7b6ac2a1797..92e2f6f6f7d 100644 --- a/apps/mobile/src/components/PriceExplorer/Text.tsx +++ b/apps/mobile/src/components/PriceExplorer/Text.tsx @@ -6,6 +6,7 @@ import { useLineChartPrice, useLineChartRelativeChange } from 'src/components/Pr import { AnimatedText } from 'src/components/text/AnimatedText' import { Flex, useSporeColors } from 'ui/src' import { AnimatedCaretChange } from 'ui/src/components/icons' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { isAndroid } from 'utilities/src/platform' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' @@ -32,7 +33,7 @@ export function PriceText({ maxWidth }: { loading: boolean; maxWidth?: number }) maxWidth={maxWidth} number={price} separator={decimalSeparator} - testID="price-text" + testID={TestID.PriceText} variant="heading1" /> ) @@ -60,7 +61,13 @@ export function RelativeChangeText({ loading }: { loading: boolean }): JSX.Eleme } return ( - + + {label} ) diff --git a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap index 0d63d3ca71f..bb0ea151f03 100644 --- a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap +++ b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap @@ -322,6 +322,7 @@ exports[`RelativeChangeText renders without error 1`] = ` "marginTop": 2, } } + testID="relative-price-change" > (ScannerModalState.ScanQr) const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) diff --git a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx index eb1c26b97cc..4da3106a9a8 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx @@ -9,7 +9,9 @@ import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { iconSizes } from 'ui/src/theme' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { WalletChainId } from 'uniswap/src/types/chains' import { RecipientList } from 'wallet/src/components/RecipientSearch/RecipientList' +import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' import { useFilteredRecipientSections } from 'wallet/src/components/RecipientSearch/hooks' import { SearchBar } from 'wallet/src/features/search/SearchBar' @@ -18,6 +20,7 @@ interface RecipientSelectProps { onHideRecipientSelector: () => void recipient?: string focusInput?: boolean + chainId?: WalletChainId } function QRScannerIconButton({ onPress }: { onPress: () => void }): JSX.Element { @@ -35,6 +38,7 @@ export function _RecipientSelect({ onHideRecipientSelector, recipient, focusInput, + chainId, }: RecipientSelectProps): JSX.Element { const { t } = useTranslation() const { isSheetReady } = useBottomSheetContext() @@ -42,6 +46,8 @@ export function _RecipientSelect({ const [pattern, setPattern] = useState('') const [showQRScanner, setShowQRScanner] = useState(false) + const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) + const [selectedRecipient, setSelectedRecipient] = useState(recipient) const sections = useFilteredRecipientSections(pattern) useEffect(() => { @@ -61,17 +67,33 @@ export function _RecipientSelect({ setShowQRScanner(false) }, [setShowQRScanner]) + const onSelect = useCallback( + (newRecipient: string) => { + setSelectedRecipient(newRecipient) + setCheckSpeedBumps(true) + }, + [setSelectedRecipient], + ) + + const onSpeedBumpConfirm = useCallback(() => { + if (selectedRecipient) { + onSelectRecipient(selectedRecipient) + } + }, [onSelectRecipient, selectedRecipient]) + return ( <> - {t('qrScanner.recipient.label.send')} + + {t('send.recipient.header')} + } - placeholder={t('qrScanner.recipient.input.placeholder')} + placeholder={t('send.recipient.input.placeholder')} value={pattern ?? ''} onBack={recipient ? onHideRecipientSelector : undefined} onChangeText={setPattern} @@ -79,17 +101,24 @@ export function _RecipientSelect({ /> {!sections.length ? ( - {t('qrScanner.recipient.results.empty')} + {t('send.recipient.results.empty')} - {t('qrScanner.recipient.results.error')} + {t('send.recipient.results.error')} ) : ( // Show either suggested recipients or filtered sections based on query - isSheetReady && + isSheetReady && )} - {showQRScanner && } + {showQRScanner && } + ) } diff --git a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx index 896dc9e9af5..4c3b9c0cc26 100644 --- a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx +++ b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx @@ -1,8 +1,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { AssociatedAccountsList } from 'src/components/RemoveWallet/AssociatedAccountsList' import { RemoveLastMnemonicWalletFooter } from 'src/components/RemoveWallet/RemoveLastMnemonicWalletFooter' @@ -33,9 +32,9 @@ export function RemoveWalletModal(): JSX.Element | null { const dispatch = useDispatch() const addressToAccount = useAccounts() - const associatedAccounts = useAppSelector(selectSignerMnemonicAccounts) + const associatedAccounts = useSelector(selectSignerMnemonicAccounts) - const { initialState } = useAppSelector(selectModalState(ModalName.RemoveWallet)) + const { initialState } = useSelector(selectModalState(ModalName.RemoveWallet)) const address = initialState?.address const account = (address && addressToAccount[address]) || undefined diff --git a/apps/mobile/src/components/RemoveWallet/useModalContent.tsx b/apps/mobile/src/components/RemoveWallet/useModalContent.tsx index 3a0ec12bb2b..47c59cd7e38 100644 --- a/apps/mobile/src/components/RemoveWallet/useModalContent.tsx +++ b/apps/mobile/src/components/RemoveWallet/useModalContent.tsx @@ -7,7 +7,6 @@ import TrashIcon from 'ui/src/assets/icons/trash.svg' import WalletIcon from 'ui/src/assets/icons/wallet-filled.svg' import { ThemeNames } from 'ui/src/theme' import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' -import { concatStrings } from 'utilities/src/primitives/string' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { useDisplayName } from 'wallet/src/features/wallet/hooks' @@ -104,10 +103,9 @@ export const useModalContent = ({ // removing mnemonic account if (account?.type === AccountType.SignerMnemonic && currentStep === RemoveWalletStep.Final) { - const associatedAccountNames = concatStrings( - associatedAccounts.filter((aa): aa is Account => aa.address !== account?.address).map((aa) => aa.name ?? ''), - t('common.endAdornment'), - ) + const associatedAccountNames = associatedAccounts + .filter((aa): aa is Account => aa.address !== account?.address) + .map((aa) => aa.name ?? '') return { title: ( @@ -121,15 +119,7 @@ export const useModalContent = ({ /> ), - description: ( - , - }} - i18nKey="account.recoveryPhrase.remove.mnemonic.description" - values={{ walletName: associatedAccountNames }} - /> - ), + description: t('account.recoveryPhrase.remove.mnemonic.description', { walletNames: associatedAccountNames }), Icon: TrashIcon, iconColorLabel: 'statusCritical', actionButtonLabel: t('common.button.remove'), diff --git a/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx b/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx index e1834f6d89d..e17064f599e 100644 --- a/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx +++ b/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx @@ -1,6 +1,5 @@ import { BigNumber } from 'ethers' -import { Transaction, TransactionDescription } from 'no-yolo-signatures' -import React, { useEffect, useState } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import { ScrollView } from 'react-native-gesture-handler' import { LinkButton } from 'src/components/buttons/LinkButton' @@ -14,9 +13,13 @@ import { getValidAddress, shortenAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' import { useENS } from 'wallet/src/features/ens/useENS' import { ContentRow } from 'wallet/src/features/transactions/TransactionRequest/ContentRow' -import { SpendingDetails } from 'wallet/src/features/transactions/TransactionRequest/SpendingDetails' +import { + SpendingDetails, + SpendingEthDetails, +} from 'wallet/src/features/transactions/TransactionRequest/SpendingDetails' import { ExplorerDataType, getExplorerLink } from 'wallet/src/utils/linking' import { useNoYoloParser } from 'wallet/src/utils/useNoYoloParser' +import { useTransactionCurrencies } from 'wallet/src/utils/useTransactionCurrencies' const getStrMessage = (request: WalletConnectRequest): string => { if (request.type === EthMethod.PersonalSign || request.type === EthMethod.EthSign) { @@ -110,46 +113,24 @@ function TransactionDetails({ transaction: EthTransaction }): JSX.Element { const { t } = useTranslation() - const parser = useNoYoloParser(chainId) - - const [isLoading, setIsLoading] = useState(true) - const [parsedData, setParsedData] = useState(undefined) - - const { from, to, value, data } = transaction - - useEffect(() => { - const parseResult = async (): Promise => { - // no-yolo-parser library expects these fields to be defined - if (!from || !to || !value || !data) { - return - } - return parser.parseAsResult(transaction as Transaction).then((result) => { - if (!result.transactionDescription.ok) { - throw result.transactionDescription.error - } - - return result.transactionDescription.result - }) - } + const { parsedTransactionData, isLoading } = useNoYoloParser(transaction, chainId) + const { to, value } = transaction - parseResult() - .then((result) => { - setParsedData(result) - }) - .catch((error) => { - setParsedData(undefined) - logger.warn('RequestMessage', 'DecodedDataDetails', 'Could not parse data', error) - }) - .finally(() => { - setIsLoading(false) - }) - }, [data, from, parser, to, transaction, value]) + const transactionCurrencies = useTransactionCurrencies({ chainId, to, parsedTransactionData }) return ( - {value && !BigNumber.from(value).eq(0) ? : null} + {value && !BigNumber.from(value).eq(0) ? : null} + {transactionCurrencies?.map((currencyInfo, i) => ( + + ))} {to ? ( - + ) : null} @@ -162,7 +143,7 @@ function TransactionDetails({ py="$spacing2" > - {parsedData ? parsedData.name : t('common.text.unknown')} + {parsedTransactionData ? parsedTransactionData.name : t('common.text.unknown')} diff --git a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx index db9d610cf01..e2e2a7599cf 100644 --- a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx +++ b/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx @@ -3,8 +3,7 @@ import { getSdkError } from '@walletconnect/utils' import { providers } from 'ethers' import React, { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' import { KidSuperCheckinModal } from 'src/components/Requests/RequestModal/KidSuperCheckinModal' import { UwULinkErc20SendModal } from 'src/components/Requests/RequestModal/UwULinkErc20SendModal' @@ -46,7 +45,7 @@ const VALID_REQUEST_TYPES = [ export function WalletConnectRequestModal({ onClose, request }: Props): JSX.Element | null { const { t } = useTranslation() const netInfo = useNetInfo() - const didOpenFromDeepLink = useAppSelector(selectDidOpenFromDeepLink) + const didOpenFromDeepLink = useSelector(selectDidOpenFromDeepLink) const chainId = request.chainId const tx: providers.TransactionRequest | null = useMemo(() => { diff --git a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx b/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx index 73f3b0da64f..18d4e19d77e 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx +++ b/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx @@ -3,12 +3,10 @@ import { getSdkError } from '@walletconnect/utils' import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { DappHeaderIcon } from 'src/components/Requests/DappHeaderIcon' import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' import { PendingConnectionSwitchAccountModal } from 'src/components/Requests/ScanSheet/PendingConnectionSwitchAccountModal' -import { truncateQueryParams } from 'src/components/Requests/ScanSheet/util' import { LinkButton } from 'src/components/buttons/LinkButton' import { returnToPreviousApp } from 'src/features/walletConnect/WalletConnect' import { wcWeb3Wallet } from 'src/features/walletConnect/saga' @@ -154,7 +152,7 @@ export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX. const dispatch = useDispatch() const activeAddress = useActiveAccountAddressWithThrow() const activeAccount = useActiveAccountWithThrow() - const didOpenFromDeepLink = useAppSelector(selectDidOpenFromDeepLink) + const didOpenFromDeepLink = useSelector(selectDidOpenFromDeepLink) const [modalState, setModalState] = useState(PendingConnectionModalState.Hidden) @@ -288,7 +286,7 @@ function PendingConnectionModalContent({ { - // In fact, the first element will be always returned below. url is - // added as a fallback just to satisfy TypeScript. - return url.split('?')[0] ?? url -} - export async function getSupportedURI( uri: string, enabledFeatureFlags?: EnabledFeatureFlags, diff --git a/apps/mobile/src/components/Settings/FooterSettings.tsx b/apps/mobile/src/components/Settings/FooterSettings.tsx new file mode 100644 index 00000000000..a8d50cfbda9 --- /dev/null +++ b/apps/mobile/src/components/Settings/FooterSettings.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet } from 'react-native' +import { FadeInDown, FadeOutUp } from 'react-native-reanimated' +import { getFullAppVersion } from 'src/utils/version' +import { Flex, Image, Text, useIsDarkMode } from 'ui/src' +import { AVATARS_DARK, AVATARS_LIGHT } from 'ui/src/assets' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' + +const SIGNATURE_VISIBLE_DURATION = ONE_SECOND_MS * 10 + +export function FooterSettings(): JSX.Element { + const { t } = useTranslation() + const [showSignature, setShowSignature] = useState(false) + const isDarkMode = useIsDarkMode() + + // Fade out signature after duration + useTimeout( + showSignature + ? (): void => { + setShowSignature(false) + } + : (): void => undefined, + SIGNATURE_VISIBLE_DURATION, + ) + + return ( + + {showSignature ? ( + + + + {t('settings.footer')} + + + {isDarkMode ? ( + + ) : ( + + )} + + ) : null} + { + setShowSignature(true) + }} + > + {t('settings.version', { appVersion: getFullAppVersion() })} + + + ) +} + +const styles = StyleSheet.create({ + responsiveImage: { + aspectRatio: 135 / 76, + height: undefined, + width: '100%', + }, +}) diff --git a/apps/mobile/src/components/Settings/OnboardingRow.tsx b/apps/mobile/src/components/Settings/OnboardingRow.tsx new file mode 100644 index 00000000000..eb9084ebb1f --- /dev/null +++ b/apps/mobile/src/components/Settings/OnboardingRow.tsx @@ -0,0 +1,49 @@ +import { SvgProps } from 'react-native-svg' +import { useDispatch } from 'react-redux' +import { useSettingsStackNavigation } from 'src/app/navigation/types' +import { Flex, Text, TouchableArea } from 'ui/src' +import UniswapIcon from 'ui/src/assets/icons/uniswap-logo.svg' +import { RotatableChevron } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { resetWallet, setFinishedOnboarding } from 'wallet/src/features/wallet/slice' + +export function OnboardingRow({ iconProps }: { iconProps: SvgProps }): JSX.Element { + const dispatch = useDispatch() + const navigation = useSettingsStackNavigation() + const associatedAccounts = useSignerAccounts() + + const onPressReset = (): void => { + const uniqueMnemonicIds = new Set(associatedAccounts.map((a) => a.mnemonicId)) + const accountAddresses = associatedAccounts.map((a) => a.address) + Promise.all([[...uniqueMnemonicIds].map(Keyring.removeMnemonic), accountAddresses.map(Keyring.removePrivateKey)]) + .then(() => { + navigation.goBack() + dispatch(resetWallet()) + dispatch(setFinishedOnboarding({ finishedOnboarding: false })) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'SettingsScreen', function: 'Keyring.removeMnemonic' }, + }) + }) + } + + return ( + + + + + + + + Onboarding + + + + + + ) +} diff --git a/apps/mobile/src/components/Settings/WalletSettings.tsx b/apps/mobile/src/components/Settings/WalletSettings.tsx new file mode 100644 index 00000000000..4d6a951f47a --- /dev/null +++ b/apps/mobile/src/components/Settings/WalletSettings.tsx @@ -0,0 +1,84 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSettingsStackNavigation } from 'src/app/navigation/types' +import { Button, Flex, Text, TouchableArea } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { AccountType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { useAccounts } from 'wallet/src/features/wallet/hooks' + +const DEFAULT_ACCOUNTS_TO_DISPLAY = 6 + +export function WalletSettings(): JSX.Element { + const { t } = useTranslation() + const navigation = useSettingsStackNavigation() + const addressToAccount = useAccounts() + const [showAll, setShowAll] = useState(false) + + const allAccounts = useMemo(() => { + const accounts = Object.values(addressToAccount) + const _mnemonicWallets = accounts + .filter((a): a is SignerMnemonicAccount => a.type === AccountType.SignerMnemonic) + .sort((a, b) => { + return a.derivationIndex - b.derivationIndex + }) + const _viewOnlyWallets = accounts + .filter((a) => a.type === AccountType.Readonly) + .sort((a, b) => { + return a.timeImportedMs - b.timeImportedMs + }) + return [..._mnemonicWallets, ..._viewOnlyWallets] + }, [addressToAccount]) + + const toggleViewAll = (): void => { + setShowAll(!showAll) + } + + const handleNavigation = (address: string): void => { + navigation.navigate(MobileScreens.SettingsWallet, { address }) + } + + return ( + + + + {t('settings.section.wallet.title')} + + + {allAccounts.slice(0, showAll ? allAccounts.length : DEFAULT_ACCOUNTS_TO_DISPLAY).map((account) => { + const isViewOnlyWallet = account.type === AccountType.Readonly + + return ( + handleNavigation(account.address)} + > + + + + + + ) + })} + {allAccounts.length > DEFAULT_ACCOUNTS_TO_DISPLAY && ( + + )} + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/BuyNativeTokenModal.tsx b/apps/mobile/src/components/TokenDetails/BuyNativeTokenModal.tsx new file mode 100644 index 00000000000..88ddab2e35b --- /dev/null +++ b/apps/mobile/src/components/TokenDetails/BuyNativeTokenModal.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WalletChainId } from 'uniswap/src/types/chains' +import { useCurrencyInfo, useNativeCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' +import { BuyNativeTokenButton } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/BuyNativeTokenButton' + +export function BuyNativeTokenModal({ + chainId, + currencyId, + onClose, +}: { + chainId: WalletChainId + currencyId: string + onClose: () => void +}): JSX.Element | null { + const { t } = useTranslation() + const nativeCurrencyInfo = useNativeCurrencyInfo(chainId) + const currencyInfo = useCurrencyInfo(currencyId) + if (!nativeCurrencyInfo || !currencyInfo) { + return null + } + + return ( + + + + + + {t('token.zeroNativeBalance.title', { nativeTokenName: nativeCurrencyInfo.currency.name })} + + + {t('token.zeroNativeBalance.description', { + tokenSymbol: currencyInfo.currency.symbol, + nativeTokenSymbol: nativeCurrencyInfo.currency.symbol, + })} + + + + + + + + + ) +} diff --git a/apps/mobile/src/components/TokenDetails/LinkButton.tsx b/apps/mobile/src/components/TokenDetails/LinkButton.tsx index 83b7a1e4359..9a4c8165199 100644 --- a/apps/mobile/src/components/TokenDetails/LinkButton.tsx +++ b/apps/mobile/src/components/TokenDetails/LinkButton.tsx @@ -8,6 +8,7 @@ import { iconSizes } from 'ui/src/theme' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName, ElementNameType } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { setClipboard } from 'uniswap/src/utils/clipboard' import { openUri } from 'uniswap/src/utils/linking' @@ -27,6 +28,7 @@ export function LinkButton({ openExternalBrowser = false, isSafeUri = false, value, + testID, }: { buttonType: LinkButtonType label: string @@ -35,6 +37,7 @@ export function LinkButton({ openExternalBrowser?: boolean isSafeUri?: boolean value: string + testID?: TestIDType }): JSX.Element { const dispatch = useDispatch() const colors = useSporeColors() @@ -69,7 +72,7 @@ export function LinkButton({ borderRadius="$rounded20" px="$spacing12" py="$spacing8" - testID={element} + testID={testID} onPress={onPress} > diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx index 862fc21a98d..9fe5ae4ff00 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx @@ -4,17 +4,20 @@ import { Button, Flex, useSporeColors } from 'ui/src' import { opacify, validColor } from 'ui/src/theme' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName, ElementNameType, SectionName } from 'uniswap/src/features/telemetry/constants' +import { TestID, TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { getContrastPassingTextColor } from 'uniswap/src/utils/colors' function CTAButton({ title, element, onPress, + testID, tokenColor, }: { title: string element: ElementNameType onPress: () => void + testID?: TestIDType tokenColor?: Maybe }): JSX.Element { const colors = useSporeColors() @@ -31,6 +34,7 @@ function CTAButton({ onPress={onPress} size="large" backgroundColor={validColor(tokenColor) ?? '$accent1'} + testID={testID} > {title} @@ -64,6 +68,7 @@ export function TokenDetailsActionButtons({ > diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx index db8c958dd67..59301831af7 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx @@ -7,6 +7,7 @@ import { TokenDetailsScreenQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' export interface TokenDetailsHeaderProps { data?: TokenDetailsScreenQuery @@ -31,7 +32,14 @@ export function TokenDetailsHeader({ url={tokenProject?.logoUrl ?? undefined} /> - + {tokenProject?.name ?? '—'} {/* Suppress warning icon on low warning level */} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx index 9664fb54908..f6b67709bd5 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsLinks.tsx @@ -9,6 +9,7 @@ import TwitterIcon from 'ui/src/assets/icons/x-twitter.svg' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { TokenDetailsScreenQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { UniverseChainId } from 'uniswap/src/types/chains' import { currencyIdToAddress, currencyIdToChain, isDefaultNativeAddress } from 'wallet/src/utils/currencyId' import { ExplorerDataType, getExplorerLink, getTwitterLink } from 'wallet/src/utils/linking' @@ -41,6 +42,7 @@ export function TokenDetailsLinks({ buttonType={LinkButtonType.Link} element={ElementName.TokenLinkEtherscan} label={explorerName} + testID={TestID.TokenLinkEtherscan} value={explorerLink} /> {homepageUrl && ( @@ -49,6 +51,7 @@ export function TokenDetailsLinks({ buttonType={LinkButtonType.Link} element={ElementName.TokenLinkWebsite} label={t('token.links.website')} + testID={TestID.TokenLinkWebsite} value={homepageUrl} /> )} @@ -58,6 +61,7 @@ export function TokenDetailsLinks({ buttonType={LinkButtonType.Link} element={ElementName.TokenLinkTwitter} label={t('token.links.twitter')} + testID={TestID.TokenLinkTwitter} value={getTwitterLink(twitterName)} /> )} @@ -66,6 +70,7 @@ export function TokenDetailsLinks({ buttonType={LinkButtonType.Copy} element={ElementName.Copy} label={t('common.text.contract')} + testID={TestID.TokenLinkCopy} value={address} /> )} diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx index 51e4f1ba391..23972db9779 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsStats.tsx @@ -6,6 +6,7 @@ import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' import { ChartBar, ChartPie, Language as LanguageIcon, TrendDown, TrendUp } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { TokenDetailsScreenQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { NumberType } from 'utilities/src/format/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { Language } from 'wallet/src/features/language/constants' @@ -139,7 +140,7 @@ export function TokenDetailsStats({ {currentDescription && ( {name && ( - + {t('token.stats.section.about', { token: name })} )} diff --git a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx index 092fe8f62e0..c307f436eff 100644 --- a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx +++ b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx @@ -7,7 +7,6 @@ import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { TokenOptionItem } from 'uniswap/src/components/TokenSelector/TokenOptionItem' import { useBottomSheetFocusHook } from 'uniswap/src/components/modals/hooks' import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' -import { UniverseChainId } from 'uniswap/src/types/chains' import { CurrencyId } from 'uniswap/src/types/currency' import { NumberType } from 'utilities/src/format/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -51,7 +50,6 @@ function TokenOptionItemWrapper({ option={option} quantity={option.quantity} quantityFormatted={formatNumberOrString({ value: option.quantity, type: NumberType.TokenTx })} - showNetworkPill={currencyInfo?.currency.chainId !== UniverseChainId.Mainnet} showWarnings={true} tokenWarningDismissed={tokenWarningDismissed} onDismiss={() => Keyboard.dismiss()} diff --git a/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx b/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx index ba99f79913a..8bb17958630 100644 --- a/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx +++ b/apps/mobile/src/components/Trace/TraceUserProperties.test.tsx @@ -1,7 +1,6 @@ import React from 'react' import { useColorScheme } from 'react-native' import renderer, { act } from 'react-test-renderer' -import * as appHooks from 'src/app/hooks' import { TraceUserProperties } from 'src/components/Trace/TraceUserProperties' import * as biometricHooks from 'src/features/biometrics/hooks' import { AuthMethod } from 'src/features/telemetry/utils' @@ -33,6 +32,15 @@ jest.mock('wallet/src/features/wallet/Keyring/Keyring', () => { } }) +const mockDispatch = jest.fn() +const mockSelector = jest.fn() + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: (): jest.Mock => mockDispatch, + useSelector: (): jest.Mock => mockSelector, +})) + const address1 = '0x168fA52Da8A45cEb01318E72B299b2d6A17167BF' const address2 = '0x168fA52Da8A45cEb01318E72B299b2d6A17167BD' const address3 = '0x168fA52Da8A45cEb01318E72B299b2d6A17167BE' @@ -87,7 +95,6 @@ describe('TraceUserProperties', () => { mockFn(useIsDarkModeFile, 'useIsDarkMode', true) mockFn(fiatCurrencyHooks, 'useAppFiatCurrency', FiatCurrency.UnitedStatesDollar) mockFn(languageHooks, 'useCurrentLanguageInfo', { loggingName: 'English' }) - mockFn(appHooks, 'useAppSelector', { enabled: true }) // mock setUserProperty const mocked = mockFn(analytics, 'setUserProperty', undefined) diff --git a/apps/mobile/src/components/Trace/TraceUserProperties.tsx b/apps/mobile/src/components/Trace/TraceUserProperties.tsx index e1e3eb91999..070b25361dc 100644 --- a/apps/mobile/src/components/Trace/TraceUserProperties.tsx +++ b/apps/mobile/src/components/Trace/TraceUserProperties.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { NativeModules } from 'react-native' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { useBiometricAppSettings, useDeviceSupportsBiometricAuth } from 'src/features/biometrics/hooks' import { getAuthMethod } from 'src/features/telemetry/utils' import { getFullAppVersion } from 'src/utils/version' @@ -39,7 +39,7 @@ export function TraceUserProperties(): null { const hideSmallBalances = useHideSmallBalancesSetting() // Effects must check this and ensure they are setting properties for when analytics is reenabled - const allowAnalytics = useAppSelector(selectAllowAnalytics) + const allowAnalytics = useSelector(selectAllowAnalytics) useGatingUserPropertyUsernames() diff --git a/apps/mobile/src/components/accounts/AccountHeader.tsx b/apps/mobile/src/components/accounts/AccountHeader.tsx index a169672f6bf..b76f9aa3cdc 100644 --- a/apps/mobile/src/components/accounts/AccountHeader.tsx +++ b/apps/mobile/src/components/accounts/AccountHeader.tsx @@ -1,7 +1,6 @@ import { SharedEventName } from '@uniswap/analytics-events' import React, { useCallback, useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { openModal } from 'src/features/modals/modalSlice' import { Flex, HapticFeedback, ImpactFeedbackStyle, Text, TouchableArea } from 'ui/src' @@ -24,8 +23,8 @@ import { selectActiveAccount, selectActiveAccountAddress } from 'wallet/src/feat import { DisplayNameType } from 'wallet/src/features/wallet/types' export function AccountHeader(): JSX.Element { - const activeAddress = useAppSelector(selectActiveAccountAddress) - const account = useAppSelector(selectActiveAccount) + const activeAddress = useSelector(selectActiveAccountAddress) + const account = useSelector(selectActiveAccount) const dispatch = useDispatch() const { avatar } = useAvatar(activeAddress) diff --git a/apps/mobile/src/components/banners/ExtensionPromoBanner.tsx b/apps/mobile/src/components/banners/ExtensionPromoBanner.tsx deleted file mode 100644 index 131378d5137..00000000000 --- a/apps/mobile/src/components/banners/ExtensionPromoBanner.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Keyboard, StyleProp, ViewStyle } from 'react-native' -import { useDispatch } from 'react-redux' -import { Flex, Image, Text, TouchableArea, useIsDarkMode, useIsShortMobileDevice, useSporeColors } from 'ui/src' -import { EXTENSION_PROMO_BANNER_DARK, EXTENSION_PROMO_BANNER_LIGHT } from 'ui/src/assets' -import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' -import { borderRadii, iconSizes, spacing } from 'ui/src/theme' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' -import { MobileEventName } from 'uniswap/src/features/telemetry/constants' -import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { ExtensionOnboardingState, setExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/slice' - -const IMAGE_ASPECT_RATIO = 0.69 -const IMAGE_SCREEN_WIDTH_PROPORTION = 0.3 - -export function ExtensionPromoBanner({ - onShowExtensionPromoModal, -}: { - onShowExtensionPromoModal: () => void -}): JSX.Element { - const dispatch = useDispatch() - const { t } = useTranslation() - const { fullWidth } = useDeviceDimensions() - const colors = useSporeColors() - const isDarkMode = useIsDarkMode() - const isShortDevice = useIsShortMobileDevice() - - const imageWidth = IMAGE_SCREEN_WIDTH_PROPORTION * fullWidth - const imageHeight = imageWidth / IMAGE_ASPECT_RATIO - - const isGAEnabled = useFeatureFlag(FeatureFlags.ExtensionPromotionGA) - - const onPressJoin = (): void => { - Keyboard.dismiss() - sendAnalyticsEvent(MobileEventName.ExtensionPromoBannerActionTaken, { - action: 'join', - }) - onShowExtensionPromoModal() - } - - const onPressMaybeLater = (): void => { - sendAnalyticsEvent(MobileEventName.ExtensionPromoBannerActionTaken, { - action: 'dismiss', - }) - dispatch(setExtensionOnboardingState(ExtensionOnboardingState.Completed)) - } - - const baseButtonStyle: StyleProp = { - borderRadius: borderRadii.rounded12, - justifyContent: 'center', - height: iconSizes.icon36, - paddingVertical: spacing.spacing8, - paddingHorizontal: spacing.spacing12, - } - - return ( - - - - - {t('home.banner.extension.title')} - - {!isShortDevice && ( - - {isGAEnabled ? t('home.banner.extension.message.default') : t('home.banner.extension.message.beta')} - - )} - - - - - {isGAEnabled ? t('home.banner.extension.confirm.default') : t('home.banner.extension.confirm.beta')} - - - - - {t('common.button.later')} - - - - - - - - - ) -} diff --git a/apps/mobile/src/components/banners/OfflineBanner.tsx b/apps/mobile/src/components/banners/OfflineBanner.tsx index 1def59e1975..31f11123b11 100644 --- a/apps/mobile/src/components/banners/OfflineBanner.tsx +++ b/apps/mobile/src/components/banners/OfflineBanner.tsx @@ -1,7 +1,7 @@ import { useNetInfo } from '@react-native-community/netinfo' import React from 'react' import { useTranslation } from 'react-i18next' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { BANNER_HEIGHT, BottomBanner } from 'src/components/banners/BottomBanner' import { selectSomeModalOpen } from 'src/features/modals/selectSomeModalOpen' import { useSporeColors } from 'ui/src' @@ -17,8 +17,8 @@ export function OfflineBanner(): JSX.Element | null { const netInfo = useNetInfo() // don't show the offline banner in onboarding - const finishedOnboarding = useAppSelector(selectFinishedOnboarding) - const isModalOpen = useAppSelector(selectSomeModalOpen) + const finishedOnboarding = useSelector(selectFinishedOnboarding) + const isModalOpen = useSelector(selectSomeModalOpen) // Needs to explicity check for false since `netInfo.isConnected` may be null const showBanner = netInfo.isConnected === false && finishedOnboarding && !isModalOpen diff --git a/apps/mobile/src/components/buttons/BackButton.tsx b/apps/mobile/src/components/buttons/BackButton.tsx index adef1898145..9dfb19c7053 100644 --- a/apps/mobile/src/components/buttons/BackButton.tsx +++ b/apps/mobile/src/components/buttons/BackButton.tsx @@ -2,6 +2,7 @@ import { useNavigation } from '@react-navigation/native' import React from 'react' import { BackButtonView } from 'src/components/layout/BackButtonView' import { ColorTokens, TouchableArea, TouchableAreaProps } from 'ui/src' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' type Props = { size?: number @@ -19,14 +20,7 @@ export function BackButton({ onPressBack, size, color, showButtonLabel, ...rest navigation.goBack() } return ( - + ) diff --git a/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap b/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap index 63ff5e52d03..4927183bf47 100644 --- a/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap +++ b/apps/mobile/src/components/buttons/__snapshots__/BackButton.test.tsx.snap @@ -26,7 +26,7 @@ exports[`BackButton renders without error 1`] = ` ], } } - testID="buttons/back-button" + testID="back" > = useCallback( ({ item, index }: ListRenderItemInfo) => { - return + return ( + + ) }, [tokenMetadataDisplayType], ) @@ -238,8 +249,8 @@ type FavoritesSectionProps = AutoScrollProps & { } function FavoritesSection(props: FavoritesSectionProps): JSX.Element | null { - const hasFavoritedTokens = useAppSelector(selectHasFavoriteTokens) - const hasFavoritedWallets = useAppSelector(selectHasWatchedWallets) + const hasFavoritedTokens = useSelector(selectHasFavoriteTokens) + const hasFavoritedWallets = useSelector(selectHasWatchedWallets) if (!hasFavoritedTokens && !hasFavoritedWallets) { return null diff --git a/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx b/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx index c018560a890..e8cae98b0b9 100644 --- a/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx +++ b/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' import FavoriteTokenCard, { FAVORITE_TOKEN_CARD_LOADER_HEIGHT } from 'src/components/explore/FavoriteTokenCard' import { Loader } from 'src/components/loading' @@ -31,7 +30,7 @@ export function FavoriteTokensGrid({ showLoading, ...rest }: FavoriteTokensGridP const [isEditing, setIsEditing] = useState(false) const isTokenDragged = useSharedValue(false) - const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens) + const favoriteCurrencyIds = useSelector(selectFavoriteTokens) // Reset edit mode when there are no favorite tokens useEffect(() => { diff --git a/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx b/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx index a72d91eb5ae..e87e0b20938 100644 --- a/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx +++ b/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx @@ -1,8 +1,7 @@ import { default as React, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' import FavoriteWalletCard from 'src/components/explore/FavoriteWalletCard' import { Loader } from 'src/components/loading' @@ -31,7 +30,7 @@ export function FavoriteWalletsGrid({ showLoading, ...rest }: FavoriteWalletsGri const [isEditing, setIsEditing] = useState(false) const isTokenDragged = useSharedValue(false) - const watchedWalletsSet = useAppSelector(selectWatchedAddressSet) + const watchedWalletsSet = useSelector(selectWatchedAddressSet) const watchedWalletsList = useMemo(() => Array.from(watchedWalletsSet), [watchedWalletsSet]) // Reset edit mode when there are no favorite wallets diff --git a/apps/mobile/src/components/explore/TokenItem.test.tsx b/apps/mobile/src/components/explore/TokenItem.test.tsx index a0ef2fe0919..e16d89d967b 100644 --- a/apps/mobile/src/components/explore/TokenItem.test.tsx +++ b/apps/mobile/src/components/explore/TokenItem.test.tsx @@ -3,6 +3,7 @@ import { TokenItem } from 'src/components/explore/TokenItem' import * as exploreHooks from 'src/components/explore/hooks' import { TOKEN_ITEM_DATA, tokenItemData } from 'src/test/fixtures' import { fireEvent, render, within } from 'src/test/test-utils' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' import { TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' @@ -23,28 +24,36 @@ describe('TokenItem', () => { }) it('renders without error', () => { - const tree = render() + const tree = render( + , + ) expect(tree).toMatchSnapshot() }) it('renders correct token number based on index', () => { const data = tokenItemData() - const { queryByText } = render() + const { queryByText } = render( + , + ) expect(queryByText('2')).toBeTruthy() }) it('renders proper token name', () => { const data = tokenItemData() - const { queryByText } = render() + const { queryByText } = render( + , + ) expect(queryByText(data.name)).toBeTruthy() }) it('navigates to the token details screen when pressed', () => { const data = tokenItemData() - const { getByTestId } = render() + const { getByTestId } = render( + , + ) fireEvent.press(getByTestId(`token-item-${data.name}`), ON_PRESS_EVENT_PAYLOAD) @@ -54,7 +63,9 @@ describe('TokenItem', () => { describe('token price', () => { it('renders token price if it is provided', () => { const data = tokenItemData({ price: 123.45 }) - const { getByTestId } = render() + const { getByTestId } = render( + , + ) const tokenPrice = getByTestId('token-item/price') @@ -64,7 +75,9 @@ describe('TokenItem', () => { it('renders price placeholder if token price is not provided', () => { const data = tokenItemData({ price: undefined }) - const { getByTestId } = render() + const { getByTestId } = render( + , + ) const tokenPrice = getByTestId('token-item/price') @@ -75,7 +88,9 @@ describe('TokenItem', () => { describe('token price change', () => { it('renders token price change if it is provided', () => { const data = tokenItemData({ pricePercentChange24h: 12.34 }) - const { getByTestId } = render() + const { getByTestId } = render( + , + ) const relativeChange = getByTestId('relative-change') @@ -84,7 +99,9 @@ describe('TokenItem', () => { it('renders price change placeholder if token price change is not provided', () => { const data = tokenItemData({ pricePercentChange24h: undefined }) - const { getByTestId } = render() + const { getByTestId } = render( + , + ) const relativeChange = getByTestId('relative-change') @@ -107,7 +124,14 @@ describe('TokenItem', () => { ] it.each(cases)('renders $test metadata subtitle', ({ type, expected }) => { - const { getByTestId } = render() + const { getByTestId } = render( + , + ) const metadataSubtitle = getByTestId('token-item/metadata-subtitle') diff --git a/apps/mobile/src/components/explore/TokenItem.tsx b/apps/mobile/src/components/explore/TokenItem.tsx index 1916a03d55b..268b0eeb8e9 100644 --- a/apps/mobile/src/components/explore/TokenItem.tsx +++ b/apps/mobile/src/components/explore/TokenItem.tsx @@ -1,16 +1,18 @@ -import React, { memo } from 'react' +import React, { ReactNode, memo } from 'react' import { useTranslation } from 'react-i18next' +import { LayoutRectangle } from 'react-native' import ContextMenu from 'react-native-context-menu-view' import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' +import { TokenItemChart } from 'src/components/explore/TokenItemChart' +import { TokenItemData } from 'src/components/explore/TokenItemData' import { useExploreTokenContextMenu } from 'src/components/explore/hooks' import { TokenMetadata } from 'src/components/tokens/TokenMetadata' import { disableOnPress } from 'src/utils/disableOnPress' -import { Flex, ImpactFeedbackStyle, Text, TouchableArea } from 'ui/src' +import { Flex, ImpactFeedbackStyle, Text, TouchableArea, ViewProps } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' import { MobileEventName, SectionName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { WalletChainId } from 'uniswap/src/types/chains' import { buildCurrencyId, buildNativeCurrencyId, @@ -22,26 +24,31 @@ import { RelativeChange } from 'wallet/src/components/text/RelativeChange' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' -export type TokenItemData = { - name: string - logoUrl: string - chainId: WalletChainId - address: Address | null - symbol: string - price?: number - marketCap?: number - pricePercentChange24h?: number - volume24h?: number - totalValueLocked?: number -} - interface TokenItemProps { tokenItemData: TokenItemData index: number + eventName: MobileEventName.ExploreTokenItemSelected | MobileEventName.HomeExploreTokenItemSelected metadataDisplayType?: TokenMetadataDisplayType + containerProps?: ViewProps + hideNumberedList?: boolean + priceWrapperProps?: ViewProps + showChart?: boolean + overlay?: ReactNode + onPriceWrapperLayout?: (layout: LayoutRectangle) => void } -export const TokenItem = memo(function _TokenItem({ tokenItemData, index, metadataDisplayType }: TokenItemProps) { +export const TokenItem = memo(function _TokenItem({ + tokenItemData, + index, + metadataDisplayType, + containerProps, + eventName, + hideNumberedList, + priceWrapperProps, + showChart, + overlay, + onPriceWrapperLayout, +}: TokenItemProps) { const { t } = useTranslation() const tokenDetailsNavigation = useTokenDetailsNavigation() const { convertFiatAmountFormatted } = useLocalizationContext() @@ -81,10 +88,10 @@ export const TokenItem = memo(function _TokenItem({ tokenItemData, index, metada const onPress = (): void => { tokenDetailsNavigation.preload(_currencyId) tokenDetailsNavigation.navigate(_currencyId) - sendAnalyticsEvent(MobileEventName.ExploreTokenItemSelected, { + sendAnalyticsEvent(eventName, { address: currencyIdToAddress(_currencyId), chain: currencyIdToChain(_currencyId) as number, - name, + name: tokenItemData.name, position: index + 1, }) } @@ -104,9 +111,10 @@ export const TokenItem = memo(function _TokenItem({ tokenItemData, index, metada onLongPress={disableOnPress} onPress={onPress} > - + {overlay} + - {index !== undefined && ( + {!hideNumberedList && ( {index + 1} @@ -115,7 +123,7 @@ export const TokenItem = memo(function _TokenItem({ tokenItemData, index, metada )} - + {name} @@ -123,7 +131,14 @@ export const TokenItem = memo(function _TokenItem({ tokenItemData, index, metada {getMetadataSubtitle()} - + {showChart && } + onPriceWrapperLayout?.(e.nativeEvent.layout)} + {...priceWrapperProps} + > {convertFiatAmountFormatted(price, NumberType.FiatTokenPrice)} diff --git a/apps/mobile/src/components/explore/TokenItemChart.tsx b/apps/mobile/src/components/explore/TokenItemChart.tsx new file mode 100644 index 00000000000..cb251091ae9 --- /dev/null +++ b/apps/mobile/src/components/explore/TokenItemChart.tsx @@ -0,0 +1,63 @@ +import { curveNatural } from 'd3-shape' +import { useMemo } from 'react' +import { LineChart, LineChartProvider } from 'react-native-wagmi-charts' +import { useTokenPriceHistory } from 'src/components/PriceExplorer/usePriceHistory' +import { TokenItemData } from 'src/components/explore/TokenItemData' +import { useExtractedTokenColor, useSporeColors } from 'ui/src' +import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' + +// Used to divide the number of data points for a smoother charts +// Necessary because graphql query does not support a time resolution parameter +export const DATA_REDUCTION_FACTOR = 10 + +export function TokenItemChart({ + tokenItemData, + height, + width, +}: { + tokenItemData: TokenItemData + height: number + width: number +}): JSX.Element | null { + const { convertFiatAmount } = useLocalizationContext() + const conversionRate = convertFiatAmount(1).amount + const colors = useSporeColors() + + const currencyId = tokenItemData.address + ? buildCurrencyId(tokenItemData.chainId, tokenItemData.address) + : buildNativeCurrencyId(tokenItemData.chainId) + const { data } = useTokenPriceHistory(currencyId) + const { tokenColor } = useExtractedTokenColor( + tokenItemData.logoUrl, + tokenItemData.symbol, + /*background=*/ colors.surface1.val, + /*default=*/ colors.neutral3.val, + ) + + const convertedPriceHistory = useMemo( + () => + data?.priceHistory + ?.filter((_, index) => index % DATA_REDUCTION_FACTOR === 0) + .map((point) => { + return { ...point, value: point.value * conversionRate } + }), + [data, conversionRate], + ) + + if (!convertedPriceHistory) { + return null + } + + return ( + + + + + + ) +} diff --git a/apps/mobile/src/components/explore/TokenItemData.ts b/apps/mobile/src/components/explore/TokenItemData.ts new file mode 100644 index 00000000000..c27f02cb66e --- /dev/null +++ b/apps/mobile/src/components/explore/TokenItemData.ts @@ -0,0 +1,14 @@ +import { WalletChainId } from 'uniswap/src/types/chains' + +export type TokenItemData = { + name: string + logoUrl: string + chainId: WalletChainId + address: Address | null + symbol: string + price?: number + marketCap?: number + pricePercentChange24h?: number + volume24h?: number + totalValueLocked?: number +} diff --git a/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap index 5d6f98d78fc..0fba7c45640 100644 --- a/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap +++ b/apps/mobile/src/components/explore/__snapshots__/FavoriteHeaderRow.test.tsx.snap @@ -61,9 +61,9 @@ exports[`FavoriteHeaderRow when editing renders without error 1`] = ` { "color": "#FC72FF", "fontFamily": "Basel-Medium", - "fontSize": 17, + "fontSize": 15, "fontWeight": "500", - "lineHeight": 24, + "lineHeight": 20, } } suppressHighlighting={true} diff --git a/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap index 39854e39284..6c77f84a8af 100644 --- a/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap +++ b/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap @@ -88,9 +88,9 @@ exports[`SortButton renders without error 1`] = ` "color": "#7D7D7D", "flexShrink": 1, "fontFamily": "Basel-Medium", - "fontSize": 17, + "fontSize": 15, "fontWeight": "500", - "lineHeight": 24, + "lineHeight": 20, } } suppressHighlighting={true} diff --git a/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap index 8c3bc7d9471..d038ccfba17 100644 --- a/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap +++ b/apps/mobile/src/components/explore/__snapshots__/TokenItem.test.tsx.snap @@ -41,6 +41,7 @@ exports[`TokenItem renders without error 1`] = ` } style={ { + "alignItems": "center", "flexDirection": "row", "flexGrow": 1, "gap": 12, @@ -77,7 +78,7 @@ exports[`TokenItem renders without error 1`] = ` { "color": "#7D7D7D", "fontFamily": "Basel-Medium", - "fontSize": 15, + "fontSize": 13, "fontWeight": "500", "lineHeight": 16, } @@ -123,6 +124,7 @@ exports[`TokenItem renders without error 1`] = ` { dispatch(clearSearchHistory()) diff --git a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx index 58ba3f57a92..6374abcb437 100644 --- a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx +++ b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx @@ -4,8 +4,7 @@ import { SearchTokenItem } from 'src/components/explore/search/items/SearchToken import { getSearchResultId } from 'src/components/explore/search/utils' import { Flex, Loader } from 'ui/src' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { TokenSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { TopToken, usePopularTokens } from 'wallet/src/features/tokens/hooks' function gqlTokenToTokenSearchResult(token: Maybe): TokenSearchResult | null { diff --git a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx index 506a85991af..d330aaa015e 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx @@ -25,12 +25,12 @@ import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { useExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { SearchContext } from 'uniswap/src/features/search/SearchContext' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import i18n from 'uniswap/src/i18n/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' -import { NFTCollectionSearchResult, TokenSearchResult } from 'wallet/src/features/search/SearchResult' +import { NFTCollectionSearchResult } from 'wallet/src/features/search/SearchResult' const ICON_SIZE = '$icon.24' const ICON_COLOR = '$neutral2' @@ -167,10 +167,7 @@ export function SearchResultsSection({ searchQuery }: { searchQuery: string }): if (error) { const filteredErrors = error.graphQLErrors.filter((e) => !IGNORED_ERRORS.includes(e.message)) - - if (filteredErrors.length === 0) { - logger.info('SearchResultSection', 'useExploreSearchQuery', error?.message) - } else { + if (filteredErrors.length !== 0) { return ( { tokenDetailsNavigation.preload(currencyId) @@ -62,7 +62,7 @@ export function SearchTokenItem({ token, searchContext }: SearchTokenItemProps): } const { menuActions, onContextMenuPress } = useExploreTokenContextMenu({ - chainId, + chainId: chainId as WalletChainId, currencyId, analyticsSection: SectionName.ExploreSearch, }) @@ -72,7 +72,7 @@ export function SearchTokenItem({ token, searchContext }: SearchTokenItemProps): diff --git a/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx b/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx index ead2cf54655..1003855b407 100644 --- a/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx @@ -1,8 +1,7 @@ import React, { PropsWithChildren, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks' import { useToggleWatchedWalletCallback } from 'src/features/favorites/hooks' import { disableOnPress } from 'src/utils/disableOnPress' @@ -29,7 +28,7 @@ export function SearchWalletItemBase({ const dispatch = useDispatch() const { preload, navigate } = useEagerExternalProfileNavigation() const { address, type } = searchResult - const isFavorited = useAppSelector(selectWatchedAddressSet).has(address) + const isFavorited = useSelector(selectWatchedAddressSet).has(address) const onPress = (): void => { navigate(address) diff --git a/apps/mobile/src/components/explore/search/utils.ts b/apps/mobile/src/components/explore/search/utils.ts index eb7760d6a6f..16aba4fde91 100644 --- a/apps/mobile/src/components/explore/search/utils.ts +++ b/apps/mobile/src/components/explore/search/utils.ts @@ -2,8 +2,8 @@ import { SEARCH_RESULT_HEADER_KEY } from 'src/components/explore/search/constant import { SearchResultOrHeader } from 'src/components/explore/search/types' import { Chain, ExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { NFTCollectionSearchResult, TokenSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' +import { NFTCollectionSearchResult } from 'wallet/src/features/search/SearchResult' import { searchResultId } from 'wallet/src/features/search/searchHistorySlice' const MAX_TOKEN_RESULTS_COUNT = 4 diff --git a/apps/mobile/src/components/home/FeedTab.tsx b/apps/mobile/src/components/home/FeedTab.tsx index a59a7eb67b9..38d5612b80c 100644 --- a/apps/mobile/src/components/home/FeedTab.tsx +++ b/apps/mobile/src/components/home/FeedTab.tsx @@ -2,8 +2,7 @@ import { ForwardedRef, forwardRef, memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, RefreshControl } from 'react-native' import Animated from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { useAdaptiveFooter } from 'src/components/home/hooks' import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' @@ -19,7 +18,6 @@ import { isAndroid } from 'utilities/src/platform' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' import { useFormattedTransactionDataForFeed } from 'wallet/src/features/activity/hooks' import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { generateActivityItemRenderer } from 'wallet/src/features/transactions/SummaryCards/utils' import { useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' @@ -45,7 +43,7 @@ export const FeedTab = memo( const colors = useSporeColors() const insets = useDeviceInsets() - const watchedWalletsSet = useAppSelector(selectWatchedAddressSet) + const watchedWalletsSet = useSelector(selectWatchedAddressSet) const watchedWalletsList = useMemo(() => Array.from(watchedWalletsSet), [watchedWalletsSet]) const { onContentSizeChange } = useAdaptiveFooter(containerProps?.contentContainerStyle) @@ -54,13 +52,7 @@ export const FeedTab = memo( const hideSpamTokens = useHideSpamTokensSetting() const renderActivityItem = useMemo(() => { - return generateActivityItemRenderer( - TransactionSummaryLayout, - , - SectionTitle, - undefined, - undefined, - ) + return generateActivityItemRenderer(, SectionTitle, undefined, undefined) }, []) const { onRetry, hasData, isLoading, isError, sectionData, keyExtractor } = useFormattedTransactionDataForFeed( diff --git a/apps/mobile/src/components/home/HomeExploreTab.tsx b/apps/mobile/src/components/home/HomeExploreTab.tsx new file mode 100644 index 00000000000..4ec587cb3a0 --- /dev/null +++ b/apps/mobile/src/components/home/HomeExploreTab.tsx @@ -0,0 +1,186 @@ +import { ForwardedRef, forwardRef, memo, useCallback, useEffect, useMemo, useState } from 'react' +import { FlatList, LayoutRectangle, RefreshControl } from 'react-native' +import Animated from 'react-native-reanimated' +import { TokenItem } from 'src/components/explore/TokenItem' +import { TokenItemData } from 'src/components/explore/TokenItemData' +import { useAdaptiveFooter } from 'src/components/home/hooks' +import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' +import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' +import { Flex, LinearGradient, Text, useDeviceInsets, useSporeColors } from 'ui/src' +import { SwirlyArrowDown } from 'ui/src/components/icons' +import { zIndices } from 'ui/src/theme' +import { + Chain, + ContractInput, + HomeScreenTokensQuery, + useHomeScreenTokensQuery, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { Experiments, OnboardingRedesignHomeScreenProperties } from 'uniswap/src/features/gating/experiments' +import { useExperimentValue } from 'uniswap/src/features/gating/hooks' +import { MobileEventName } from 'uniswap/src/features/telemetry/constants' +import { useTranslation } from 'uniswap/src/i18n' +import { isAndroid } from 'utilities/src/platform' +import { useAppFiatCurrency } from 'wallet/src/features/fiatCurrency/hooks' +import { TokenMetadataDisplayType } from 'wallet/src/features/wallet/types' + +const ESTIMATED_ITEM_SIZE = 68 + +export const HomeExploreTab = memo( + forwardRef, TabProps>(function _HomeExploreTab( + { containerProps, scrollHandler, headerHeight, refreshing, onRefresh }, + ref, + ) { + const colors = useSporeColors() + const insets = useDeviceInsets() + const appFiatCurrency = useAppFiatCurrency() + const [maxTokenPriceWrapperWidth, setMaxTokenPriceWrapperWidth] = useState(0) + + const chainId = useExperimentValue( + Experiments.OnboardingRedesignHomeScreen, + OnboardingRedesignHomeScreenProperties.ExploreChainId, + Chain.Ethereum, + (x): x is Chain => Object.values(Chain).includes(x as Chain), + ) + const tokenAddresses = useExperimentValue( + Experiments.OnboardingRedesignHomeScreen, + OnboardingRedesignHomeScreenProperties.ExploreTokenAddresses, + [] as string[], + (x): x is string[] => Array.isArray(x) && x.every((val) => typeof val === 'string'), + ) + const recommendedTokens: ContractInput[] = useMemo( + () => tokenAddresses.map((address) => ({ chain: chainId, address })), + [chainId, tokenAddresses], + ) + + const { onContentSizeChange } = useAdaptiveFooter(containerProps?.contentContainerStyle) + + const { data } = useHomeScreenTokensQuery({ variables: { contracts: recommendedTokens, chain: chainId } }) + const tokenDataList = useMemo( + () => + [data?.eth, ...(data?.tokens ?? [])] + ?.map((token) => gqlTokenToTokenItemData(token)) + .filter((tokenItemData): tokenItemData is TokenItemData => !!tokenItemData), + [data], + ) + + // Used because fiat currency causes price layout width to change but does not change token data + useEffect(() => { + setMaxTokenPriceWrapperWidth(0) + }, [appFiatCurrency]) + + const onTokenLayout = useCallback((layout: LayoutRectangle) => { + setMaxTokenPriceWrapperWidth((prev) => Math.max(prev, layout.width)) + }, []) + + const renderToken = useCallback( + ({ item, index }: { item: TokenItemData; index: number }) => { + const gradientYStart = -index + const gradientYEnd = tokenDataList.length - index + + return ( + + + + + } + priceWrapperProps={{ minWidth: maxTokenPriceWrapperWidth }} + tokenItemData={item} + onPriceWrapperLayout={onTokenLayout} + /> + + ) + }, + [tokenDataList.length, maxTokenPriceWrapperWidth, onTokenLayout], + ) + + const refreshControl = useMemo(() => { + return ( + + ) + }, [refreshing, headerHeight, onRefresh, colors.neutral3, insets.top]) + + return ( + + >} + ListFooterComponent={FooterElement} + data={tokenDataList} + estimatedItemSize={ESTIMATED_ITEM_SIZE} + initialNumToRender={20} + maxToRenderPerBatch={20} + refreshControl={refreshControl} + refreshing={refreshing} + renderItem={renderToken} + showsVerticalScrollIndicator={false} + updateCellsBatchingPeriod={10} + onContentSizeChange={onContentSizeChange} + onRefresh={onRefresh} + onScroll={scrollHandler} + {...containerProps} + /> + + ) + }), +) + +function FooterElement(): JSX.Element { + const { t } = useTranslation() + + return ( + + + {t('home.explore.footer')} + + + + ) +} + +function gqlTokenToTokenItemData( + token: Maybe[0]>>, +): TokenItemData | null { + if (!token || !token.project) { + return null + } + + const { symbol, address, chain, project } = token + const { logoUrl, markets, name } = project + const tokenProjectMarket = markets?.[0] + + const chainId = fromGraphQLChain(chain) + + if (!chainId || !name || !symbol || !logoUrl) { + return null + } + + return { + chainId, + address: address ?? null, + name, + symbol, + logoUrl, + price: tokenProjectMarket?.price?.value, + pricePercentChange24h: tokenProjectMarket?.pricePercentChange24h?.value, + } satisfies TokenItemData +} diff --git a/apps/mobile/src/components/home/TokensTab.tsx b/apps/mobile/src/components/home/TokensTab.tsx index 16a68981bc4..97793200c6e 100644 --- a/apps/mobile/src/components/home/TokensTab.tsx +++ b/apps/mobile/src/components/home/TokensTab.tsx @@ -12,8 +12,6 @@ import { NoTokens } from 'ui/src/components/icons' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { GQLQueries } from 'uniswap/src/data/graphql/uniswap-data-api/queries' import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { CurrencyId } from 'uniswap/src/types/currency' import { MobileScreens } from 'uniswap/src/types/screens/mobile' @@ -44,8 +42,7 @@ export const TokensTab = memo( const dispatch = useDispatch() const tokenDetailsNavigation = useTokenDetailsNavigation() const startProfilerTimer = useStartProfiler() - const cexTransferEnabled = useFeatureFlag(FeatureFlags.CexTransfers) - const cexTransferProviders = useCexTransferProviders(cexTransferEnabled) + const cexTransferProviders = useCexTransferProviders() const onPressToken = useCallback( (currencyId: CurrencyId): void => { diff --git a/apps/mobile/src/components/home/introCards/IntroCard.test.tsx b/apps/mobile/src/components/home/introCards/IntroCard.test.tsx new file mode 100644 index 00000000000..57876f72ccf --- /dev/null +++ b/apps/mobile/src/components/home/introCards/IntroCard.test.tsx @@ -0,0 +1,19 @@ +import { IntroCard, IntroCardProps } from 'src/components/home/introCards/IntroCard' +import { render, screen } from 'src/test/test-utils' +import { Wallet } from 'ui/src/components/icons' + +describe(IntroCard, () => { + it('should render the passed values', () => { + const props = { + Icon: Wallet, + title: 'Test title', + description: 'Test description', + headerActionString: 'Test header action', + } satisfies IntroCardProps + + render() + expect(screen.findByText(props.title)).toBeTruthy() + expect(screen.findByText(props.description)).toBeTruthy() + expect(screen.findByText(props.headerActionString)).toBeTruthy() + }) +}) diff --git a/apps/mobile/src/components/home/introCards/IntroCard.tsx b/apps/mobile/src/components/home/introCards/IntroCard.tsx new file mode 100644 index 00000000000..12a6ec74a71 --- /dev/null +++ b/apps/mobile/src/components/home/introCards/IntroCard.tsx @@ -0,0 +1,75 @@ +import { Flex, GeneratedIcon, IconProps, Text, ViewProps } from 'ui/src' + +type HeaderActionDisplayType = 'text' | 'button' + +export type IntroCardProps = { + Icon: GeneratedIcon + iconProps?: IconProps + iconContainerProps?: ViewProps + title: string + description: string + headerActionString?: string + headerActionType?: HeaderActionDisplayType + + onPress?: () => void +} + +export function IntroCard({ + Icon, + iconProps, + iconContainerProps, + title, + description, + headerActionString, + headerActionType = 'text', + onPress, +}: IntroCardProps): JSX.Element { + return ( + + + + + + + + + {title} + + {headerActionString && ( + <> + {headerActionType === 'text' && ( + + {headerActionString} + + )} + {headerActionType === 'button' && ( + + + {headerActionString} + + + )} + + )} + + + {description} + + + + ) +} diff --git a/apps/mobile/src/components/home/introCards/IntroCardStack.tsx b/apps/mobile/src/components/home/introCards/IntroCardStack.tsx new file mode 100644 index 00000000000..43666f9a2a6 --- /dev/null +++ b/apps/mobile/src/components/home/introCards/IntroCardStack.tsx @@ -0,0 +1,9 @@ +import { IntroCard, IntroCardProps } from 'src/components/home/introCards/IntroCard' +import { SwipeableCardStack } from 'ui/src/components/swipeablecards/SwipeableCardStack' + +type IntroCardStackProps = { + cards: IntroCardProps[] +} +export function IntroCardStack({ cards }: IntroCardStackProps): JSX.Element { + return } /> +} diff --git a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx new file mode 100644 index 00000000000..8975fb9809e --- /dev/null +++ b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx @@ -0,0 +1,60 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { useMemo } from 'react' +import { useDispatch } from 'react-redux' +import { IntroCardProps } from 'src/components/home/introCards/IntroCard' +import { IntroCardStack } from 'src/components/home/introCards/IntroCardStack' +import { openModal } from 'src/features/modals/modalSlice' +import { Buy, UniswapLogo } from 'ui/src/components/icons' +import { Experiments, OnboardingRedesignRecoveryBackupProperties } from 'uniswap/src/features/gating/experiments' +import { useExperimentValue } from 'uniswap/src/features/gating/hooks' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useTranslation } from 'uniswap/src/i18n' + +export function OnboardingIntroCardStack(): JSX.Element { + const { t } = useTranslation() + const dispatch = useDispatch() + + const redesignRecoveryBackupEnabled = useExperimentValue( + Experiments.OnboardingRedesignRecoveryBackup, + OnboardingRedesignRecoveryBackupProperties.Enabled, + false, + ) + + const cards: IntroCardProps[] = useMemo( + () => [ + ...(redesignRecoveryBackupEnabled + ? [ + { + Icon: UniswapLogo, + iconProps: { + color: '$accent1', + }, + iconContainerProps: { + backgroundColor: '$accent2', + borderRadius: '$rounded12', + }, + title: t('onboarding.home.intro.welcome.title'), + description: t('onboarding.home.intro.welcome.description'), + headerActionString: t('common.action.swipe'), + } satisfies IntroCardProps, + ] + : []), + { + Icon: Buy, + title: t('onboarding.home.intro.fund.title'), + description: t('onboarding.home.intro.fund.description'), + headerActionString: t('common.action.go'), + headerActionType: 'button', + onPress: (): void => { + dispatch(openModal({ name: ModalName.FiatOnRampAggregator })) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.OnboardingIntroCardFundWallet, + }) + }, + }, + ], + [dispatch, redesignRecoveryBackupEnabled, t], + ) + return +} diff --git a/apps/mobile/src/components/layout/TabHelpers.tsx b/apps/mobile/src/components/layout/TabHelpers.tsx index e441424ffb4..06238eb123d 100644 --- a/apps/mobile/src/components/layout/TabHelpers.tsx +++ b/apps/mobile/src/components/layout/TabHelpers.tsx @@ -14,6 +14,7 @@ import Animated, { SharedValue } from 'react-native-reanimated' import { Route } from 'react-native-tab-view' import { Flex, Text } from 'ui/src' import { colorsLight, spacing } from 'ui/src/theme' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { PendingNotificationBadge } from 'wallet/src/features/notifications/components/PendingNotificationBadge' export const TAB_VIEW_SCROLL_THROTTLE = 16 @@ -85,7 +86,7 @@ export type TabProps = { refreshing?: boolean onRefresh?: () => void headerHeight?: number - testID?: string + testID?: TestIDType } export type TabContentProps = Partial> & { @@ -97,23 +98,39 @@ export type TabContentProps = Partial> & { scrollEventThrottle?: number } +export type TabLabelProps = { + route: Route + focused: boolean + isExternalProfile?: boolean + textStyleType?: 'primary' | 'secondary' + enableNotificationBadge?: boolean +} export const TabLabel = ({ route, focused, isExternalProfile, -}: { - route: Route - focused: boolean - isExternalProfile?: boolean -}): JSX.Element => { + textStyleType = 'primary', + enableNotificationBadge, +}: TabLabelProps): JSX.Element => { return ( - + {route.title} {/* Streamline UI by hiding the Activity tab spinner when focused and showing it only on the specific pending transactions. */} - {route.title === 'Activity' && !isExternalProfile && !focused ? : null} + {enableNotificationBadge && !isExternalProfile && !focused ? : null} ) } diff --git a/apps/mobile/src/components/text/LongMarkdownText.test.tsx b/apps/mobile/src/components/text/LongMarkdownText.test.tsx index 91b68e5d156..3224990d181 100644 --- a/apps/mobile/src/components/text/LongMarkdownText.test.tsx +++ b/apps/mobile/src/components/text/LongMarkdownText.test.tsx @@ -4,6 +4,7 @@ import { ReactTestInstance } from 'react-test-renderer' import { LongMarkdownText } from 'src/components/text/LongMarkdownText' import { fireEvent, render, within } from 'src/test/test-utils' import { fonts } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' const TEXT_VARIANT = 'body2' const LINE_HEIGHT = fonts[TEXT_VARIANT].lineHeight @@ -75,7 +76,7 @@ describe(LongMarkdownText, () => { const tree = renderMarkdown(SHORT_TEXT) measureMarkdown(tree, 1) // Assume Short text is one line - const readMoreButton = tree.queryByTestId('read-more-button') + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) expect(readMoreButton).toBeNull() }) @@ -98,7 +99,7 @@ describe(LongMarkdownText, () => { measureMarkdown(tree, 5) // Assume Some very long text is five lines - const readMoreButton = tree.queryByTestId('read-more-button') + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) expect(readMoreButton).toBeTruthy() expect(within(readMoreButton!).getByText('Read more')).toBeTruthy() @@ -111,7 +112,7 @@ describe(LongMarkdownText, () => { measureMarkdown(tree, 5) // Assume Some very long text is five lines - const readMoreButton = tree.getByTestId('read-more-button') + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) fireEvent.press(readMoreButton) expect(MockedMarkdown.mock.lastCall[0]).toEqual( @@ -124,7 +125,7 @@ describe(LongMarkdownText, () => { measureMarkdown(tree, 5) // Assume Some very long text is five lines - const readMoreButton = tree.getByTestId('read-more-button') + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) fireEvent.press(readMoreButton) expect(readMoreButton).toBeTruthy() @@ -138,7 +139,7 @@ describe(LongMarkdownText, () => { measureMarkdown(tree, 5) // Assume Some very long text is five lines - const readMoreButton = tree.getByTestId('read-more-button') + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) fireEvent.press(readMoreButton) // expand expect(MockedMarkdown.mock.lastCall[0]).toEqual( diff --git a/apps/mobile/src/components/text/LongMarkdownText.tsx b/apps/mobile/src/components/text/LongMarkdownText.tsx index 7905472efd0..67e437525cd 100644 --- a/apps/mobile/src/components/text/LongMarkdownText.tsx +++ b/apps/mobile/src/components/text/LongMarkdownText.tsx @@ -4,6 +4,7 @@ import { LayoutChangeEvent } from 'react-native' import Markdown, { MarkdownProps } from 'react-native-markdown-display' import { Flex, SpaceTokens, Text, useSporeColors } from 'ui/src' import { fonts } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { openUri } from 'uniswap/src/utils/linking' type LongMarkdownTextProps = { @@ -116,7 +117,7 @@ export function LongMarkdownText(props: LongMarkdownTextProps): JSX.Element { my="$none" py="$none" style={{ color: readMoreOrLessColor }} - testID="read-more-button" + testID={TestID.ReadMoreButton} variant="buttonLabel3" onPress={toggleExpanded} > diff --git a/apps/mobile/src/components/text/LongText.test.tsx b/apps/mobile/src/components/text/LongText.test.tsx index 4ace94ece88..2f30524fb18 100644 --- a/apps/mobile/src/components/text/LongText.test.tsx +++ b/apps/mobile/src/components/text/LongText.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { ReactTestInstance } from 'react-test-renderer' import { LongText } from 'src/components/text/LongText' import { fireEvent, render, within } from 'src/test/test-utils' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' const SHORT_TEXT = 'Short text' const LONG_TEXT = 'Some very long text' @@ -42,7 +43,7 @@ describe(LongText, () => { const tree = render() fireTextLayoutEvent(tree.getByText(SHORT_TEXT), 1) // Assume Short text is one line - const readMoreButton = tree.queryByTestId('read-more-button') + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) expect(readMoreButton).toBeNull() }) @@ -63,7 +64,7 @@ describe(LongText, () => { const tree = render() fireTextLayoutEvent(tree.getByText(LONG_TEXT), 5) // Assume Some very long text is five lines - const readMoreButton = tree.queryByTestId('read-more-button') + const readMoreButton = tree.queryByTestId(TestID.ReadMoreButton) expect(readMoreButton).toBeTruthy() expect(within(readMoreButton!).getByText('Read more')).toBeTruthy() @@ -79,7 +80,7 @@ describe(LongText, () => { expect(textInstance.props.numberOfLines).toBe(3) - const readMoreButton = tree.getByTestId('read-more-button') + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) fireEvent.press(readMoreButton) expect(textInstance.props.numberOfLines).toBeUndefined() @@ -89,7 +90,7 @@ describe(LongText, () => { const tree = render() fireTextLayoutEvent(tree.getByText(LONG_TEXT), 5) // Assume Some very long text is five lines - const readMoreButton = tree.getByTestId('read-more-button') + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) fireEvent.press(readMoreButton) expect(within(readMoreButton).getByText('Read less')).toBeTruthy() @@ -100,7 +101,7 @@ describe(LongText, () => { const tree = render() fireTextLayoutEvent(tree.getByText(LONG_TEXT), 5) // Assume Some very long text is five lines - const readMoreButton = tree.getByTestId('read-more-button') + const readMoreButton = tree.getByTestId(TestID.ReadMoreButton) fireEvent.press(readMoreButton) // expand expect(tree.getByText(LONG_TEXT).props.numberOfLines).toBeUndefined() diff --git a/apps/mobile/src/components/text/LongText.tsx b/apps/mobile/src/components/text/LongText.tsx index 77917601f2f..52bde0e26a6 100644 --- a/apps/mobile/src/components/text/LongText.tsx +++ b/apps/mobile/src/components/text/LongText.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { NativeSyntheticEvent, TextLayoutEventData } from 'react-native' import { Flex, SpaceTokens, Text, useSporeColors } from 'ui/src' import { fonts } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' type LongTextProps = { initialDisplayedLines?: number @@ -64,7 +65,7 @@ export function LongText(props: LongTextProps): JSX.Element { my="$none" py="$none" style={{ color: readMoreOrLessColor }} - testID="read-more-button" + testID={TestID.ReadMoreButton} variant="buttonLabel3" onPress={(): void => setExpanded(!expanded)} > diff --git a/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx b/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx index bacaa3e5156..b3fef6a9d43 100644 --- a/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx +++ b/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx @@ -1,7 +1,7 @@ import { useNavigation } from '@react-navigation/native' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ActivityIndicator, EmitterSubscription, Keyboard } from 'react-native' +import { ActivityIndicator, Keyboard } from 'react-native' import { getUniqueId } from 'react-native-device-info' import { useDispatch } from 'react-redux' import { Button, Flex, Text, useSporeColors } from 'ui/src' @@ -9,13 +9,13 @@ import { AlertTriangle } from 'ui/src/components/icons' import { fonts, spacing } from 'ui/src/theme' import { TextInput } from 'uniswap/src/components/input/TextInput' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { useBottomSheetSafeKeyboard } from 'uniswap/src/components/modals/useBottomSheetSafeKeyboard' import { ModalName, UnitagEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { useUnitagUpdater } from 'uniswap/src/features/unitags/context' import { UnitagErrorCodes } from 'uniswap/src/features/unitags/types' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { logger } from 'utilities/src/logger/logger' -import { isIOS } from 'utilities/src/platform' import { useAsyncData } from 'utilities/src/react/hooks' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' @@ -44,7 +44,6 @@ export function ChangeUnitagModal({ const signerManager = useWalletSigners() const [newUnitag, setNewUnitag] = useState(unitag) - const [keyboardHeight, setKeyboardHeight] = useState(0) const [showConfirmModal, setShowConfirmModal] = useState(false) const [isCheckingUnitag, setIsCheckingUnitag] = useState(false) const [isChangeResponseLoading, setIsChangeResponseLoading] = useState(false) @@ -56,6 +55,7 @@ export function ChangeUnitagModal({ ) const { errorCode } = useCanAddressClaimUnitag(address, true) const { triggerRefetchUnitags } = useUnitagUpdater() + const { keyboardHeight } = useBottomSheetSafeKeyboard() const isUnitagEdited = unitag !== newUnitag const isUnitagInvalid = newUnitag === unitagToCheck && !!canClaimUnitagNameError && !loadingUnitagErrorCheck @@ -151,36 +151,6 @@ export function ChangeUnitagModal({ } } - // This useEffect makes KeyboardAvoidingView work when inside a BottomSheetModal - // Dynamically add bottom padding equal to keyboard height so that elements have room to shift up - useEffect(() => { - let showSubscription: EmitterSubscription - let hideSubscription: EmitterSubscription - - if (isIOS) { - // Using keyboardWillShow makes it feel more responsive, but only available on iOS - showSubscription = Keyboard.addListener('keyboardWillShow', (e) => { - setKeyboardHeight(e.endCoordinates.height) - }) - hideSubscription = Keyboard.addListener('keyboardWillHide', () => { - setKeyboardHeight(0) - }) - } else { - // keyboardDidShow only emits after the keyboard has fully appeared - showSubscription = Keyboard.addListener('keyboardDidShow', (e) => { - setKeyboardHeight(e.endCoordinates.height) - }) - hideSubscription = Keyboard.addListener('keyboardDidHide', () => { - setKeyboardHeight(0) - }) - } - - return () => { - showSubscription.remove() - hideSubscription.remove() - } - }, []) - // When useUnitagError completes loading, if unitag is valid then continue to speedbump useEffect(() => { if (isCheckingUnitag && !!unitagToCheck && !loadingUnitagErrorCheck) { diff --git a/apps/mobile/src/components/unitags/UnitagBanner.tsx b/apps/mobile/src/components/unitags/UnitagBanner.tsx index cc5efe4e0bb..9021d51dd18 100644 --- a/apps/mobile/src/components/unitags/UnitagBanner.tsx +++ b/apps/mobile/src/components/unitags/UnitagBanner.tsx @@ -1,7 +1,7 @@ import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { Keyboard } from 'react-native' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { openModal } from 'src/features/modals/modalSlice' import { Flex, Image, Text, TouchableArea, TouchableAreaProps, useIsDarkMode, useIsShortMobileDevice } from 'ui/src' @@ -15,7 +15,6 @@ import { MobileScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' import { selectHasCompletedUnitagsIntroModal } from 'wallet/src/features/behaviorHistory/selectors' import { setHasSkippedUnitagPrompt } from 'wallet/src/features/behaviorHistory/slice' import { UNITAG_SUFFIX_NO_LEADING_DOT } from 'wallet/src/features/unitags/constants' -import { useAppSelector } from 'wallet/src/state' const IMAGE_ASPECT_RATIO = 0.42 const IMAGE_SCREEN_WIDTH_PROPORTION = 0.18 @@ -34,7 +33,7 @@ export function UnitagBanner({ const { t } = useTranslation() const { fullWidth } = useDeviceDimensions() const isDarkMode = useIsDarkMode() - const hasCompletedUnitagsIntroModal = useAppSelector(selectHasCompletedUnitagsIntroModal) + const hasCompletedUnitagsIntroModal = useSelector(selectHasCompletedUnitagsIntroModal) const isShortDevice = useIsShortMobileDevice() const imageWidth = compact diff --git a/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx b/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx index c3e2def592b..e0677fa9993 100644 --- a/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx +++ b/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx @@ -2,8 +2,7 @@ import { SharedEventName } from '@uniswap/analytics-events' import React from 'react' import { useTranslation } from 'react-i18next' import 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' @@ -22,7 +21,7 @@ export function UnitagsIntroModal(): JSX.Element { const { t } = useTranslation() const isDarkMode = useIsDarkMode() const appDispatch = useDispatch() - const modalState = useAppSelector(selectModalState(ModalName.UnitagsIntro)).initialState + const modalState = useSelector(selectModalState(ModalName.UnitagsIntro)).initialState const address = modalState?.address const entryPoint = modalState?.entryPoint diff --git a/apps/mobile/src/features/CloudBackup/hooks.ts b/apps/mobile/src/features/CloudBackup/hooks.ts index 1a73c9a3bf0..cbf1f67e07e 100644 --- a/apps/mobile/src/features/CloudBackup/hooks.ts +++ b/apps/mobile/src/features/CloudBackup/hooks.ts @@ -1,9 +1,9 @@ -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { selectCloudBackups } from 'src/features/CloudBackup/selectors' import { CloudStorageMnemonicBackup } from 'src/features/CloudBackup/types' export function useCloudBackups(mnemonicId?: string): CloudStorageMnemonicBackup[] { - const backups = useAppSelector(selectCloudBackups) + const backups = useSelector(selectCloudBackups) if (mnemonicId) { return backups.filter((b) => b.mnemonicId === mnemonicId) } diff --git a/apps/mobile/src/features/appRating/saga.ts b/apps/mobile/src/features/appRating/saga.ts index 3bd4a1e8de0..5436f275b18 100644 --- a/apps/mobile/src/features/appRating/saga.ts +++ b/apps/mobile/src/features/appRating/saga.ts @@ -3,20 +3,17 @@ import { Alert } from 'react-native' import { APP_FEEDBACK_LINK } from 'src/constants/urls' import { hasConsecutiveRecentSwapsSelector } from 'src/features/appRating/selectors' import { call, delay, put, select, takeLatest } from 'typed-redux-saga' -import { FeatureFlags, WALLET_FEATURE_FLAG_NAMES } from 'uniswap/src/features/gating/flags' -import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import i18n from 'uniswap/src/i18n/i18n' import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' -import { isAndroid } from 'utilities/src/platform' import { ONE_DAY_MS, ONE_SECOND_MS } from 'utilities/src/time/time' import { finalizeTransaction } from 'wallet/src/features/transactions/slice' import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' import { setAppRating } from 'wallet/src/features/wallet/slice' -import { appSelect } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' // at most once per reminder period (120 days) const MIN_PROMPT_REMINDER_MS = 120 * ONE_DAY_MS @@ -28,12 +25,6 @@ const SWAP_FINALIZED_PROMPT_DELAY_MS = 3 * ONE_SECOND_MS export function* appRatingWatcherSaga() { function* processFinalizedTx(action: ReturnType) { // count successful swaps - - const shouldSkip = yield* call(shouldSkipRatingPrompt) - if (shouldSkip) { - return - } - if (action.payload.typeInfo.type === TransactionType.Swap && action.payload.status === TransactionStatus.Success) { yield* delay(SWAP_FINALIZED_PROMPT_DELAY_MS) yield* call(maybeRequestAppRating) @@ -56,15 +47,15 @@ function* maybeRequestAppRating() { } // Conditions - const appRatingProvidedMs = yield* appSelect((state) => state.wallet.appRatingProvidedMs) + const appRatingProvidedMs = yield* select((state: RootState) => state.wallet.appRatingProvidedMs) if (appRatingProvidedMs) { return } // avoids prompting again - const appRatingPromptedMs = yield* appSelect((state) => state.wallet.appRatingPromptedMs) - const appRatingFeedbackProvidedMs = yield* appSelect((state) => state.wallet.appRatingFeedbackProvidedMs) + const appRatingPromptedMs = yield* select((state: RootState) => state.wallet.appRatingPromptedMs) + const appRatingFeedbackProvidedMs = yield* select((state: RootState) => state.wallet.appRatingFeedbackProvidedMs) - const consecutiveSwapsCondition = yield* appSelect(hasConsecutiveRecentSwapsSelector) + const consecutiveSwapsCondition = yield* select(hasConsecutiveRecentSwapsSelector) // prompt if enough time has passed since last prompt or last feedback provided const reminderCondition = @@ -83,7 +74,7 @@ function* maybeRequestAppRating() { return } - logger.info('appRating', 'maybeRequestAppRating', 'Requesting app rating', { + logger.debug('appRating', 'maybeRequestAppRating', 'Requesting app rating', { lastPrompt: appRatingPromptedMs, lastProvided: appRatingProvidedMs, consecutiveSwapsCondition, @@ -93,8 +84,6 @@ function* maybeRequestAppRating() { const shouldShowNativeReviewModal = yield* call(openRatingOptionsAlert) if (shouldShowNativeReviewModal) { - yield* call(openNativeReviewModal) - // expo-review does not return whether a rating was actually provided. // assume it was and mark rating as provided. yield* put(setAppRating({ ratingProvided: true })) @@ -192,10 +181,3 @@ async function openNativeReviewModal() { logger.error(e, { tags: { file: 'appRating/saga', function: 'useAppRating' } }) } } - -function shouldSkipRatingPrompt(): boolean { - const isPlaystoreRatingPromptEnabled = Statsig.checkGate( - WALLET_FEATURE_FLAG_NAMES.get(FeatureFlags.PlaystoreAppRating) ?? '', - ) - return isAndroid && !isPlaystoreRatingPromptEnabled -} diff --git a/apps/mobile/src/features/biometrics/hooks.tsx b/apps/mobile/src/features/biometrics/hooks.tsx index 04dbb5b3de1..06e38cc5547 100644 --- a/apps/mobile/src/features/biometrics/hooks.tsx +++ b/apps/mobile/src/features/biometrics/hooks.tsx @@ -4,7 +4,8 @@ import { isEnrolledAsync, supportedAuthenticationTypesAsync, } from 'expo-local-authentication' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' +import { MobileState } from 'src/app/reducer' import { BiometricAuthenticationStatus, tryLocalAuthenticate } from 'src/features/biometrics' import { useBiometricContext } from 'src/features/biometrics/context' import { BiometricSettingsState } from 'src/features/biometrics/slice' @@ -115,7 +116,7 @@ export function useOsBiometricAuthEnabled(): boolean | undefined { } export function useBiometricAppSettings(): BiometricSettingsState { - const biometricSettings = useAppSelector((state) => state.biometricSettings) + const biometricSettings = useSelector((state: MobileState) => state.biometricSettings) return biometricSettings } diff --git a/apps/mobile/src/features/dataApi/balances.ts b/apps/mobile/src/features/dataApi/balances.ts index 3314ef78e94..46a31c43a69 100644 --- a/apps/mobile/src/features/dataApi/balances.ts +++ b/apps/mobile/src/features/dataApi/balances.ts @@ -1,15 +1,18 @@ import { useMemo } from 'react' +import { usePortfolioBalances } from 'uniswap/src/features/dataApi/balances' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { CurrencyId } from 'uniswap/src/types/currency' -import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' /** Helper hook to retrieve balances for a set of currencies for the active account. */ export function useBalances(currencies: CurrencyId[] | undefined): PortfolioBalance[] | null { const address = useActiveAccountAddressWithThrow() + const valueModifiers = usePortfolioValueModifiers(address) ?? [] const { data: balances } = usePortfolioBalances({ address, fetchPolicy: 'cache-and-network', + valueModifiers, }) return useMemo(() => { diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts index bd95777755b..2bcb679919a 100644 --- a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts @@ -2,7 +2,6 @@ import { createAction } from '@reduxjs/toolkit' import { parseUri } from '@walletconnect/utils' import { Alert } from 'react-native' import { URL } from 'react-native-url-polyfill' -import { appSelect } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { getScantasticQueryParams, parseScantasticParams } from 'src/components/Requests/ScanSheet/util' import { @@ -17,7 +16,7 @@ import { closeAllModals, openModal } from 'src/features/modals/modalSlice' import { waitForWcWeb3WalletIsReady } from 'src/features/walletConnect/saga' import { pairWithWalletConnectURI } from 'src/features/walletConnect/utils' import { setDidOpenFromDeepLink } from 'src/features/walletConnect/walletConnectSlice' -import { call, put, takeLatest } from 'typed-redux-saga' +import { call, put, select, takeLatest } from 'typed-redux-saga' import { UNISWAP_WEB_HOSTNAME } from 'uniswap/src/constants/urls' import { fromUniswapWebAppLink } from 'uniswap/src/features/chains/utils' import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' @@ -160,8 +159,8 @@ export function* handleUniswapAppDeepLink(path: string, url: string, linkSource: if (!accountAddress) { return } - const accounts = yield* appSelect(selectAccounts) - const activeAccountAddress = yield* appSelect(selectActiveAccountAddress) + const accounts = yield* select(selectAccounts) + const activeAccountAddress = yield* select(selectActiveAccountAddress) if (accountAddress === activeAccountAddress) { return } @@ -199,7 +198,7 @@ export function* handleDeepLink(action: ReturnType) { const userAddress = url.searchParams.get('userAddress') const fiatOnRamp = url.searchParams.get('fiatOnRamp') === 'true' - const activeAccount = yield* appSelect(selectActiveAccount) + const activeAccount = yield* select(selectActiveAccount) if (!activeAccount) { // For app.uniswap.org links it should open a browser with the link // instead of handling it inside the app @@ -354,7 +353,7 @@ export function* parseAndValidateUserAddress(userAddress: string | null) { throw new Error('No `userAddress` provided') } - const userAccounts = yield* appSelect(selectAccounts) + const userAccounts = yield* select(selectAccounts) const matchingAccount = Object.values(userAccounts).find( (account) => account.address.toLowerCase() === userAddress.toLowerCase(), ) diff --git a/apps/mobile/src/features/explore/utils.ts b/apps/mobile/src/features/explore/utils.ts index 3ea4d5fa6ad..798b2102424 100644 --- a/apps/mobile/src/features/explore/utils.ts +++ b/apps/mobile/src/features/explore/utils.ts @@ -1,4 +1,4 @@ -import { TokenItemData } from 'src/components/explore/TokenItem' +import { TokenItemData } from 'src/components/explore/TokenItemData' import { AppTFunction } from 'ui/src/i18n/types' import { TokenSortableField } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { ClientTokensOrderBy, TokenMetadataDisplayType, TokensOrderBy } from 'wallet/src/features/wallet/types' diff --git a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx index 4aa3e5d963c..cf23e1d79a0 100644 --- a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx +++ b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx @@ -1,10 +1,9 @@ -import React, { memo, useCallback, useMemo } from 'react' +import React, { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { StatusBar, StyleSheet } from 'react-native' import { FadeIn } from 'react-native-reanimated' import Svg, { ClipPath, Defs, RadialGradient, Rect, Stop } from 'react-native-svg' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { BackButton } from 'src/components/buttons/BackButton' import { Favorite } from 'src/components/icons/Favorite' import { LongText } from 'src/components/text/LongText' @@ -31,6 +30,7 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { openUri } from 'uniswap/src/utils/linking' +import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { useENSDescription, useENSName, useENSTwitterUsername } from 'wallet/src/features/ens/api' import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' @@ -55,7 +55,8 @@ export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHea const colors = useSporeColors() const dispatch = useDispatch() const isDarkMode = useIsDarkMode() - const isFavorited = useAppSelector(selectWatchedAddressSet).has(address) + const isFavorited = useSelector(selectWatchedAddressSet).has(address) + const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) const displayName = useDisplayName(address, { includeUnitagSuffix: true }) @@ -103,7 +104,7 @@ export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHea } }, [address]) - const onPressSend = useCallback(() => { + const openSendModal = useCallback(() => { dispatch( openModal({ name: ModalName.Send, @@ -112,6 +113,10 @@ export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHea ) }, [dispatch, initialSendState]) + const onPressSend = useCallback(async () => { + setCheckSpeedBumps(true) + }, []) + const onPressTwitter = useCallback(async () => { if (twitter) { await openUri(`https://twitter.com/${twitter}`) @@ -249,6 +254,12 @@ export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHea + ) }) diff --git a/apps/mobile/src/features/favorites/hooks.ts b/apps/mobile/src/features/favorites/hooks.ts index 84135cebcb7..6ef00b3b2c6 100644 --- a/apps/mobile/src/features/favorites/hooks.ts +++ b/apps/mobile/src/features/favorites/hooks.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' +import { MobileState } from 'src/app/reducer' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { CurrencyId } from 'uniswap/src/types/currency' @@ -36,7 +36,7 @@ export function useToggleFavoriteCallback(id: CurrencyId, isFavoriteToken: boole export function useToggleWatchedWalletCallback(address: Address): () => void { const dispatch = useDispatch() - const isFavoriteWallet = useAppSelector(selectWatchedAddressSet).has(address) + const isFavoriteWallet = useSelector(selectWatchedAddressSet).has(address) const displayName = useDisplayName(address) return useCallback(() => { @@ -55,5 +55,5 @@ export function useToggleWatchedWalletCallback(address: Address): () => void { export function useSelectHasTokenFavorited(currencyId: string): boolean { const selectHasTokenFavorited = useMemo(makeSelectHasTokenFavorited, []) - return useAppSelector((state) => selectHasTokenFavorited(state, currencyId)) + return useSelector((state: MobileState) => selectHasTokenFavorited(state, currencyId)) } diff --git a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx index 9b6144524ca..5f49703c89c 100644 --- a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx +++ b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx @@ -1,5 +1,4 @@ -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' import { ExchangeTransferConnecting } from 'src/screens/ExchangeTransferConnecting' @@ -12,7 +11,7 @@ export function ExchangeTransferModal(): JSX.Element | null { dispatch(closeModal({ name: ModalName.ExchangeTransferModal })) } - const { initialState } = useAppSelector(selectModalState(ModalName.ExchangeTransferModal)) + const { initialState } = useSelector(selectModalState(ModalName.ExchangeTransferModal)) const serviceProvider = initialState?.serviceProvider return serviceProvider ? ( diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx index eff43f7f745..b3f36bac9db 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAmountSection.tsx @@ -1,15 +1,9 @@ -import React, { useEffect } from 'react' +import { useFocusEffect } from '@react-navigation/core' +import React, { RefObject, forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { - LayoutChangeEvent, - NativeSyntheticEvent, - TextInput, - TextInputProps, - TextInputSelectionChangeEventData, -} from 'react-native' +import { NativeSyntheticEvent, TextInput, TextInputSelectionChangeEventData } from 'react-native' import { TouchableOpacity } from 'react-native-gesture-handler' import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated' -import { useFormatExactCurrencyAmount } from 'src/features/fiatOnRamp/hooks' import { ColorTokens, Flex, HapticFeedback, Text, useSporeColors } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { fonts, spacing } from 'ui/src/theme' @@ -20,6 +14,7 @@ import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { usePrevious } from 'utilities/src/react/hooks' import { DEFAULT_DELAY, useDebounce } from 'utilities/src/time/timing' import { AmountInput } from 'wallet/src/components/input/AmountInput' +import { useFormatExactCurrencyAmount } from 'wallet/src/features/fiatOnRamp/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { errorShakeAnimation } from 'wallet/src/utils/animations' import { useDynamicFontSizing } from 'wallet/src/utils/useDynamicFontSizing' @@ -45,14 +40,9 @@ function OnRampError({ errorText, color }: { errorText: string; color: ColorToke ) } -interface Props { - showNativeKeyboard: boolean - onInputPanelLayout: (event: LayoutChangeEvent) => void - inputRef: React.RefObject +interface FiatOnRampAmountSectionProps { disabled?: boolean - showSoftInputOnFocus: boolean value: string - setSelection: (selection: TextInputProps['selection']) => void errorColor: ColorTokens | undefined errorText: string | undefined currency: FiatOnRampCurrency @@ -66,77 +56,107 @@ interface Props { appFiatCurrencySupported: boolean notAvailableInThisRegion?: boolean fiatCurrencyInfo: FiatCurrencyInfo + onSelectionChange?: (start: number, end: number) => void } -export function FiatOnRampAmountSection({ - showNativeKeyboard, - onInputPanelLayout, - inputRef, - disabled, - showSoftInputOnFocus, - value, - setSelection, - errorColor, - errorText, - currency, - onEnterAmount, - onChoosePredifendAmount, - quoteAmount, - quoteCurrencyAmountReady, - selectTokenLoading, - onTokenSelectorPress, - predefinedAmountsSupported, - appFiatCurrencySupported, - notAvailableInThisRegion, - fiatCurrencyInfo, -}: Props): JSX.Element { - const { t } = useTranslation() - const { - onLayout: onInputLayout, - fontSize, - onSetFontSize, - } = useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE) - const prevErrorText = usePrevious(errorText) - - const onChangeValue = - (next: OnChangeAmount) => - (newAmount: string): void => { - onSetFontSize(newAmount) - next(newAmount) - } +export type FiatOnRampAmountSectionRef = { + textInputRef: RefObject + triggerShakeAnimation: () => void +} - const onSelectionChange = ({ - nativeEvent: { - selection: { start, end }, +export const FiatOnRampAmountSection = forwardRef( + function _FiatOnRampAmountSection( + { + disabled, + value, + onSelectionChange: selectionChange, + errorColor, + errorText, + currency, + onEnterAmount, + onChoosePredifendAmount, + quoteAmount, + quoteCurrencyAmountReady, + selectTokenLoading, + onTokenSelectorPress, + predefinedAmountsSupported, + appFiatCurrencySupported, + notAvailableInThisRegion, + fiatCurrencyInfo, }, - }: NativeSyntheticEvent): void => { - setSelection({ start, end }) - } + forwardedRef, + ): JSX.Element { + const { t } = useTranslation() + const { + onLayout: onInputLayout, + fontSize, + onSetFontSize, + } = useDynamicFontSizing(MAX_CHAR_PIXEL_WIDTH, MAX_INPUT_FONT_SIZE, MIN_INPUT_FONT_SIZE) + const prevErrorText = usePrevious(errorText) + + const inputRef = useRef(null) + + useImperativeHandle(forwardedRef, () => ({ + textInputRef: inputRef, + triggerShakeAnimation, + })) + + // This is needed to ensure that the text resizes when modified from outside the component (e.g. custom numpad) + useEffect(() => { + if (value) { + onSetFontSize(value) + // Always set font size if focused to format placeholder size, we need to pass in a non-empty string to avoid formatting crash + } else { + onSetFontSize('0') + } + }, [onSetFontSize, value]) + + const onSelectionChange = useCallback( + ({ + nativeEvent: { + selection: { start, end }, + }, + }: NativeSyntheticEvent) => selectionChange?.(start, end), + [selectionChange], + ) - const inputShakeX = useSharedValue(0) - const inputAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: inputShakeX.value }], - })) + const inputShakeX = useSharedValue(0) + const inputAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: inputShakeX.value }], + })) - useEffect(() => { - async function shake(): Promise { + const triggerShakeAnimation = useCallback(() => { inputShakeX.value = errorShakeAnimation(inputShakeX) - await HapticFeedback.impact() - } - if (errorText && prevErrorText !== errorText) { - shake().catch(() => undefined) - } - }, [errorText, inputShakeX, prevErrorText]) + }, [inputShakeX]) - // Design has asked to make it around 100ms and DEFAULT_DELAY is 200ms - const debouncedErrorText = useDebounce(errorText, DEFAULT_DELAY / 2) + useEffect(() => { + async function shake(): Promise { + triggerShakeAnimation() + await HapticFeedback.impact() + } + if (errorText && prevErrorText !== errorText) { + shake().catch(() => undefined) + } + }, [errorText, inputShakeX, prevErrorText, triggerShakeAnimation]) - const formattedAmount = useFormatExactCurrencyAmount(quoteAmount.toString(), currency.currencyInfo?.currency) + // Design has asked to make it around 100ms and DEFAULT_DELAY is 200ms + const debouncedErrorText = useDebounce(errorText, DEFAULT_DELAY / 2) - return ( - - - { + if (!isTextInputRefActuallyFocused) { + inputRef.current?.focus() + } + }, [inputRef, isTextInputRefActuallyFocused]), + ) + + return ( + + )} - + @@ -216,9 +235,9 @@ export function FiatOnRampAmountSection({ ) : null} - - ) -} + ) + }, +) // Predefined amount is only supported for certain currencies function PredefinedAmount({ diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx index 1f6ddd701e2..e7566090183 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx @@ -4,8 +4,11 @@ import React, { createContext, useContext, useState } from 'react' import { SectionListData } from 'react-native' import { getCountry } from 'react-native-localize' +import { useSelector } from 'react-redux' +import { selectModalState } from 'src/features/modals/selectModalState' import { getNativeAddress } from 'uniswap/src/constants/addresses' import { FORQuote, FiatCurrencyInfo, FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { ModalName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId } from 'uniswap/src/types/chains' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' @@ -54,14 +57,19 @@ export function FiatOnRampProvider({ children }: { children: React.ReactNode }): const [baseCurrencyInfo, setBaseCurrencyInfo] = useState() const [amount, setAmount] = useState() - // We hardcode ETH as the starting currency + const { initialState: initialModalState } = useSelector(selectModalState(ModalName.FiatOnRampAggregator)) + const prefilledCurrency = initialModalState?.prefilledCurrency + + // We hardcode ETH as the default starting currency if not specified by modal state's prefilledCurrency const ethCurrencyInfo = useCurrencyInfo( buildCurrencyId(UniverseChainId.Mainnet, getNativeAddress(UniverseChainId.Mainnet)), ) - const [quoteCurrency, setQuoteCurrency] = useState({ - currencyInfo: ethCurrencyInfo, - meldCurrencyCode: 'ETH', - }) + const [quoteCurrency, setQuoteCurrency] = useState( + prefilledCurrency ?? { + currencyInfo: ethCurrencyInfo, + meldCurrencyCode: 'ETH', + }, + ) return ( { + if (value && (liveCheck || !focused)) { + return errorMessage ? '$statusCritical' : '$statusSuccess' + } + return '$surface3' + }, [value, liveCheck, focused, errorMessage]) + return ( [ModalName.Explore]: AppModalState [ModalName.FiatCurrencySelector]: AppModalState - [ModalName.FiatOnRampAggregator]: AppModalState + [ModalName.FiatOnRampAggregator]: AppModalState [ModalName.ReceiveCryptoModal]: AppModalState [ModalName.LanguageSelector]: AppModalState [ModalName.QueuedOrderModal]: AppModalState diff --git a/apps/mobile/src/features/modals/modalSlice.ts b/apps/mobile/src/features/modals/modalSlice.ts index 239333724e4..19aa7f6464d 100644 --- a/apps/mobile/src/features/modals/modalSlice.ts +++ b/apps/mobile/src/features/modals/modalSlice.ts @@ -4,6 +4,7 @@ import { RemoveWalletModalState } from 'src/components/RemoveWallet/RemoveWallet import { ExchangeTransferModalState } from 'src/features/fiatOnRamp/ExchangeTransferModalState' import { ModalsState } from 'src/features/modals/ModalsState' import { ScantasticModalState } from 'src/features/scantastic/ScantasticModalState' +import { FiatOnRampModalState } from 'src/screens/FiatOnRampModalState' import { ReceiveCryptoModalState } from 'src/screens/ReceiveCryptoModalState' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { TransactionState } from 'uniswap/src/features/transactions/transactionState/types' @@ -35,7 +36,7 @@ type FiatCurrencySelectorParams = { type FiatOnRampAggregatorModalParams = { name: typeof ModalName.FiatOnRampAggregator - initialState?: undefined + initialState?: FiatOnRampModalState } type ReceiveCryptoModalParams = { diff --git a/apps/mobile/src/features/nfts/item/__snapshots__/traits.test.tsx.snap b/apps/mobile/src/features/nfts/item/__snapshots__/traits.test.tsx.snap index 8bee7d7c66f..bc4bbe6dd0d 100644 --- a/apps/mobile/src/features/nfts/item/__snapshots__/traits.test.tsx.snap +++ b/apps/mobile/src/features/nfts/item/__snapshots__/traits.test.tsx.snap @@ -25,7 +25,7 @@ exports[`renders trait card 1`] = ` { "color": "#7D7D7D", "fontFamily": "Basel-Medium", - "fontSize": 15, + "fontSize": 13, "fontWeight": "500", "lineHeight": 16, } diff --git a/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx b/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx index a2c932933b6..5a8233dde4c 100644 --- a/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx +++ b/apps/mobile/src/features/notifications/NotificationToastWrapper.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { ScantasticCompleteNotification } from 'src/features/notifications/ScantasticCompleteNotification' import { WCNotification } from 'src/features/notifications/WCNotification' import { SharedNotificationToastRouter } from 'wallet/src/features/notifications/components/SharedNotificationToastRouter' @@ -7,7 +7,7 @@ import { selectActiveAccountNotifications } from 'wallet/src/features/notificati import { AppNotification, AppNotificationType } from 'wallet/src/features/notifications/types' export function NotificationToastWrapper(): JSX.Element | null { - const notifications = useAppSelector(selectActiveAccountNotifications) + const notifications = useSelector(selectActiveAccountNotifications) const notification = notifications?.[0] if (!notification) { diff --git a/apps/mobile/src/features/openai/AIAssistantOverlay.tsx b/apps/mobile/src/features/openai/AIAssistantOverlay.tsx new file mode 100644 index 00000000000..6175263ae25 --- /dev/null +++ b/apps/mobile/src/features/openai/AIAssistantOverlay.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react' +import { OpenAIContext } from 'src/features/openai/OpenAIContext' +import { Flex } from 'ui/src' +import { CommentDots } from 'ui/src/components/icons' + +export function AIAssistantOverlay(): JSX.Element { + const { open } = useContext(OpenAIContext) + + return ( + <> + + + + + ) +} diff --git a/apps/mobile/src/features/openai/AIAssistantScreen.tsx b/apps/mobile/src/features/openai/AIAssistantScreen.tsx new file mode 100644 index 00000000000..c1a34a0d0bb --- /dev/null +++ b/apps/mobile/src/features/openai/AIAssistantScreen.tsx @@ -0,0 +1,113 @@ +import { useContext, useEffect, useRef, useState } from 'react' +import { ScrollView as NativeScrollView } from 'react-native' +import { Message, OpenAIContext } from 'src/features/openai/OpenAIContext' +import { Button, Flex, Input, ScrollView, SpinningLoader, Text } from 'ui/src' +import { ArrowUpCircle, UniswapLogo } from 'ui/src/components/icons' +import { fonts, spacing } from 'ui/src/theme' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { useBottomSheetSafeKeyboard } from 'uniswap/src/components/modals/useBottomSheetSafeKeyboard' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { useActiveAccountAddress, useAvatar } from 'wallet/src/features/wallet/hooks' + +export function AIAssistantScreen(): JSX.Element { + const scrollRef = useRef(null) + const inputRef = useRef(null) + + const { messages, sendMessage, isOpen, isLoading, close } = useContext(OpenAIContext) + const [input, setInput] = useState('') + const [optimisticMessage, setOptimisticMessage] = useState() + const address = useActiveAccountAddress() || undefined + const { avatar } = useAvatar(address) + + const { keyboardHeight } = useBottomSheetSafeKeyboard() + + useEffect(() => { + setOptimisticMessage(undefined) + }, [messages]) + + const handleSendMessage = (): void => { + setOptimisticMessage({ text: input, role: 'user', buttons: [] }) + setInput('') + inputRef.current?.clear() + sendMessage(input) + } + + return ( + <> + {isOpen && ( + + 0 ? keyboardHeight - spacing.spacing20 : '$spacing12'}> + scrollRef.current?.scrollToEnd()} + > + + {[...messages, ...(optimisticMessage ? [optimisticMessage] : [])].map((message, index) => ( + + + {message.role === 'assistant' && } + + + + {message.text} + + + + {message.role === 'user' && address && ( + + )} + + + {message.buttons.map((button, buttonIndex) => ( + + ))} + + + ))} + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + + )} + + ) +} diff --git a/apps/mobile/src/features/openai/OpenAIContext.tsx b/apps/mobile/src/features/openai/OpenAIContext.tsx new file mode 100644 index 00000000000..f9e72b49d68 --- /dev/null +++ b/apps/mobile/src/features/openai/OpenAIContext.tsx @@ -0,0 +1,419 @@ +import { useApolloClient } from '@apollo/client' +import OpenAI from 'openai' +import { createContext, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Linking } from 'react-native' +import { getUniqueId } from 'react-native-device-info' +import { useDispatch } from 'react-redux' +import { navigate } from 'src/app/navigation/rootNavigation' +import { openModal } from 'src/features/modals/modalSlice' +import { ASSISTANT_ID, openai } from 'src/features/openai/assistant' +import { FunctionName, PossibleFunctionArgs } from 'src/features/openai/functions' +import { DEFAULT_NATIVE_ADDRESS, UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { + AiTopTokensDocument, + SearchTokensDocument, + TokenDetailsScreenDocument, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { AssetType, CurrencyAsset } from 'uniswap/src/entities/assets' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { usePortfolioBalances, useTokenBalancesGroupedByVisibility } from 'uniswap/src/features/dataApi/balances' +import { ALL_GQL_CHAINS } from 'uniswap/src/features/dataApi/searchTokens' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { CurrencyField } from 'uniswap/src/types/currency' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { logger } from 'utilities/src/logger/logger' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { AppearanceSettingType, setSelectedAppearanceSettings } from 'wallet/src/features/appearance/slice' +import { WarningAction, WarningLabel, WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { useActiveAccountAddress, useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +export type OpenAIContextState = { + isOpen: boolean + isLoading: boolean + messages: Message[] + open: () => void + close: () => void + sendMessage: (message: string) => void +} + +const initialState: OpenAIContextState = { + isOpen: false, + isLoading: false, + messages: [], + open: () => {}, + close: () => {}, + sendMessage: () => {}, +} + +export const OpenAIContext = createContext(initialState) + +export type Button = { + functionName: FunctionName + text: string +} +export type Message = { + text: string + role: 'user' | 'assistant' + buttons: Button[] +} + +async function handleRunStatus( + threadId: string, + run: OpenAI.Beta.Threads.Runs.Run, + processMessages: () => void, + toolsMap: Record Promise>, +): Promise { + if (run.status === 'completed') { + processMessages() + } else if (run.status === 'requires_action') { + return await handleRequiresAction(threadId, run, processMessages, toolsMap) + } else { + logger.debug('OpenAIContext.tsx', 'handleRunStatus', `Run did not complete: ${run.id}`) + } +} + +async function handleRequiresAction( + threadId: string, + run: OpenAI.Beta.Threads.Runs.Run, + processMessages: () => void, + toolsMap: Record Promise>, +): Promise { + const toolOutputsPromises: Promise[] = + run.required_action?.submit_tool_outputs.tool_calls.map(async (tool) => { + const toolFunction = toolsMap[tool.function.name as FunctionName] + if (toolFunction) { + const args = JSON.parse(tool.function.arguments) + const output = JSON.stringify(await toolFunction(args)) + return { + tool_call_id: tool.id, + output, + } + } + + return {} + }) ?? [] + + const toolOutputs = await Promise.all(toolOutputsPromises) + + // Submit all tool outputs at once after collecting them in a list + if (toolOutputs.length > 0) { + run = await openai.beta.threads.runs.submitToolOutputsAndPoll(threadId, run.id, { + tool_outputs: toolOutputs, + }) + } + + // Check status after submitting tool outputs + return handleRunStatus(threadId, run, processMessages, toolsMap) +} + +export function OpenAIContextProvider({ children }: { children: React.ReactNode }): JSX.Element { + const featureEnabled = useFeatureFlag(FeatureFlags.OpenAIAssistant) + + return featureEnabled ? <_OpenAIContextProvider>{children} : <>{children} +} + +function _OpenAIContextProvider({ children }: { children: React.ReactNode }): JSX.Element { + const [isOpen, setIsOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [mainThread, setMainThread] = useState() + const [messages, setMessages] = useState([]) + const { navigateToSwapFlow, navigateToSend } = useWalletNavigation() + const dispatch = useDispatch() + const { t } = useTranslation() + + // Temporarily hard-coding swap warning for testing purposes, need to be replaced with hook + const [swapSwarning, _setSwapWarning] = useState({ + type: WarningLabel.InsufficientGasFunds, + severity: WarningSeverity.Medium, + action: WarningAction.DisableSubmit, + title: t('swap.warning.insufficientGas.title', { + currencySymbol: 'ETH', + }), + }) + + const activeAddress = useActiveAccountAddress() || undefined + + const signerAccount = useSignerAccounts()[0] + // We sync backup state across all accounts under the same mnemonic, so can check status with any account. + const hasCloudBackup = signerAccount?.backups?.includes(BackupType.Cloud) + const apollo = useApolloClient() + + const { data: balancesById } = usePortfolioBalances({ + address: activeAddress, + fetchPolicy: 'cache-and-network', + }) + const { shownTokens } = useTokenBalancesGroupedByVisibility({ + balancesById, + }) + + const toolsMap: Record Promise> = useMemo(() => { + return { + [FunctionName.BackupCloud]: async (): Promise => { + navigate(MobileScreens.SettingsStack, { + screen: hasCloudBackup + ? MobileScreens.SettingsCloudBackupStatus + : MobileScreens.SettingsCloudBackupPasswordCreate, + params: { address: signerAccount?.address ?? '' }, + }) + return { success: true } + }, + [FunctionName.BackupManual]: async (): Promise => { + navigate(MobileScreens.SettingsStack, { + screen: MobileScreens.SettingsViewSeedPhrase, + params: { address: signerAccount?.address ?? '', walletNeedsRestore: false }, + }) + return { success: true } + }, + [FunctionName.GetTopTokens]: async (args): Promise => { + const { chain, sortBy, pageSize } = args + const { data } = await apollo.query({ + query: AiTopTokensDocument, + variables: { chain, topTokensOrderBy: sortBy, pageSize }, + }) + + return { data } + }, + [FunctionName.GetTokenDetails]: async (args): Promise => { + const { chain, address } = args + const { data } = await apollo.query({ + query: TokenDetailsScreenDocument, + variables: { chain, address }, + }) + return { data } + }, + [FunctionName.GetWalletPortfolioBalances]: async (): Promise => { + shownTokens?.forEach((balance) => { + const chainId = toSupportedChainId(balance.currencyInfo.currency.chainId) + return { + ...balance.currencyInfo.currency, + chainName: chainId ? UNIVERSE_CHAIN_INFO[chainId].label : 'unknown', + } + }) + + return { data: shownTokens } + }, + [FunctionName.GetSwapWarning]: async (): Promise => { + return { data: swapSwarning } + }, + [FunctionName.SettingChangeAppearance]: async (args): Promise => { + const { appearanceSettingType } = args + + dispatch(setSelectedAppearanceSettings(appearanceSettingType as AppearanceSettingType)) + return { success: true } + }, + [FunctionName.SearchTokens]: async (args): Promise => { + const { text, chain } = args + const { data } = await apollo.query({ + query: SearchTokensDocument, + variables: { searchQuery: text, chains: chain ? [chain] : ALL_GQL_CHAINS }, + }) + return { data } + }, + [FunctionName.SearchRecipients]: async (): Promise => { + // Should be using getOnChainEnsFetch but needs work, temporarily using hayden + return { + data: [{ address: '0x50EC05ADe8280758E2077fcBC08D878D4aef79C3', name: 'hayden.eth' }], + } + }, + [FunctionName.StartSwap]: async (args): Promise => { + const { chainId, inputTokenAddress, outputTokenAddress, outputTokenAmount, isSwappingAll } = args + let { inputTokenAmount } = args + + if (!chainId || !inputTokenAmount || !inputTokenAddress || !outputTokenAddress) { + return { error: 'Missing required parameters' } + } + + const inputAsset = { + address: inputTokenAddress, + chainId, + type: AssetType.Currency, + } satisfies CurrencyAsset + const outputAsset = { + address: outputTokenAddress, + chainId, + type: AssetType.Currency, + } satisfies CurrencyAsset + + if (isSwappingAll) { + if (inputTokenAddress === DEFAULT_NATIVE_ADDRESS) { + inputTokenAmount -= chainId === UniverseChainId.Mainnet ? 0.005 : 0.001 + inputTokenAmount = Math.max(inputTokenAmount, 0) + } + } else { + inputTokenAmount = Math.max(inputTokenAmount - 0.00001, 0) + } + + setTimeout(() => { + navigateToSwapFlow({ + initialState: { + exactCurrencyField: inputTokenAmount ? CurrencyField.INPUT : CurrencyField.OUTPUT, + exactAmountToken: inputTokenAmount?.toString() ?? outputTokenAmount?.toString() ?? '', + [CurrencyField.INPUT]: inputAsset, + [CurrencyField.OUTPUT]: outputAsset, + }, + }) + }, 8000) + + return { success: true } + }, + [FunctionName.StartSend]: async (args): Promise => { + const { chainId, inputTokenAddress, inputTokenUSD, recipientAddress, isSwappingAll } = args + let { inputTokenAmount } = args + if (!inputTokenAddress || !chainId || !recipientAddress) { + return { error: 'Missing required parameters' } + } + + const inputAsset = { + address: inputTokenAddress, + chainId, + type: AssetType.Currency, + } satisfies CurrencyAsset + + if (isSwappingAll && inputTokenAmount !== undefined) { + if (inputTokenAddress === DEFAULT_NATIVE_ADDRESS) { + inputTokenAmount -= chainId === UniverseChainId.Mainnet ? 0.005 : 0.001 + inputTokenAmount = Math.max(inputTokenAmount, 0) + } + } + + navigateToSend({ + initialState: { + exactCurrencyField: CurrencyField.INPUT, + exactAmountToken: inputTokenAmount?.toString() ?? '', + exactAmountFiat: inputTokenUSD?.toString() ?? '', + recipient: recipientAddress, + [CurrencyField.INPUT]: inputAsset, + [CurrencyField.OUTPUT]: null, + }, + }) + return { success: true } + }, + [FunctionName.NavigateToFiatOnramp]: async (): Promise => { + dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + }), + ) + return { success: true } + }, + } + }, [ + apollo, + dispatch, + hasCloudBackup, + navigateToSend, + navigateToSwapFlow, + shownTokens, + signerAccount?.address, + swapSwarning, + ]) + + const processMessages = useCallback(async () => { + if (!mainThread) { + return + } + + setIsLoading(false) + + const messageResponse = await openai.beta.threads.messages.list(mainThread?.id) + const newMessages = messageResponse.data + .map((messageData): Message => { + const text = messageData.content.reduce((acc, curr) => { + if (curr.type === 'text') { + try { + return acc + JSON.parse(curr.text.value).message + } catch { + return acc + curr.text.value + } + } + return acc + }, '') + + const buttons = messageData.content.reduce((acc, curr) => { + if (curr.type === 'text') { + try { + const localButtons = (JSON.parse(curr.text.value).buttons as Button[]) ?? [] + acc.push(...localButtons) + } catch { + // noop + } + } + return acc + }, []) + + return { + text, + buttons, + role: messageData.role, + } + }) + .reverse() + setMessages(newMessages) + }, [mainThread]) + + useEffect(() => { + async function setup(): Promise { + const uniqueId = await getUniqueId() + const thread = await openai.beta.threads.create({ + metadata: { userId: uniqueId, username: 'ggeri' }, + }) + setMainThread(thread) + } + + setup().catch((error) => + logger.debug('OpenAIContext.tsx', 'useEffect', `Failed into initiate main thread due to: ${error}`), + ) + }, []) + + const sendMessage = useCallback( + async (message: string) => { + if (!mainThread) { + return + } + + setIsLoading(true) + await openai.beta.threads.messages.create(mainThread?.id, { + role: 'user', + content: message, + }) + const run = await openai.beta.threads.runs.createAndPoll(mainThread?.id, { + assistant_id: ASSISTANT_ID, + }) + await handleRunStatus(mainThread?.id, run, processMessages, toolsMap) + }, + [mainThread, processMessages, toolsMap], + ) + + useEffect(() => { + if (mainThread) { + // Attempted siri integration , not currently working + const listener = Linking.addEventListener('url', (event) => { + if (event.url.startsWith('uniswap://openai')) { + const capturedPhrase = decodeURI(event.url.split('uniswap://openai?capturedPhrase=')[1] ?? '') + capturedPhrase && sendMessage(capturedPhrase).catch(console.error) + } + }) + return listener.remove + } + }, [mainThread, sendMessage]) + + const value = { + isOpen, + isLoading, + messages, + open: (): void => { + setIsOpen(true) + }, + close: (): void => { + setIsOpen(false) + }, + sendMessage, + } + + return {children} +} diff --git a/apps/mobile/src/features/openai/assistant.ts b/apps/mobile/src/features/openai/assistant.ts new file mode 100644 index 00000000000..1b87fd00d5c --- /dev/null +++ b/apps/mobile/src/features/openai/assistant.ts @@ -0,0 +1,20 @@ +import OpenAI from 'openai' +import { tools } from 'src/features/openai/functions' +import { config } from 'uniswap/src/config' +import { logger } from 'utilities/src/logger/logger' + +export const ASSISTANT_ID = 'asst_PlaX9ILXiyV3cjsMEIUV6xbw' + +export const openai = new OpenAI({ + apiKey: config.openaiApiKey, +}) + +export function setupAssistant(): void { + openai.beta.assistants + .update(ASSISTANT_ID, { + description: ` + You are a helpful assistant for a crypto wallet app that allows users to swap on the decentralized exchange Uniswap. You will help answer questions for the user and help them use the app more effectively. Assume that the user is asking about tokens on the Ethereum blockchain unless specified otherwise. Do not include links or urls in responses. You can address the user by their username`, + tools, + }) + .catch((error) => logger.debug('assistant.ts', 'setupAssistant', `Error fetching assistant: ${error}`)) +} diff --git a/apps/mobile/src/features/openai/functions.ts b/apps/mobile/src/features/openai/functions.ts new file mode 100644 index 00000000000..6e022b3a467 --- /dev/null +++ b/apps/mobile/src/features/openai/functions.ts @@ -0,0 +1,267 @@ +import OpenAI from 'openai' +import { AppearanceSettingType } from 'wallet/src/features/appearance/slice' + +export enum FunctionName { + BackupCloud = 'backupCloud', + BackupManual = 'backupManual', + GetTopTokens = 'getTopTokens', + GetTokenDetails = 'getTokenDetails', + GetWalletPortfolioBalances = 'getWalletPortfolioBalances', + GetSwapWarning = 'getSwapWarning', + StartSend = 'startSend', + StartSwap = 'startSwap', + SearchTokens = 'searchTokens', + SearchRecipients = 'searchRecipients', + SettingChangeAppearance = 'settingChangeAppearance', + NavigateToFiatOnramp = 'navigateToFiatOnramp', +} + +export type PossibleFunctionArgs = { + address?: string + chain?: string + chainId?: number + pageSize?: number + sortBy?: string + text?: string + + inputTokenAddress?: string + inputTokenAmount?: number + inputTokenUSD?: number + outputTokenAddress?: string + outputTokenAmount?: number + isSwappingAll?: boolean + recipientAddress?: string + + appearanceSettingType?: string +} + +export const tools: OpenAI.Beta.Assistants.AssistantTool[] = [ + { + type: 'function', + function: { + name: FunctionName.BackupCloud, + description: + 'Takes the user to a screen where they can back up their recovery phrase to the iCloud or Google Drive, encrypted by a password that the user will input.', + }, + }, + { + type: 'function', + function: { + name: FunctionName.BackupManual, + description: + "Takes the user to a screen with the user's recovery phrase that allows the user to write it down or copy it to be saved elsewhere", + }, + }, + { + type: 'function', + function: { + name: FunctionName.GetTopTokens, + parameters: { + type: 'object', + properties: { + chain: { + type: 'string', + description: + 'An enum string for the ethereum chain to search on. The possible values are ARBITRUM, ETHEREUM, OPTIMISM, POLYGON, BNB, BASE, BLAST. It should be defaulted to ETHEREUM.', + }, + sortBy: { + type: 'string', + description: + 'An enum string for the field to sort by, descending. The possible values are TOTAL_VALUE_LOCKED, MARKET_CAP, VOLUME, POPULARITY.', + }, + pageSize: { + type: 'number', + description: 'The number of results that should be returned.', + }, + }, + required: ['chain', 'sortBy', 'pageSize'], + }, + description: 'Retrieves a sorted list of tokens for a specific chain', + }, + }, + { + type: 'function', + function: { + name: FunctionName.GetTokenDetails, + parameters: { + type: 'object', + properties: { + chain: { + type: 'string', + description: + 'An enum string for the ethereum chain to search on. The possible values are ARBITRUM, ETHEREUM, OPTIMISM, POLYGON, BNB, BASE, BLAST. It should be defaulted to ETHEREUM.', + }, + address: { + type: 'string', + description: 'The hexadecimal string representing the contract address for the specific token', + }, + }, + required: ['chain', 'address'], + }, + description: 'Fetches details for a specific token on a specific chain', + }, + }, + { + type: 'function', + function: { + name: FunctionName.GetWalletPortfolioBalances, + description: + 'Retrieves the portfolio balances of tokens for the user. Each balance is grouped by token for a specific chain.', + }, + }, + { + type: 'function', + function: { + name: FunctionName.GetSwapWarning, + description: 'Returns the current warning message for the swap the user is trying to make, if there is one.', + }, + }, + { + type: 'function', + function: { + name: FunctionName.SearchTokens, + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'The text to search for in the token name or symbol', + }, + chain: { + type: 'string', + description: + 'An enum string for the ethereum chain to search on. The possible values are ARBITRUM, ETHEREUM, OPTIMISM, POLYGON, BNB, BASE, BLAST. If no value is passed, it will search on all chains.', + }, + }, + required: ['text'], + }, + description: 'Searches for tokens based on the text provided', + }, + }, + { + type: 'function', + function: { + name: FunctionName.SearchRecipients, + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'The text to search for in the user wallet address or username', + }, + }, + required: ['text'], + }, + description: + 'Searches for recipient wallet addresses to send tokens to, can search for ENS username or Unitag username. It will return the recipient wallet address and username if available.', + }, + }, + { + type: 'function', + function: { + name: FunctionName.SettingChangeAppearance, + parameters: { + type: 'object', + properties: { + setting: { + appearanceSettingType: 'string', + description: `The setting value for controlling dark mode. Possible values are: ${Object.values( + AppearanceSettingType, + ).join(', ')}`, + }, + }, + description: 'Changes the appearance of the app to the specified theme e.g. dark mode or light mode', + }, + }, + }, + { + type: 'function', + function: { + name: FunctionName.StartSwap, + parameters: { + type: 'object', + properties: { + chainId: { + type: 'number', + description: + 'The hexadecimal string representing the chain address to swap the tokens on, converted to a number. These are the chain ids based on name: ArbitrumOne = 42161, Base = 8453, Optimism = 10, Polygon = 137, Blast = 81457, Bnb = 56.', + }, + inputTokenAddress: { + type: 'string', + description: + 'The hexadecimal string representing the contract address for the specific input token the user would like to swap.', + }, + outputTokenAddress: { + type: 'string', + description: + 'The hexadecimal string representing the contract address for the specific output token the user would like to swap for.', + }, + inputTokenAmount: { + type: 'number', + description: 'The amount of input token the user would like to swap for the output token.', + }, + outputTokenAmount: { + type: 'number', + description: 'The amount of output token the user would like to receive for the input token.', + }, + isSwappingAll: { + type: 'boolean', + description: + 'A boolean value that indicates if the user is swapping all of their owned input token. This is purely a helper flag and the inputTokenAmount variable is still needed.', + }, + }, + required: ['chainId'], + }, + description: + 'Navigates the user to a screen where they can swap one token for another on a specific chain on the Uniswap exchange protocol. At least one of inputTokenAddress or outputTokenAddress should be filled out, and both could be filled out. At least one of inputTokenAmount or outputTokenAmount should be provided, but both cannot be. Token amounts should be limited to a max of 10 significant digits, rounded. When swapping a certain percentage or ratio of the user’s input token, check their wallet portfolo for the amount.', + }, + }, + { + type: 'function', + function: { + name: FunctionName.StartSend, + parameters: { + type: 'object', + properties: { + chainId: { + type: 'number', + description: + 'The hexadecimal string representing the chain address to send the tokens, converted to a number. These are the chain ids based on name: ArbitrumOne = 42161, Base = 8453, Optimism = 10, Polygon = 137, Blast = 81457, Bnb = 56.', + }, + inputTokenAddress: { + type: 'string', + description: + 'The hexadecimal string representing the contract address for the specific input token the user would like to send to the recipient.', + }, + recipientAddress: { + type: 'string', + description: 'The hexadecimal string representing the wallet address for the recipient', + }, + inputTokenAmount: { + type: 'number', + description: 'The amount of input token the user would like to send to the recipient.', + }, + inputTokenUSD: { + type: 'number', + description: + 'The equivalent amount in USD of input token the user would like to send to the recipient. Can be used in place of inputTokenAmount', + }, + isSwappingAll: { + type: 'boolean', + description: 'A boolean value that indicates if the user is swapping all of their owned input token', + }, + }, + required: ['chainId', 'inputTokenAddress', 'recipientAddress', 'inputTokenAmount'], + }, + description: 'Navigates the user to a screen where the user can send tokens to another wallet address', + }, + }, + { + type: 'function', + function: { + name: FunctionName.NavigateToFiatOnramp, + description: + 'Navigates the user to a screen where they can buy crypto with fiat. This is helpful when the user does not have any crypto in their wallet or needs more for gas fees.', + }, + }, +] diff --git a/apps/mobile/src/features/scantastic/ScantasticModal.tsx b/apps/mobile/src/features/scantastic/ScantasticModal.tsx index 3d4d4a4d1d2..3a0631adc53 100644 --- a/apps/mobile/src/features/scantastic/ScantasticModal.tsx +++ b/apps/mobile/src/features/scantastic/ScantasticModal.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' import { closeAllModals } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' @@ -15,7 +14,6 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' import { logger } from 'utilities/src/logger/logger' import { ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' import { useInterval } from 'utilities/src/time/timing' -import { ExtensionOnboardingState, setExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/slice' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' @@ -47,7 +45,7 @@ export function ScantasticModal(): JSX.Element | null { throw new Error('This should not be accessed with no mnemonic accounts') } - const { initialState } = useAppSelector(selectModalState(ModalName.Scantastic)) + const { initialState } = useSelector(selectModalState(ModalName.Scantastic)) const params = initialState?.params const [OTP, setOTP] = useState('') @@ -79,7 +77,6 @@ export function ScantasticModal(): JSX.Element | null { hideDelay: 6 * ONE_SECOND_MS, }), ) - dispatch(setExtensionOnboardingState(ExtensionOnboardingState.Completed)) dispatch(closeAllModals()) } diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx deleted file mode 100644 index 682b55061b1..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.stories.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { ApproveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ApproveTransactionInfo, - ClassicTransactionDetails, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: '0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', - }, - }, - result: { - data: { - token: { - __typename: 'Token', - id: 'VG9rZW46RVRIRVJFVU1fMHgyYjU5MWU5OWFmZTlmMzJlYWE2MjE0ZjdiNzYyOTc2OGM0MGVlYjM5', - name: 'HEX', - symbol: 'HEX', - decimals: 8, - chain: 'ETHEREUM', - address: '0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNXzB4NWZmNzcyYTM1MjkxQkZBOTJkNTYxNDQ3MzVjMEEzNzhlNjQyM0Y4NA==', - logoUrl: - 'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png', - safetyLevel: 'MEDIUM_WARNING', - isSpam: false, - }, - }, - }, - }, - }, - { - request: { - query: TokenDocument, - variables: { - chain: 'OPTIMISM', - address: '0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', - }, - }, - result: { - data: { - token: { - __typename: 'Token', - id: 'VG9rZW46RVRIRVJFVU1fMHgyYjU5MWU5OWFmZTlmMzJlYWE2MjE0ZjdiNzYyOTc2OGM0MGVlYjM5', - name: 'HEX', - symbol: 'HEX', - decimals: 8, - chain: 'ETHEREUM', - address: '0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNXzB4NWZmNzcyYTM1MjkxQkZBOTJkNTYxNDQ3MzVjMEEzNzhlNjQyM0Y4NA==', - logoUrl: - 'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x2b591e99afE9f32eAA6214f7B7629768c40Eeb39/logo.png', - safetyLevel: 'MEDIUM_WARNING', - isSpam: false, - }, - }, - }, - }, - }, - ], - }, - }, -} - -export default meta - -const baseApproveTx: Omit & { - typeInfo: ApproveTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '', - addedTime: Date.now() - 30000, - hash: '', - options: { request: {} }, - chainId: 1, - id: '', - typeInfo: { - type: TransactionType.Approve, - spender: '', - approvalAmount: '1.0', - tokenAddress: '0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', - }, -} - -const baseApproveUnlimitedTx = { - ...baseApproveTx, - typeInfo: { - ...baseApproveTx.typeInfo, - approvalAmount: 'INF', - }, -} - -const baseRevokeTx = { - ...baseApproveTx, - typeInfo: { - ...baseApproveTx.typeInfo, - approvalAmount: '0.0', - }, -} - -export const Approve: StoryObj = { - render: () => ( - <> - - - - - - - - - ), -} - -export const Revoke: StoryObj = { - render: () => ( - <> - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx deleted file mode 100644 index ae95efe8ba8..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.stories.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { getNativeAddress } from 'uniswap/src/constants/addresses' -import { Chain, TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { FiatPurchaseSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - FiatPurchaseTransactionInfo, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - ], - }, - }, -} - -export default meta - -const baseFiatPurchaseTx: Omit & { - typeInfo: FiatPurchaseTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - type: TransactionType.FiatPurchase, - inputCurrency: { - type: 'fiat', - code: 'USD', - }, - inputCurrencyAmount: 123, - outputCurrency: { - type: 'crypto', - metadata: { - contractAddress: getNativeAddress(UniverseChainId.Mainnet), - chainId: Chain.Ethereum, - }, - }, - outputCurrencyAmount: 123, - syncedWithBackend: false, - }, -} - -export const FiatPurchase: StoryObj = { - render: () => ( - <> - - - - - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx deleted file mode 100644 index 1139a81d9e0..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.stories.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { NFTApproveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - NFTApproveTransactionInfo, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', -} - -export default meta - -const baseApproveTx: Omit & { - typeInfo: NFTApproveTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '', - addedTime: Date.now() - 30000, - hash: '', - options: { request: {} }, - chainId: 1, - id: '', - typeInfo: { - type: TransactionType.NFTApprove, - spender: '', - nftSummaryInfo: { - collectionName: 'Froggy Friends Official', - imageURL: - 'https://lh3.googleusercontent.com/9LokgAuB0Xqkio273GE0pY0WSJwOExFtFI1SkJT2jK-USvqFc-5if7ZP5PQ1h8s5YPimyJG5cSOdGGR2UaD3gTYMKAhj6yikYaw=s250', - name: 'Froggy Friend #1777', - tokenId: '1777', - address: '0x7ad05c1b87e93be306a9eadf80ea60d7648f1b6f', - }, - }, -} - -export const NFTApprove: StoryObj = { - render: () => ( - <> - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx deleted file mode 100644 index 7fa84aa8b97..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.stories.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { NFTMintSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - NFTMintTransactionInfo, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', -} - -export default meta - -const baseNFTMintTx: Omit & { - typeInfo: NFTMintTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '', - addedTime: Date.now() - 30000, - hash: '', - options: { request: {} }, - chainId: 1, - id: '', - typeInfo: { - type: TransactionType.NFTMint, - nftSummaryInfo: { - collectionName: 'Froggy Friends Official', - imageURL: - 'https://lh3.googleusercontent.com/9LokgAuB0Xqkio273GE0pY0WSJwOExFtFI1SkJT2jK-USvqFc-5if7ZP5PQ1h8s5YPimyJG5cSOdGGR2UaD3gTYMKAhj6yikYaw=s250', - name: 'Froggy Friend #1777', - tokenId: '1777', - address: '0x7ad05c1b87e93be306a9eadf80ea60d7648f1b6f', - }, - }, -} - -export const NFTMint: StoryObj = { - render: () => ( - <> - - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx deleted file mode 100644 index 2f1a0509038..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.stories.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { NFTTradeSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - NFTTradeTransactionInfo, - NFTTradeType, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', -} - -export default meta - -const baseNFTBuyTx: Omit & { - typeInfo: NFTTradeTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '', - addedTime: Date.now() - 30000, - hash: '', - options: { request: {} }, - chainId: 1, - id: '', - typeInfo: { - type: TransactionType.NFTTrade, - tradeType: NFTTradeType.BUY, - nftSummaryInfo: { - collectionName: 'Froggy Friends Official', - imageURL: - 'https://lh3.googleusercontent.com/9LokgAuB0Xqkio273GE0pY0WSJwOExFtFI1SkJT2jK-USvqFc-5if7ZP5PQ1h8s5YPimyJG5cSOdGGR2UaD3gTYMKAhj6yikYaw=s250', - name: 'Froggy Friend #1777', - tokenId: '1777', - address: '0x7ad05c1b87e93be306a9eadf80ea60d7648f1b6f', - }, - purchaseCurrencyId: buildNativeCurrencyId(UniverseChainId.Mainnet), - purchaseCurrencyAmountRaw: '1000000000000000000', - }, -} - -const baseNFTSellTx: Omit & { - typeInfo: NFTTradeTransactionInfo -} = { - ...baseNFTBuyTx, - typeInfo: { - ...baseNFTBuyTx.typeInfo, - tradeType: NFTTradeType.SELL, - }, -} - -export const NFTBuy: StoryObj = { - render: () => ( - <> - - - - - - - ), -} - -export const NFTSell: StoryObj = { - render: () => ( - <> - - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx deleted file mode 100644 index c7ea41de3fa..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { AssetType } from 'uniswap/src/entities/assets' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { ReceiveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - ReceiveTokenTransactionInfo, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - { - request: { - query: TokenDocument, - variables: { - chain: 'OPTIMISM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'OPTIMISM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - ], - }, - }, -} - -export default meta - -const baseReceiveTx: Omit & { - typeInfo: ReceiveTokenTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - type: TransactionType.Receive, - currencyAmountRaw: '50000000000000000', - sender: '0xa0c68c638235ee32657e8f720a23cec1bfc77c77', - tokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - assetType: AssetType.Currency, - transactedUSDValue: 105.21800000000002, - }, -} - -const baseNFTReceiveTx: Omit & { - typeInfo: ReceiveTokenTransactionInfo -} = { - ...baseReceiveTx, - typeInfo: { - type: TransactionType.Receive, - sender: '0xa0c68c638235ee32657e8f720a23cec1bfc77c77', - tokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - assetType: AssetType.ERC721, - nftSummaryInfo: { - collectionName: 'Froggy Friends Official', - imageURL: - 'https://lh3.googleusercontent.com/9LokgAuB0Xqkio273GE0pY0WSJwOExFtFI1SkJT2jK-USvqFc-5if7ZP5PQ1h8s5YPimyJG5cSOdGGR2UaD3gTYMKAhj6yikYaw=s250', - name: 'Froggy Friend #1777', - tokenId: '1777', - address: '0x7ad05c1b87e93be306a9eadf80ea60d7648f1b6f', - }, - }, -} - -export const Receive: StoryObj = { - render: () => ( - <> - - - - ), -} - -export const NFTReceive: StoryObj = { - render: () => ( - <> - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx deleted file mode 100644 index e459764c256..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { AssetType } from 'uniswap/src/entities/assets' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { SendSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - SendTokenTransactionInfo, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' - -const meta: Meta = { - title: 'WIP/Activity Items', - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - { - request: { - query: TokenDocument, - variables: { - chain: 'OPTIMISM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'OPTIMISM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - ], - }, - }, -} - -export default meta - -const baseSendTx: Omit & { - typeInfo: SendTokenTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - type: TransactionType.Send, - currencyAmountRaw: '50000000000000000', - recipient: '0xa0c68c638235ee32657e8f720a23cec1bfc77c77', - tokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - assetType: AssetType.Currency, - transactedUSDValue: 105.21800000000002, - }, -} - -const baseNFTSendTx: Omit & { - typeInfo: SendTokenTransactionInfo -} = { - ...baseSendTx, - typeInfo: { - type: TransactionType.Send, - recipient: '0xa0c68c638235ee32657e8f720a23cec1bfc77c77', - tokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - assetType: AssetType.ERC721, - nftSummaryInfo: { - collectionName: 'Froggy Friends Official', - imageURL: - 'https://lh3.googleusercontent.com/9LokgAuB0Xqkio273GE0pY0WSJwOExFtFI1SkJT2jK-USvqFc-5if7ZP5PQ1h8s5YPimyJG5cSOdGGR2UaD3gTYMKAhj6yikYaw=s250', - name: 'Froggy Friend #1777', - tokenId: '1777', - address: '0x7ad05c1b87e93be306a9eadf80ea60d7648f1b6f', - }, - }, -} - -export const Send: StoryObj = { - render: () => ( - <> - - - - - - - - ), -} - -export const NFTSend: StoryObj = { - render: () => ( - <> - - - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx deleted file mode 100644 index 20a439ee89d..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.stories.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import { TradeType } from '@uniswap/sdk-core' -import React from 'react' -import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { SwapSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { - ClassicTransactionDetails, - ExactInputSwapTransactionInfo, - ExactOutputSwapTransactionInfo, - TransactionStatus, - TransactionType, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: '0x6b175474e89094c44da98b954eedeac495271d0f', - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: '0x6b175474e89094c44da98b954eedeac495271d0f', - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fMHg2QjE3NTQ3NEU4OTA5NEM0NERhOThiOTU0RWVkZUFDNDk1MjcxZDBG', - name: 'Dai Stablecoin', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNXzB4REExMDAwOWNCZDVEMDdkZDBDZUNjNjYxNjFGQzkzRDdjOTAwMGRhMQ==', - isSpam: false, - logoUrl: - 'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'DAI', - }, - }, - }, - }, - ], - }, - }, -} - -export default meta - -const baseSwapTx: Omit & { - typeInfo: ExactOutputSwapTransactionInfo | ExactInputSwapTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - type: TransactionType.Swap, - tradeType: TradeType.EXACT_OUTPUT, - outputCurrencyAmountRaw: '50000000000000000', - expectedInputCurrencyAmountRaw: '50000000000000000', - maximumInputCurrencyAmountRaw: '50000000000000000', - inputCurrencyId: buildNativeCurrencyId(UniverseChainId.Mainnet), - outputCurrencyId: buildCurrencyId(UniverseChainId.Mainnet, '0x6b175474e89094c44da98b954eedeac495271d0f'), - transactedUSDValue: 105.21800000000002, - }, -} - -export const Swap: StoryObj = { - render: () => ( - <> - - - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx deleted file mode 100644 index bfcbc1fc45a..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.stories.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { UnknownSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem' -import { - ClassicTransactionDetails, - TransactionStatus, - TransactionType, - UnknownTransactionInfo, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', -} - -export default meta - -const baseUnknownTx: Omit & { - typeInfo: UnknownTransactionInfo -} = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - type: TransactionType.Unknown, - }, -} - -export const Unknown: StoryObj = { - render: () => ( - <> - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx deleted file mode 100644 index 2b259fc74b4..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { WCSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem' -import { - ClassicTransactionDetails, - TransactionStatus, - TransactionType, - WCConfirmInfo, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', -} - -export default meta - -const baseUnknownItem: Omit & { typeInfo: WCConfirmInfo } = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - type: TransactionType.WCConfirm, - dapp: { - icon: 'https://synapseprotocol.com/favicon.ico', - name: 'Synapse', - url: 'https://synapseprotocol.com', - source: 'walletconnect', - }, - }, -} - -export const WalletConnect: StoryObj = { - render: () => ( - <> - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx deleted file mode 100644 index 4747ec5db63..00000000000 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.stories.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import React from 'react' -import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' -import { WrapSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem' -import { - ClassicTransactionDetails, - TransactionStatus, - TransactionType, - WrapTransactionInfo, -} from 'wallet/src/features/transactions/types' - -const meta: Meta = { - title: 'WIP/Activity Items', - parameters: { - apolloClient: { - mocks: [ - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: null, - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: null, - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fbnVsbA==', - name: 'Ethereum', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNX251bGw=', - isSpam: false, - logoUrl: 'https://token-icons.s3.amazonaws.com/eth.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'ETH', - }, - }, - }, - }, - { - request: { - query: TokenDocument, - variables: { - chain: 'ETHEREUM', - address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - }, - }, - result: { - data: { - token: { - __typename: 'Token', - address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', - chain: 'ETHEREUM', - decimals: 18, - id: 'VG9rZW46RVRIRVJFVU1fMHhjMDJhYWEzOWIyMjNmZThkMGEwZTVjNGYyN2VhZDkwODNjNzU2Y2My', - name: 'Wrapped Ether', - project: { - __typename: 'TokenProject', - id: 'VG9rZW5Qcm9qZWN0OlRva2VuOkFSQklUUlVNXzB4ODJhRjQ5NDQ3RDhhMDdlM2JkOTVCRDBkNTZmMzUyNDE1MjNmQmFiMQ==', - isSpam: false, - logoUrl: - 'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', - safetyLevel: 'VERIFIED', - }, - symbol: 'WETH', - }, - }, - }, - }, - ], - }, - }, -} - -export default meta - -const baseWrapTx: Omit & { typeInfo: WrapTransactionInfo } = { - routing: Routing.CLASSIC, - from: '0x76e4de46c21603545eaaf7daf25e54c0d06bafa9', - addedTime: Date.now() - 30000, - hash: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - id: '0x3ba4b82fb3bcb237cff0180b4fb4f94902cde2cfa56c57567b59b5608590d077', - options: { request: {} }, - chainId: 1, - typeInfo: { - currencyAmountRaw: '10000000000000000', - type: TransactionType.Wrap, - unwrapped: false, - }, -} - -const baseUnwrapTx: Omit & { typeInfo: WrapTransactionInfo } = { - ...baseWrapTx, - typeInfo: { - ...baseWrapTx.typeInfo, - unwrapped: true, - }, -} - -export const Wrap: StoryObj = { - render: () => ( - <> - - - - - - - ), -} - -export const Unwrap: StoryObj = { - render: () => ( - <> - - - - - - - ), -} diff --git a/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx b/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx index c957d8c40bd..3ae3e301c12 100644 --- a/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx +++ b/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx @@ -3,6 +3,7 @@ import { default as React, useCallback, useEffect, useMemo, useReducer, useState import { useTranslation } from 'react-i18next' import { Keyboard, LayoutAnimation, StyleSheet, TouchableWithoutFeedback } from 'react-native' import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' +import { useSelector } from 'react-redux' import { useShouldShowNativeKeyboard } from 'src/app/hooks' import { RecipientSelect } from 'src/components/RecipientSelect/RecipientSelect' import { Screen } from 'src/components/layout/Screen' @@ -15,8 +16,16 @@ import EyeIcon from 'ui/src/assets/icons/eye.svg' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { iconSizes } from 'ui/src/theme' import { TokenSelectorModal, TokenSelectorVariation } from 'uniswap/src/components/TokenSelector/TokenSelector' +import { + useCommonTokensOptions, + useFilterCallbacks, + usePopularTokensOptions, + usePortfolioTokenOptions, + useTokenSectionsForSearchResults, +} from 'uniswap/src/components/TokenSelector/hooks' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { HandleBar } from 'uniswap/src/components/modals/HandleBar' +import { TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' import { CurrencyField, TransactionState } from 'uniswap/src/features/transactions/transactionState/types' @@ -24,24 +33,22 @@ import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/ty import { currencyAddress } from 'uniswap/src/utils/currencyId' import { useAddToSearchHistory, - useCommonTokensOptions, useFavoriteTokensOptions, - useFilterCallbacks, - usePopularTokensOptions, - usePortfolioTokenOptions, useTokenSectionsForEmptySearch, - useTokenSectionsForSearchResults, } from 'wallet/src/components/TokenSelector/hooks' import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { selectSearchHistory } from 'wallet/src/features/search/selectSearchHistory' import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' import { WarningAction, WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' import { useParsedSendWarnings } from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' import { useTransactionGasWarning } from 'wallet/src/features/transactions/hooks/useTransactionGasWarning' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' import { INITIAL_TRANSACTION_STATE, transactionStateReducer, @@ -74,7 +81,10 @@ export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JS const { isSheetReady } = useBottomSheetContext() const { formatNumberOrString, convertFiatAmountFormatted } = useLocalizationContext() const { navigateToBuyOrReceiveWithEmptyWallet } = useWalletNavigation() + const address = useActiveAccountAddressWithThrow() + const valueModifiers = usePortfolioValueModifiers(address) const { registerSearch } = useAddToSearchHistory() + const searchHistory = useSelector(selectSearchHistory) const [state, dispatch] = useReducer(transactionStateReducer, prefilledState || INITIAL_TRANSACTION_STATE) const derivedTransferInfo = useDerivedTransferInfo(state) @@ -177,6 +187,7 @@ export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JS Keyboard.dismiss()} @@ -290,6 +303,10 @@ function TransferInnerContent({ const { showNativeKeyboard, onDecimalPadLayout, isLayoutPending, onInputPanelLayout } = useShouldShowNativeKeyboard() const { currencyAmounts, recipient, currencyInInfo, nftIn, chainId, txId } = derivedTransferInfo + + // for transfer analytics + const currencyAmountUSD = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + const transferERC20Callback = useTransferERC20Callback( txId, chainId, @@ -298,6 +315,7 @@ function TransferInnerContent({ currencyAmounts[CurrencyField.INPUT]?.quotient.toString(), txRequest, onReviewNext, + currencyAmountUSD, ) const transferNFTCallback = useTransferNFTCallback( txId, diff --git a/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx index 2aeff67f62a..2cddd808fc9 100644 --- a/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx +++ b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx @@ -1,5 +1,5 @@ import { Dispatch, ReactNode, SetStateAction, useEffect, useMemo, useState } from 'react' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { selectModalState } from 'src/features/modals/selectModalState' import { useOnCloseSendModal } from 'src/features/transactions/swap/hooks/useOnCloseSendModal' import { TransferFormScreen } from 'src/features/transactions/transfer/transferRewrite/TransferFormScreen' @@ -77,7 +77,7 @@ function TransferFormScreenDelayedRender(): JSX.Element { } function TransferContextsContainer({ children }: { children?: ReactNode }): JSX.Element { - const { initialState } = useAppSelector(selectModalState(ModalName.Send)) + const { initialState } = useSelector(selectModalState(ModalName.Send)) const prefilledState = useMemo( (): SwapFormState | undefined => diff --git a/apps/mobile/src/features/wallet/hooks.ts b/apps/mobile/src/features/wallet/hooks.ts index 8758f49d45b..a3051b793a1 100644 --- a/apps/mobile/src/features/wallet/hooks.ts +++ b/apps/mobile/src/features/wallet/hooks.ts @@ -1,10 +1,7 @@ import { useCallback, useEffect, useState } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { openModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { logger } from 'utilities/src/logger/logger' import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' @@ -20,14 +17,13 @@ export function useWalletRestore(params?: { openModalImmediately?: boolean }): { // Means that no private key found for mnemonic wallets const [walletNeedsRestore, setWalletNeedsRestore] = useState(false) const hasImportedSeedPhrase = useNativeAccountExists() - const isRestoreWalletEnabled = useFeatureFlag(FeatureFlags.RestoreWallet) const openWalletRestoreModal = useCallback((): void => { dispatch(openModal({ name: ModalName.RestoreWallet })) }, [dispatch]) useEffect(() => { - if (!hasImportedSeedPhrase || !isRestoreWalletEnabled) { + if (!hasImportedSeedPhrase) { return } @@ -38,7 +34,7 @@ export function useWalletRestore(params?: { openModalImmediately?: boolean }): { openRestoreWalletModalIfNeeded().catch((error) => logger.error(error, { tags: { file: 'wallet/hooks', function: 'useWalletRestore' } }), ) - }, [dispatch, hasImportedSeedPhrase, isRestoreWalletEnabled]) + }, [dispatch, hasImportedSeedPhrase]) useEffect(() => { if (openModalImmediately && walletNeedsRestore) { @@ -46,7 +42,7 @@ export function useWalletRestore(params?: { openModalImmediately?: boolean }): { } }, [openModalImmediately, openWalletRestoreModal, walletNeedsRestore]) - const isModalOpen = useAppSelector(selectModalState(ModalName.RestoreWallet)).isOpen + const isModalOpen = useSelector(selectModalState(ModalName.RestoreWallet)).isOpen return { walletNeedsRestore, openWalletRestoreModal, isModalOpen } } diff --git a/apps/mobile/src/features/walletConnect/saga.ts b/apps/mobile/src/features/walletConnect/saga.ts index ad2bf357931..c1394ae8291 100644 --- a/apps/mobile/src/features/walletConnect/saga.ts +++ b/apps/mobile/src/features/walletConnect/saga.ts @@ -6,7 +6,6 @@ import { buildApprovedNamespaces, getSdkError } from '@walletconnect/utils' import { IWeb3Wallet, Web3Wallet, Web3WalletTypes } from '@walletconnect/web3wallet' import { Alert } from 'react-native' import { EventChannel, eventChannel } from 'redux-saga' -import { appSelect } from 'src/app/hooks' import { registerWCClientForPushNotifications } from 'src/features/walletConnect/api' import { getAccountAddressFromEIP155String, @@ -22,7 +21,7 @@ import { removeSession, setHasPendingSessionError, } from 'src/features/walletConnect/walletConnectSlice' -import { call, fork, put, take } from 'typed-redux-saga' +import { call, fork, put, select, take } from 'typed-redux-saga' import { config } from 'uniswap/src/config' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import i18n from 'uniswap/src/i18n/i18n' @@ -135,7 +134,7 @@ function showAlert(title: string, message: string): Promise { } function* handleSessionProposal(proposal: ProposalTypes.Struct) { - const activeAccountAddress = yield* appSelect(selectActiveAccountAddress) + const activeAccountAddress = yield* select(selectActiveAccountAddress) const { id, @@ -284,7 +283,7 @@ function* populateActiveSessions() { // Fetch all active sessions and add to store const sessions = wcWeb3Wallet.getActiveSessions() - const accounts = yield* appSelect(selectAccounts) + const accounts = yield* select(selectAccounts) for (const session of Object.values(sessions)) { // Get account address connected to the session from first namespace diff --git a/apps/mobile/src/features/walletConnect/useWalletConnect.ts b/apps/mobile/src/features/walletConnect/useWalletConnect.ts index 3ff56991b79..08772c2a79b 100644 --- a/apps/mobile/src/features/walletConnect/useWalletConnect.ts +++ b/apps/mobile/src/features/walletConnect/useWalletConnect.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' +import { MobileState } from 'src/app/reducer' import { AppModalState } from 'src/features/modals/ModalsState' import { selectModalState } from 'src/features/modals/selectModalState' import { @@ -26,11 +27,11 @@ interface WalletConnect { export function useWalletConnect(address: Maybe): WalletConnect { const selectSessions = useMemo(() => makeSelectSessions(), []) - const sessions = useAppSelector((state) => selectSessions(state, address)) ?? [] - const pendingRequests = useAppSelector(selectPendingRequests) - const modalState = useAppSelector(selectModalState(ModalName.WalletConnectScan)) - const pendingSession = useAppSelector(selectPendingSession) - const hasPendingSessionError = useAppSelector(selectHasPendingSessionError) + const sessions = useSelector((state: MobileState) => selectSessions(state, address)) ?? [] + const pendingRequests = useSelector(selectPendingRequests) + const modalState = useSelector(selectModalState(ModalName.WalletConnectScan)) + const pendingSession = useSelector(selectPendingSession) + const hasPendingSessionError = useSelector(selectHasPendingSessionError) return { sessions, pendingRequests, modalState, pendingSession, hasPendingSessionError } } diff --git a/apps/mobile/src/screens/AppLoadingScreen.tsx b/apps/mobile/src/screens/AppLoadingScreen.tsx index 1454011ca0d..094f5a0453b 100644 --- a/apps/mobile/src/screens/AppLoadingScreen.tsx +++ b/apps/mobile/src/screens/AppLoadingScreen.tsx @@ -3,7 +3,7 @@ import dayjs from 'dayjs' import { isEnrolledAsync } from 'expo-local-authentication' import { t } from 'i18next' import { useCallback, useEffect, useState } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { OnboardingStackParamList } from 'src/app/navigation/types' import { SplashScreen } from 'src/features/appLoading/SplashScreen' import { useBiometricContext } from 'src/features/biometrics/context' @@ -27,7 +27,6 @@ import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' import { AccountType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' import { selectAnyAddressHasNotificationsEnabled } from 'wallet/src/features/wallet/selectors' import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' -import { useAppSelector } from 'wallet/src/state' export const SPLASH_SCREEN = { uri: 'SplashScreen' } @@ -40,7 +39,7 @@ function useFinishAutomatedRecovery(navigation: Props['navigation']): { const { setRecoveredImportedAccounts, finishOnboarding } = useOnboardingContext() const notificationOSPermission = useNotificationOSPermissionsEnabled() - const hasAnyNotificationsEnabled = useAppSelector(selectAnyAddressHasNotificationsEnabled) + const hasAnyNotificationsEnabled = useSelector(selectAnyAddressHasNotificationsEnabled) const { deviceSupportsBiometrics } = useBiometricContext() const { requiredForTransactions: isBiometricAuthEnabled } = useBiometricAppSettings() diff --git a/apps/mobile/src/screens/DevScreen.tsx b/apps/mobile/src/screens/DevScreen.tsx index 190e1e8102c..c8a0966090a 100644 --- a/apps/mobile/src/screens/DevScreen.tsx +++ b/apps/mobile/src/screens/DevScreen.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { I18nManager, ScrollView } from 'react-native' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { BackButton } from 'src/components/buttons/BackButton' import { Screen } from 'src/components/layout/Screen' @@ -18,14 +18,13 @@ import { createAccountsActions } from 'wallet/src/features/wallet/create/createA import { useActiveAccount } from 'wallet/src/features/wallet/hooks' import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' import { resetWallet } from 'wallet/src/features/wallet/slice' -import { useAppSelector } from 'wallet/src/state' export function DevScreen(): JSX.Element { const insets = useDeviceInsets() const dispatch = useDispatch() const activeAccount = useActiveAccount() const [rtlEnabled, setRTLEnabled] = useState(I18nManager.isRTL) - const sortedMnemonicAccounts = useAppSelector(selectSortedSignerMnemonicAccounts) + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) const onPressResetTokenWarnings = (): void => { dispatch(resetDismissedWarnings()) diff --git a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx index ab3cb778675..6609a96a5f9 100644 --- a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx +++ b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { Screen } from 'src/components/layout/Screen' -import { useFiatOnRampTransactionCreator } from 'src/features/fiatOnRamp/hooks' import { Flex, useIsDarkMode } from 'ui/src' import { uniswapUrls } from 'uniswap/src/constants/urls' import { FiatOnRampConnectingView } from 'uniswap/src/features/fiatOnRamp/FiatOnRampConnectingView' @@ -16,6 +15,7 @@ import { UniverseChainId } from 'uniswap/src/types/chains' import { openUri } from 'uniswap/src/utils/linking' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useTimeout } from 'utilities/src/time/timing' +import { useFiatOnRampTransactionCreator } from 'wallet/src/features/fiatOnRamp/hooks' import { ImageUri } from 'wallet/src/features/images/ImageUri' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' diff --git a/apps/mobile/src/screens/ExploreScreen.tsx b/apps/mobile/src/screens/ExploreScreen.tsx index e1caeb84407..1c4cda83734 100644 --- a/apps/mobile/src/screens/ExploreScreen.tsx +++ b/apps/mobile/src/screens/ExploreScreen.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, KeyboardAvoidingView, TextInput } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { useExploreStackNavigation } from 'src/app/navigation/types' import { ExploreSections } from 'src/components/explore/ExploreSections' import { SearchEmptySection } from 'src/components/explore/search/SearchEmptySection' @@ -23,7 +23,7 @@ import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { useDebounce } from 'utilities/src/time/timing' export function ExploreScreen(): JSX.Element { - const modalInitialState = useAppSelector(selectModalState(ModalName.Explore)).initialState + const modalInitialState = useSelector(selectModalState(ModalName.Explore)).initialState const navigation = useExploreStackNavigation() const { isSheetReady } = useBottomSheetContext() diff --git a/apps/mobile/src/screens/FiatOnRampConnecting.tsx b/apps/mobile/src/screens/FiatOnRampConnecting.tsx index f15f73aed15..80f0fd9cc55 100644 --- a/apps/mobile/src/screens/FiatOnRampConnecting.tsx +++ b/apps/mobile/src/screens/FiatOnRampConnecting.tsx @@ -6,7 +6,6 @@ import { useDispatch } from 'react-redux' import { FiatOnRampStackParamList } from 'src/app/navigation/types' import { Screen } from 'src/components/layout/Screen' import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' -import { useFiatOnRampTransactionCreator } from 'src/features/fiatOnRamp/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { Flex, Text, useIsDarkMode } from 'ui/src' import { spacing } from 'ui/src/theme' @@ -22,6 +21,7 @@ import { FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' import { openUri } from 'uniswap/src/utils/linking' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useTimeout } from 'utilities/src/time/timing' +import { useFiatOnRampTransactionCreator } from 'wallet/src/features/fiatOnRamp/hooks' import { ImageUri } from 'wallet/src/features/images/ImageUri' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { pushNotification } from 'wallet/src/features/notifications/slice' diff --git a/apps/mobile/src/screens/FiatOnRampModalState.ts b/apps/mobile/src/screens/FiatOnRampModalState.ts new file mode 100644 index 00000000000..0f0b6d8c2bb --- /dev/null +++ b/apps/mobile/src/screens/FiatOnRampModalState.ts @@ -0,0 +1,3 @@ +import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' + +export type FiatOnRampModalState = { prefilledCurrency?: FiatOnRampCurrency } diff --git a/apps/mobile/src/screens/FiatOnRampScreen.tsx b/apps/mobile/src/screens/FiatOnRampScreen.tsx index 2b32e658140..9c9e7088635 100644 --- a/apps/mobile/src/screens/FiatOnRampScreen.tsx +++ b/apps/mobile/src/screens/FiatOnRampScreen.tsx @@ -1,25 +1,18 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { ComponentProps, useEffect, useRef, useState } from 'react' +import React, { ComponentProps, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { TextInput, TextInputProps } from 'react-native' +import { TextInputProps } from 'react-native' import FastImage from 'react-native-fast-image' import { FadeIn, FadeOut, FadeOutDown } from 'react-native-reanimated' import { useDispatch } from 'react-redux' -import { useShouldShowNativeKeyboard } from 'src/app/hooks' import { FiatOnRampStackParamList } from 'src/app/navigation/types' import { FiatOnRampCtaButton } from 'src/components/fiatOnRamp/CtaButton' import { Screen } from 'src/components/layout/Screen' -import { FiatOnRampAmountSection } from 'src/features/fiatOnRamp/FiatOnRampAmountSection' +import { FiatOnRampAmountSection, FiatOnRampAmountSectionRef } from 'src/features/fiatOnRamp/FiatOnRampAmountSection' import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' import { FiatOnRampCountryListModal } from 'src/features/fiatOnRamp/FiatOnRampCountryListModal' import { FiatOnRampTokenSelectorModal } from 'src/features/fiatOnRamp/FiatOnRampTokenSelector' -import { - useFiatOnRampQuotes, - useFiatOnRampSupportedTokens, - useMeldFiatCurrencySupportInfo, - useParseFiatOnRampError, -} from 'src/features/fiatOnRamp/hooks' -import { Flex, Text, useIsDarkMode } from 'ui/src' +import { Flex, Text, isWeb, useIsDarkMode, useIsShortMobileDevice } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { HandleBar } from 'uniswap/src/components/modals/HandleBar' @@ -36,15 +29,29 @@ import { FiatOnRampEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UniverseEventProperties } from 'uniswap/src/features/telemetry/types' import { FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' +import { truncateToMaxDecimals } from 'utilities/src/format/truncateToMaxDecimals' import { usePrevious } from 'utilities/src/react/hooks' import { DEFAULT_DELAY, useDebounce } from 'utilities/src/time/timing' -import { DecimalPadLegacy } from 'wallet/src/components/legacy/DecimalPadLegacy' import { useLocalFiatToUSDConverter } from 'wallet/src/features/fiatCurrency/hooks' +import { + useFiatOnRampQuotes, + useFiatOnRampSupportedTokens, + useMeldFiatCurrencySupportInfo, + useParseFiatOnRampError, +} from 'wallet/src/features/fiatOnRamp/hooks' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { + DecimalPadCalculateSpace, + DecimalPadInput, + DecimalPadInputRef, +} from 'wallet/src/features/transactions/swap/DecimalPadInput' type Props = NativeStackScreenProps +const MAX_FIAT_INPUT_DECIMALS = 2 +const ON_SELECTION_CHANGE_WAIT_TIME_MS = 500 + function selectInitialQuote(quotes: FORQuote[] | undefined): { quote: FORQuote | undefined type: InitialQuoteSelection | undefined @@ -81,14 +88,37 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { const { t } = useTranslation() const dispatch = useDispatch() const isDarkMode = useIsDarkMode() - const [selection, setSelection] = useState() const [value, setValue] = useState('') const [showTokenSelector, setShowTokenSelector] = useState(false) - const inputRef = useRef(null) + const inputRef = useRef(null) const [selectingCountry, setSelectingCountry] = useState(false) + const [decimalPadReady, setDecimalPadReady] = useState(false) + const decimalPadRef = useRef(null) + const selectionRef = useRef() + const valueRef = useRef('') + const amountUpdatedTimeRef = useRef(0) + const isShortMobileDevice = useIsShortMobileDevice() const { isSheetReady } = useBottomSheetContext() + // passed to memo(...) component + const onDecimalPadReady = useCallback(() => setDecimalPadReady(true), []) + + // passed to memo(...) component + const onDecimalPadTriggerInputShake = useCallback(() => { + inputRef.current?.triggerShakeAnimation() + }, [inputRef]) + + // passed to memo(...) component + const resetSelection = useCallback(({ start, end }: { start: number; end?: number }): void => { + selectionRef.current = { start, end } + if (!isWeb && inputRef.current) { + setTimeout(() => { + inputRef.current?.textInputRef.current?.setNativeProps?.({ selection: { start, end } }) + }, 0) + } + }, []) + const { selectedQuote, setSelectedQuote, @@ -104,12 +134,6 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { setQuoteCurrency, } = useFiatOnRampContext() - const resetSelection = (start: number, end?: number): void => { - setSelection({ start, end: end ?? start }) - } - - const { showNativeKeyboard, onDecimalPadLayout, isLayoutPending, onInputPanelLayout } = useShouldShowNativeKeyboard() - const { appFiatCurrencySupportedInMeld, meldSupportedFiatCurrency, supportedFiatCurrencies } = useMeldFiatCurrencySupportInfo(countryCode) @@ -190,22 +214,25 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { const onChangeValue = (source: UniverseEventProperties[FiatOnRampEventName.FiatOnRampAmountEntered]['source']) => (newAmount: string): void => { + amountUpdatedTimeRef.current = Date.now() sendAnalyticsEvent(FiatOnRampEventName.FiatOnRampAmountEntered, { source, amountUSD: fiatToUSDConverter(parseFloat(newAmount)), }) - setValue(newAmount) - setAmount(newAmount ? parseFloat(newAmount) : 0) - } + const truncatedValue = truncateToMaxDecimals({ + value: newAmount, + maxDecimals: MAX_FIAT_INPUT_DECIMALS, + }) - // hide keyboard when user goes to token selector screen - useEffect(() => { - if (showTokenSelector) { - inputRef.current?.blur() - } else if (showNativeKeyboard) { - inputRef.current?.focus() + valueRef.current = truncatedValue + setValue(truncatedValue) + setAmount(truncatedValue ? parseFloat(truncatedValue) : 0) + // if user did not use Decimal Pad to enter value + if (source !== 'textInput') { + resetSelection({ start: valueRef.current.length, end: valueRef.current.length }) + } + decimalPadRef.current?.updateDisabledKeys() } - }, [showNativeKeyboard, showTokenSelector]) // we only show loading when there are no errors and quote value is not empty const buttonDisabled = selectTokenLoading || !!quotesError || !selectedQuote?.destinationAmount @@ -249,6 +276,22 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { meldSupportedFiatCurrency.code, ) + const onSelectionChange = useCallback( + (start: number, end: number) => { + if (Date.now() - amountUpdatedTimeRef.current < ON_SELECTION_CHANGE_WAIT_TIME_MS) { + // We only want to trigger this callback when the user is manually moving the cursor, + // but this function is also triggered when the input value is updated, + // which causes issues on Android. + // We use `amountUpdatedTimeRef` to check if the input value was updated recently, + // and if so, we assume that the user is actually typing and not manually moving the cursor. + return + } + selectionRef.current = { start, end } + decimalPadRef.current?.updateDisabledKeys() + }, + [amountUpdatedTimeRef], + ) + return ( @@ -265,50 +308,49 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { /> { setShowTokenSelector(true) }} /> + - {!showNativeKeyboard && ( - + - )} + + const CONTENT_HEADER_HEIGHT_ESTIMATE = 270 /** @@ -98,15 +107,37 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const dimensions = useDeviceDimensions() const dispatch = useDispatch() const isFocused = useIsFocused() - const isModalOpen = useAppSelector(selectSomeModalOpen) + const isModalOpen = useSelector(selectSomeModalOpen) const isHomeScreenBlur = !isFocused || isModalOpen - const hasSkippedUnitagPrompt = useAppSelector(selectHasSkippedUnitagPrompt) + const hasSkippedUnitagPrompt = useSelector(selectHasSkippedUnitagPrompt) const showFeedTab = useFeatureFlag(FeatureFlags.FeedTab) + + const portfolioValueModifiers = usePortfolioValueModifiers(activeAccount.address) ?? [] + const { data: balancesById } = usePortfolioBalances({ + address: activeAccount.address, + valueModifiers: portfolioValueModifiers, + }) + const [showOnboardingRedesign, setShowOnboardingRedesign] = useState(false) + const accountHasNoTokens = balancesById && !Object.entries(balancesById).length + + useEffect(() => { + // Sets experiment value and exposes user only if they have no tokens + if (accountHasNoTokens) { + const experimentEnabled = getExperimentValue( + Experiments.OnboardingRedesignHomeScreen, + OnboardingRedesignHomeScreenProperties.Enabled, + false, + ) + setShowOnboardingRedesign(experimentEnabled) + } else { + setShowOnboardingRedesign(false) + } + }, [accountHasNoTokens, balancesById, showOnboardingRedesign]) + // opens the wallet restore modal if recovery phrase is missing after the app is opened useWalletRestore({ openModalImmediately: true }) - // Record a heartbeat for anonymous user DAU useHeartbeatReporter() // Report balances at most every 24 hours, checking every 15 seconds when app is open @@ -118,12 +149,22 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const nftsTitle = t('home.nfts.title') const activityTitle = t('home.activity.title') const feedTitle = t('home.feed.title') - - const routes = useMemo(() => { - const tabs: Array<{ key: SectionNameType; title: string }> = [ + const exploreTitle = t('home.explore.title') + + const routes = useMemo((): HomeRoute[] => { + if (showOnboardingRedesign) { + return [ + { + key: SectionName.HomeExploreTab, + title: exploreTitle, + textStyleType: 'secondary', + }, + ] + } + const tabs: Array = [ { key: SectionName.HomeTokensTab, title: tokensTitle }, { key: SectionName.HomeNFTsTab, title: nftsTitle }, - { key: SectionName.HomeActivityTab, title: activityTitle }, + { key: SectionName.HomeActivityTab, title: activityTitle, enableNotificationBadge: true }, ] if (showFeedTab) { @@ -131,7 +172,7 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. } return tabs - }, [tokensTitle, nftsTitle, activityTitle, feedTitle, showFeedTab]) + }, [showOnboardingRedesign, tokensTitle, nftsTitle, activityTitle, showFeedTab, exploreTitle, feedTitle]) useEffect( function syncTabIndex() { @@ -174,6 +215,10 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. ) const feedTabScrollValue = useSharedValue(0) const feedTabScrollHandler = useAnimatedScrollHandler((event) => (feedTabScrollValue.value = event.contentOffset.y)) + const exploreTabScrollValue = useSharedValue(0) + const exploreTabScrollHandler = useAnimatedScrollHandler( + (event) => (exploreTabScrollValue.value = event.contentOffset.y), + ) const tokensTabScrollRef = useAnimatedRef>() // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -182,9 +227,13 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const activityTabScrollRef = useAnimatedRef>() // eslint-disable-next-line @typescript-eslint/no-explicit-any const feedTabScrollRef = useAnimatedRef>() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const exploreTabScrollRef = useAnimatedRef>() const currentScrollValue = useDerivedValue(() => { - if (tabIndex === HomeScreenTabIndex.Tokens) { + if (showOnboardingRedesign) { + return exploreTabScrollValue.value + } else if (tabIndex === HomeScreenTabIndex.Tokens) { return tokensTabScrollValue.value } else if (tabIndex === HomeScreenTabIndex.NFTs) { return nftsTabScrollValue.value @@ -192,7 +241,15 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. return activityTabScrollValue.value } return feedTabScrollValue.value - }, [activityTabScrollValue, feedTabScrollValue, nftsTabScrollValue, tabIndex, tokensTabScrollValue]) + }, [ + activityTabScrollValue.value, + exploreTabScrollValue.value, + showOnboardingRedesign, + feedTabScrollValue.value, + nftsTabScrollValue.value, + tabIndex, + tokensTabScrollValue.value, + ]) // clear the notification indicator if the user is on the activity tab const hasNotifications = useSelectAddressHasNotifications(activeAccount.address) @@ -208,14 +265,18 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. tokensTabScrollValue.value = 0 activityTabScrollValue.value = 0 feedTabScrollValue.value = 0 + exploreTabScrollValue.value = 0 nftsTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) tokensTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) activityTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) feedTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) + exploreTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) }, [ activeAccount, activityTabScrollRef, activityTabScrollValue, + exploreTabScrollRef, + exploreTabScrollValue, nftsTabScrollRef, nftsTabScrollValue, tokensTabScrollRef, @@ -232,7 +293,9 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. useScrollToTop( useRef({ scrollToTop: () => { - if (currentTabIndex.value === HomeScreenTabIndex.NFTs && isNftTabsAtTop.value) { + if (showOnboardingRedesign) { + exploreTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) + } else if (currentTabIndex.value === HomeScreenTabIndex.NFTs && isNftTabsAtTop.value) { setTabIndex(HomeScreenTabIndex.Tokens) } else if (currentTabIndex.value === HomeScreenTabIndex.NFTs) { nftsTabScrollRef.current?.scrollToOffset({ offset: 0, animated: true }) @@ -277,8 +340,7 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const { sync } = useScrollSync(currentTabIndex, scrollPairs, headerConfig) - const cexTransferEnabled = useFeatureFlag(FeatureFlags.CexTransfers) - const cexTransferProviders = useCexTransferProviders(cexTransferEnabled) + const cexTransferProviders = useCexTransferProviders() const onPressBuy = useCallback(() => dispatch(openModal({ name: ModalName.FiatOnRampAggregator })), [dispatch]) const onPressScan = useCallback(() => { @@ -347,32 +409,33 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const shouldPromptUnitag = activeAccount.type === AccountType.SignerMnemonic && !hasSkippedUnitagPrompt && canClaimUnitag - - const { showExtensionPromoBanner } = useShowExtensionPromoBanner() - const [showExtensionPromoModal, setShowExtensionPromoModal] = useState(false) - const viewOnlyLabel = t('home.warning.viewOnly') const promoBanner = useMemo(() => { - if (shouldPromptUnitag) { + if (showOnboardingRedesign) { return ( - - - + + + ) - } else if (showExtensionPromoBanner) { + } else if (shouldPromptUnitag) { return ( - setShowExtensionPromoModal(true)} /> + ) } return null - }, [shouldPromptUnitag, showExtensionPromoBanner, activeAccount.address]) + }, [shouldPromptUnitag, activeAccount.address, showOnboardingRedesign]) const contentHeader = useMemo(() => { return ( - + @@ -391,7 +454,15 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. {promoBanner} ) - }, [activeAccount.address, isSignerAccount, actions, onPressViewOnlyLabel, viewOnlyLabel, promoBanner]) + }, [ + showOnboardingRedesign, + activeAccount.address, + isSignerAccount, + actions, + onPressViewOnlyLabel, + viewOnlyLabel, + promoBanner, + ]) const paddingTop = headerHeight + TAB_BAR_HEIGHT + TAB_STYLES.tabListInner.paddingTop const paddingBottom = insets.bottom + SWAP_BUTTON_HEIGHT + TAB_STYLES.tabListInner.paddingBottom + spacing.spacing12 @@ -443,6 +514,22 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const apolloClient = useApolloClient() + const renderTabLabel = useCallback( + ({ route, focused, isExternalProfile }: { route: HomeRoute; focused: boolean; isExternalProfile?: boolean }) => { + const { textStyleType: theme, enableNotificationBadge, ...rest } = route + return ( + + ) + }, + [], + ) + const renderTabBar = useCallback( (sceneProps: SceneRendererProps) => { const style: ViewStyle = { width: 'auto' } @@ -459,7 +546,7 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. indicatorStyle={TAB_STYLES.activeTabIndicator} navigationState={{ index: tabIndex, routes }} pressColor={colors.surface3.val} // Android only - renderLabel={TabLabel} + renderLabel={renderTabLabel} style={[ TAB_STYLES.tabBar, { @@ -485,6 +572,7 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. handleHeaderLayout, headerContainerStyle, isLayoutReady, + renderTabLabel, routes, tabBarStyle, tabIndex, @@ -581,6 +669,18 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. onRefresh={onRefreshHomeData} /> ) + case SectionName.HomeExploreTab: + return ( + + ) } return null }, @@ -601,14 +701,19 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. activityTabScrollHandler, feedTabScrollRef, feedTabScrollHandler, + exploreTabScrollRef, + exploreTabScrollHandler, ], ) + const openAIAssistantEnabled = useFeatureFlag(FeatureFlags.OpenAIAssistant) + // Hides lock screen on next js render cycle, ensuring this component is loaded when the screen is hidden useTimeout(hideSplashScreen, 1) return ( + {openAIAssistantEnabled && } ): JSX. width="100%" zIndex="$sticky" /> - {showExtensionPromoModal && setShowExtensionPromoModal(false)} />} ) } diff --git a/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx b/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx index c9bc6e4f421..f97b80cae14 100644 --- a/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx +++ b/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx @@ -3,8 +3,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, TextInput } from 'react-native' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { OnboardingStackParamList } from 'src/app/navigation/types' import { PasswordInput } from 'src/components/input/PasswordInput' import { restoreMnemonicFromCloudStorage } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' @@ -73,8 +72,8 @@ export function RestoreCloudBackupPasswordScreen({ navigation, route: { params } const dispatch = useDispatch() const { generateImportedAccounts } = useOnboardingContext() - const passwordAttemptCount = useAppSelector(selectPasswordAttempts) - const lockoutEndTime = useAppSelector(selectLockoutEndTime) + const passwordAttemptCount = useSelector(selectPasswordAttempts) + const lockoutEndTime = useSelector(selectLockoutEndTime) const isRestoringMnemonic = params.importType === ImportType.RestoreMnemonic diff --git a/apps/mobile/src/screens/Import/SeedPhraseInput.tsx b/apps/mobile/src/screens/Import/SeedPhraseInput.tsx index 7324bf53581..e86f6b931f5 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInput.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInput.tsx @@ -3,6 +3,7 @@ import { forwardRef, RefObject, useEffect, useState } from 'react' import { findNodeHandle, NativeSyntheticEvent, requireNativeComponent, StyleSheet, UIManager } from 'react-native' import { useNativeComponentKey } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' import { isAndroid } from 'utilities/src/platform' @@ -26,7 +27,7 @@ export enum StringKey { } interface NativeSeedPhraseInputProps { targetMnemonicId?: string - testID?: string + testID?: TestIDType strings: Record onInputValidated: (e: NativeSyntheticEvent) => void onMnemonicStored: (e: NativeSyntheticEvent) => void diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.android.mock.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.android.mock.tsx similarity index 98% rename from apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.android.mock.tsx rename to apps/mobile/src/screens/Import/SeedPhraseInputScreen.android.mock.tsx index 6dea2e459ed..916d45ae561 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.android.mock.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.android.mock.tsx @@ -29,7 +29,7 @@ import { type Props = NativeStackScreenProps // Original SeedPhraseInputScreen component including JS input field. Used as a mock for Android Detox e2e testing. -export function SeedPhraseInputScreenV2({ navigation, route: { params } }: Props): JSX.Element { +export function SeedPhraseInputScreen({ navigation, route: { params } }: Props): JSX.Element { const { t } = useTranslation() const { generateImportedAccountsByMnemonic } = useOnboardingContext() diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx deleted file mode 100644 index 49bc7b52a8d..00000000000 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { CompositeNavigationProp, RouteProp } from '@react-navigation/core' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { StackNavigationProp } from '@react-navigation/stack' -import React from 'react' -import { AppStackParamList, OnboardingStackParamList } from 'src/app/navigation/types' -import { SeedPhraseInputScreen } from 'src/screens/Import/SeedPhraseInputScreen' -import { render } from 'src/test/test-utils' -import { ImportType } from 'uniswap/src/types/onboarding' -import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' - -jest.mock('src/utils/useAddBackButton', () => ({ - useAddBackButton: (): jest.Mock => jest.fn(), -})) - -const navigationProp = {} as CompositeNavigationProp< - StackNavigationProp, - NativeStackNavigationProp -> - -const routeProp = { params: { importType: ImportType.CreateNew } } as RouteProp< - OnboardingStackParamList, - OnboardingScreens.SeedPhraseInput -> - -describe(SeedPhraseInputScreen, () => { - it.skip('seed phrase initial screen rendering', async () => { - const tree = render() - - expect(tree.toJSON()).toMatchSnapshot() - }) -}) diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx index 08860bc6ead..39aa86e3acf 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx @@ -1,36 +1,37 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent } from 'react-native' import { OnboardingStackParamList } from 'src/app/navigation/types' import { useLockScreenOnBlur } from 'src/features/authentication/lockScreenContext' -import { GenericImportForm } from 'src/features/import/GenericImportForm' import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' +import { + InputValidatedEvent, + MnemonicStoredEvent, + NativeSeedPhraseInputRef, + SeedPhraseInput, + StringKey, + handleSubmit, +} from 'src/screens/Import/SeedPhraseInput' import { useAddBackButton } from 'src/utils/useAddBackButton' import { Button, Flex, Text, TouchableArea } from 'ui/src' import { QuestionInCircleFilled } from 'ui/src/components/icons' import { uniswapUrls } from 'uniswap/src/constants/urls' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { ImportType } from 'uniswap/src/types/onboarding' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' import { openUri } from 'uniswap/src/utils/linking' import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' -import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' import { BackupType } from 'wallet/src/features/wallet/accounts/types' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' -import { - MnemonicValidationError, - translateMnemonicErrorMessage, - userFinishedTypingWord, - validateMnemonic, - validateSetOfWords, -} from 'wallet/src/utils/mnemonics' type Props = NativeStackScreenProps export function SeedPhraseInputScreen({ navigation, route: { params } }: Props): JSX.Element { const { t } = useTranslation() - const { generateImportedAccountsByMnemonic } = useOnboardingContext() + const { generateImportedAccounts } = useOnboardingContext() /** * If paste permission modal is open, we need to manually disable the splash screen that appears on blur, @@ -42,69 +43,26 @@ export function SeedPhraseInputScreen({ navigation, route: { params } }: Props): const [pastePermissionModalOpen, setPastePermissionModalOpen] = useState(false) useLockScreenOnBlur(pastePermissionModalOpen) - const [value, setValue] = useState(undefined) - const [errorMessage, setErrorMessage] = useState(undefined) - + const [submitEnabled, setSubmitEnabled] = useState(false) + const seedPhraseInputRef = useRef(null) const isRestoringMnemonic = params.importType === ImportType.RestoreMnemonic useAddBackButton(navigation) const signerAccounts = useSignerAccounts() - const mnemonicId = (isRestoringMnemonic && signerAccounts[0]?.mnemonicId) || undefined - - // Add all accounts from mnemonic. - const onSubmit = useCallback(async () => { - // Check phrase validation - const { validMnemonic, error, invalidWord } = validateMnemonic(value) + const targetMnemonicId = (isRestoringMnemonic && signerAccounts[0]?.mnemonicId) || undefined - if (error) { - setErrorMessage(translateMnemonicErrorMessage(error, invalidWord, t)) - return - } + const handleNext = useCallback( + async (storedMnemonicId: string) => { + await generateImportedAccounts(storedMnemonicId, BackupType.Manual) - if (!validMnemonic) { - return - } - - if (mnemonicId && validMnemonic) { - const generatedMnemonicId = await Keyring.generateAddressForMnemonic(validMnemonic, 0) - if (generatedMnemonicId !== mnemonicId) { - setErrorMessage(t('account.recoveryPhrase.error.wrong')) - return + // restore flow is handled in saga after `restoreMnemonicComplete` is dispatched + if (!isRestoringMnemonic) { + navigation.navigate({ name: OnboardingScreens.SelectWallet, params, merge: true }) } - } - - await generateImportedAccountsByMnemonic(validMnemonic, undefined, BackupType.Manual) - - // restore flow is handled in saga after `restoreMnemonicComplete` is dispatched - if (!isRestoringMnemonic) { - navigation.navigate({ name: OnboardingScreens.SelectWallet, params, merge: true }) - } - }, [value, mnemonicId, generateImportedAccountsByMnemonic, isRestoringMnemonic, t, navigation, params]) - - const onBlur = useCallback(() => { - const { error, invalidWord } = validateMnemonic(value) - if (error) { - setErrorMessage(translateMnemonicErrorMessage(error, invalidWord, t)) - } - }, [t, value]) - - const onChange = (text: string | undefined): void => { - const { error, invalidWord } = validateSetOfWords(text) - - // suppress error messages if the user is not done typing a word - const suppressError = - (error === MnemonicValidationError.InvalidWord && !userFinishedTypingWord(text)) || - error === MnemonicValidationError.NotEnoughWords - - if (!error || suppressError) { - setErrorMessage(undefined) - } else { - setErrorMessage(translateMnemonicErrorMessage(error, invalidWord, t)) - } - - setValue(text) - } + }, + [generateImportedAccounts, isRestoringMnemonic, navigation, params], + ) const onPressRecoveryHelpButton = (): Promise => openUri(uniswapUrls.helpArticleUrls.recoveryPhraseHowToImport) @@ -114,6 +72,22 @@ export function SeedPhraseInputScreen({ navigation, route: { params } }: Props): return ( + + + } + minHeightWhenKeyboardExpanded={false} subtitle={ isRestoringMnemonic ? t('account.recoveryPhrase.subtitle.restoring') @@ -123,44 +97,46 @@ export function SeedPhraseInputScreen({ navigation, route: { params } }: Props): isRestoringMnemonic ? t('account.recoveryPhrase.title.restoring') : t('account.recoveryPhrase.title.import') } > - - - setPastePermissionModalOpen(false)} - beforePasteButtonPress={(): void => setPastePermissionModalOpen(true)} - errorMessage={errorMessage} - inputAlignment="flex-start" - placeholderLabel={t('account.recoveryPhrase.input')} - textAlign="left" - value={value} - onBlur={onBlur} - onChange={onChange} - /> - - - - - - - {isRestoringMnemonic - ? t('account.recoveryPhrase.helpText.restoring') - : t('account.recoveryPhrase.helpText.import')} - - - - + ): void => + setSubmitEnabled(e.nativeEvent.canSubmit) + } + onMnemonicStored={(e: NativeSyntheticEvent): Promise => + handleNext(e.nativeEvent.mnemonicId) + } + onPasteEnd={(): void => { + setPastePermissionModalOpen(false) + }} + onPasteStart={(): void => { + setPastePermissionModalOpen(true) + }} + /> + + + + + + + {isRestoringMnemonic + ? t('account.recoveryPhrase.helpText.restoring') + : t('account.recoveryPhrase.helpText.import')} + + + - - - ) } diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx deleted file mode 100644 index 65602aeba8b..00000000000 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { NativeSyntheticEvent } from 'react-native' -import { OnboardingStackParamList } from 'src/app/navigation/types' -import { useLockScreenOnBlur } from 'src/features/authentication/lockScreenContext' -import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' -import { - InputValidatedEvent, - MnemonicStoredEvent, - NativeSeedPhraseInputRef, - SeedPhraseInput, - StringKey, - handleSubmit, -} from 'src/screens/Import/SeedPhraseInput' -import { useAddBackButton } from 'src/utils/useAddBackButton' -import { Button, Flex, Text, TouchableArea } from 'ui/src' -import { QuestionInCircleFilled } from 'ui/src/components/icons' -import { uniswapUrls } from 'uniswap/src/constants/urls' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { ElementName } from 'uniswap/src/features/telemetry/constants' -import { TestID } from 'uniswap/src/test/fixtures/testIDs' -import { ImportType } from 'uniswap/src/types/onboarding' -import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' -import { openUri } from 'uniswap/src/utils/linking' -import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' -import { BackupType } from 'wallet/src/features/wallet/accounts/types' -import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' - -type Props = NativeStackScreenProps - -export function SeedPhraseInputScreenV2({ navigation, route: { params } }: Props): JSX.Element { - const { t } = useTranslation() - const { generateImportedAccounts } = useOnboardingContext() - - /** - * If paste permission modal is open, we need to manually disable the splash screen that appears on blur, - * since the modal triggers the same `inactive` app state as does going to app switcher - * - * Technically seed phrase will be blocked if user pastes from keyboard, - * but that is an extreme edge case. - **/ - const [pastePermissionModalOpen, setPastePermissionModalOpen] = useState(false) - useLockScreenOnBlur(pastePermissionModalOpen) - - const [submitEnabled, setSubmitEnabled] = useState(false) - const seedPhraseInputRef = useRef(null) - const isRestoringMnemonic = params.importType === ImportType.RestoreMnemonic - - useAddBackButton(navigation) - - const signerAccounts = useSignerAccounts() - const targetMnemonicId = (isRestoringMnemonic && signerAccounts[0]?.mnemonicId) || undefined - - const handleNext = useCallback( - async (storedMnemonicId: string) => { - await generateImportedAccounts(storedMnemonicId, BackupType.Manual) - - // restore flow is handled in saga after `restoreMnemonicComplete` is dispatched - if (!isRestoringMnemonic) { - navigation.navigate({ name: OnboardingScreens.SelectWallet, params, merge: true }) - } - }, - [generateImportedAccounts, isRestoringMnemonic, navigation, params], - ) - - const onPressRecoveryHelpButton = (): Promise => openUri(uniswapUrls.helpArticleUrls.recoveryPhraseHowToImport) - - const onPressTryAgainButton = (): void => { - navigation.replace(OnboardingScreens.RestoreCloudBackupLoading, params) - } - - return ( - - - - } - minHeightWhenKeyboardExpanded={false} - subtitle={ - isRestoringMnemonic - ? t('account.recoveryPhrase.subtitle.restoring') - : t('account.recoveryPhrase.subtitle.import') - } - title={ - isRestoringMnemonic ? t('account.recoveryPhrase.title.restoring') : t('account.recoveryPhrase.title.import') - } - > - ): void => - setSubmitEnabled(e.nativeEvent.canSubmit) - } - onMnemonicStored={(e: NativeSyntheticEvent): Promise => - handleNext(e.nativeEvent.mnemonicId) - } - onPasteEnd={(): void => { - setPastePermissionModalOpen(false) - }} - onPasteStart={(): void => { - setPastePermissionModalOpen(true) - }} - /> - - - - - - - {isRestoringMnemonic - ? t('account.recoveryPhrase.helpText.restoring') - : t('account.recoveryPhrase.helpText.import')} - - - - - - ) -} diff --git a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx index efc3763cda0..950d1067286 100644 --- a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx +++ b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx @@ -12,6 +12,7 @@ import { useCompleteOnboardingCallback } from 'src/features/onboarding/hooks' import { useAddBackButton } from 'src/utils/useAddBackButton' import { Button, Flex, Text } from 'ui/src' import { GraduationCap } from 'ui/src/components/icons' +import { usePortfolioBalances } from 'uniswap/src/features/dataApi/balances' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TestID } from 'uniswap/src/test/fixtures/testIDs' @@ -19,7 +20,7 @@ import { UniverseChainId } from 'uniswap/src/types/chains' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' import { areAddressesEqual, getValidAddress } from 'uniswap/src/utils/addresses' import { normalizeTextInput } from 'utilities/src/primitives/string' -import { usePortfolioBalances } from 'wallet/src/features/dataApi/balances' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { useENS } from 'wallet/src/features/ens/useENS' import { createViewOnlyAccount } from 'wallet/src/features/onboarding/createViewOnlyAccount' import { useIsSmartContractAddress } from 'wallet/src/features/transactions/transfer/hooks/useIsSmartContractAddress' @@ -90,10 +91,13 @@ export function WatchWalletScreen({ navigation, route: { params } }: Props): JSX (validAddress || resolvedAddress) ?? undefined, UniverseChainId.Mainnet, ) + const address = isSmartContractAddress ? (validAddress || resolvedAddress) ?? undefined : undefined + const valueModifiers = usePortfolioValueModifiers(address) // Allow smart contracts with non-null balances const { data: balancesById } = usePortfolioBalances({ - address: isSmartContractAddress ? (validAddress || resolvedAddress) ?? undefined : undefined, + address, fetchPolicy: 'cache-and-network', + valueModifiers, }) const isValidSmartContract = isSmartContractAddress && !!balancesById diff --git a/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap b/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap index c3982f145f3..620de7be392 100644 --- a/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap +++ b/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupPasswordScreen.test.tsx.snap @@ -314,7 +314,7 @@ exports[`RestoreCloudBackupPasswordScreen renders correctly 1`] = ` { "color": "#FFFFFF", "fontFamily": "Basel-Medium", - "fontSize": 19, + "fontSize": 17, "fontWeight": "500", "lineHeight": 24, } diff --git a/apps/mobile/src/screens/Import/__snapshots__/SeedPhraseInputScreen.test.tsx.snap b/apps/mobile/src/screens/Import/__snapshots__/SeedPhraseInputScreen.test.tsx.snap deleted file mode 100644 index 5b324912380..00000000000 --- a/apps/mobile/src/screens/Import/__snapshots__/SeedPhraseInputScreen.test.tsx.snap +++ /dev/null @@ -1,696 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SeedPhraseInputScreen seed phrase initial screen rendering 1`] = ` - - - - - - - - Enter your recovery phrase - - - Your recovery phrase will only be stored locally on your device. - - - - - - - - - - - - - Type your recovery phrase - - - - - - - - Paste - - - - - - - - - - - - - - - - - - - - - - - - - How do I find my recovery phrase? - - - - - - - Continue - - - - - - - - ExpoLinearGradient - -`; diff --git a/apps/mobile/src/screens/Import/useOnDeviceRecoveryData.ts b/apps/mobile/src/screens/Import/useOnDeviceRecoveryData.ts index d06e24e243c..4573e1e863e 100644 --- a/apps/mobile/src/screens/Import/useOnDeviceRecoveryData.ts +++ b/apps/mobile/src/screens/Import/useOnDeviceRecoveryData.ts @@ -3,7 +3,6 @@ import { useMultiplePortfolioBalancesQuery } from 'uniswap/src/data/graphql/unis import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' import { areAddressesEqual } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' -// eslint-disable-next-line no-restricted-imports import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { useENSName } from 'wallet/src/features/ens/api' import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' diff --git a/apps/mobile/src/screens/ModalAwareView.tsx b/apps/mobile/src/screens/ModalAwareView.tsx index b2271475022..eeaaee36248 100644 --- a/apps/mobile/src/screens/ModalAwareView.tsx +++ b/apps/mobile/src/screens/ModalAwareView.tsx @@ -1,7 +1,7 @@ import { BottomSheetDraggableView } from '@gorhom/bottom-sheet' import React from 'react' import { View } from 'react-native' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' import { selectModalState } from 'src/features/modals/selectModalState' import { Flex, flexStyles, useDeviceInsets, useSporeColors } from 'ui/src' @@ -17,7 +17,7 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' * uses a compatible virtualized list when rendered within a bottom sheet modal). */ export function ExploreModalAwareView({ children }: { children: JSX.Element }): JSX.Element { - const inModal = useAppSelector(selectModalState(ModalName.Explore)).isOpen + const inModal = useSelector(selectModalState(ModalName.Explore)).isOpen const colors = useSporeColors() const insets = useDeviceInsets() diff --git a/apps/mobile/src/screens/NFTItemScreen.tsx b/apps/mobile/src/screens/NFTItemScreen.tsx index f8f94e583f8..c75983e58d3 100644 --- a/apps/mobile/src/screens/NFTItemScreen.tsx +++ b/apps/mobile/src/screens/NFTItemScreen.tsx @@ -5,8 +5,7 @@ import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { StatusBar, StyleSheet, TouchableOpacity } from 'react-native' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { AppStackScreenProp, useAppStackNavigation } from 'src/app/navigation/types' import { HeaderScrollScreen } from 'src/components/layout/screens/HeaderScrollScreen' import { Loader } from 'src/components/loading' @@ -133,7 +132,7 @@ function NFTItemScreenContents({ } } - const inModal = useAppSelector(selectModalState(ModalName.Explore)).isOpen + const inModal = useSelector(selectModalState(ModalName.Explore)).isOpen const traceProperties: Record> = useMemo(() => { const baseProps = { diff --git a/apps/mobile/src/screens/ReceiveCryptoModal.tsx b/apps/mobile/src/screens/ReceiveCryptoModal.tsx index bb3330df2a2..323d8942319 100644 --- a/apps/mobile/src/screens/ReceiveCryptoModal.tsx +++ b/apps/mobile/src/screens/ReceiveCryptoModal.tsx @@ -1,7 +1,6 @@ import { SharedEventName } from '@uniswap/analytics-events' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' import { ServiceProviderSelector } from 'src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector' import { closeModal, openModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' @@ -100,7 +99,7 @@ export function ReceiveCryptoModal(): JSX.Element { const colors = useSporeColors() const dispatch = useDispatch() const { t } = useTranslation() - const { initialState } = useAppSelector(selectModalState(ModalName.ReceiveCryptoModal)) + const { initialState } = useSelector(selectModalState(ModalName.ReceiveCryptoModal)) const onClose = (): void => { dispatch(closeModal({ name: ModalName.ReceiveCryptoModal })) diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index bc256a75f9e..b4a62e68c22 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -1,29 +1,25 @@ import { useNavigation } from '@react-navigation/core' -import { default as React, useCallback, useMemo, useState } from 'react' +import { default as React, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Image, ListRenderItemInfo, SectionList, StyleSheet } from 'react-native' -import { FadeInDown, FadeOutUp } from 'react-native-reanimated' +import { ListRenderItemInfo, SectionList } from 'react-native' import { SvgProps } from 'react-native-svg' import { useDispatch } from 'react-redux' -import { - OnboardingStackNavigationProp, - SettingsStackNavigationProp, - useSettingsStackNavigation, -} from 'src/app/navigation/types' +import { OnboardingStackNavigationProp, SettingsStackNavigationProp } from 'src/app/navigation/types' +import { FooterSettings } from 'src/components/Settings/FooterSettings' +import { OnboardingRow } from 'src/components/Settings/OnboardingRow' import { SettingsRow, SettingsSection, SettingsSectionItem, SettingsSectionItemComponent, } from 'src/components/Settings/SettingsRow' +import { WalletSettings } from 'src/components/Settings/WalletSettings' import { HeaderScrollScreen } from 'src/components/layout/screens/HeaderScrollScreen' import { APP_FEEDBACK_LINK } from 'src/constants/urls' import { useBiometricContext } from 'src/features/biometrics/context' import { useBiometricName, useDeviceSupportsBiometricAuth } from 'src/features/biometrics/hooks' import { useWalletRestore } from 'src/features/wallet/hooks' -import { getFullAppVersion } from 'src/utils/version' -import { Button, Flex, IconProps, Text, TouchableArea, useDeviceInsets, useIsDarkMode, useSporeColors } from 'ui/src' -import { AVATARS_DARK, AVATARS_LIGHT } from 'ui/src/assets' +import { Flex, IconProps, Text, useDeviceInsets, useSporeColors } from 'ui/src' import BookOpenIcon from 'ui/src/assets/icons/book-open.svg' import ContrastIcon from 'ui/src/assets/icons/contrast.svg' import FaceIdIcon from 'ui/src/assets/icons/faceid.svg' @@ -39,38 +35,25 @@ import { Language, LineChartDots, OSDynamicCloudIcon, - RotatableChevron, ShieldQuestion, } from 'ui/src/components/icons' -import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { iconSizes, spacing } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' import { isAndroid } from 'utilities/src/platform' -import { ONE_SECOND_MS } from 'utilities/src/time/time' -import { useTimeout } from 'utilities/src/time/timing' -import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { useCurrentAppearanceSetting } from 'wallet/src/features/appearance/hooks' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks' -import { AccountType, BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' import { - useAccounts, useHideSmallBalancesSetting, useHideSpamTokensSetting, useSignerAccounts, } from 'wallet/src/features/wallet/hooks' -import { - resetWallet, - setFinishedOnboarding, - setHideSmallBalances, - setHideSpamTokens, -} from 'wallet/src/features/wallet/slice' +import { setHideSmallBalances, setHideSpamTokens } from 'wallet/src/features/wallet/slice' export function SettingsScreen(): JSX.Element { const navigation = useNavigation() @@ -80,8 +63,6 @@ export function SettingsScreen(): JSX.Element { const { deviceSupportsBiometrics } = useBiometricContext() const { t } = useTranslation() - const currencyConversionEnabled = useFeatureFlag(FeatureFlags.CurrencyConversion) - // check if device supports biometric authentication, if not, hide option const { touchId: isTouchIdSupported, faceId: isFaceIdSupported } = useDeviceSupportsBiometricAuth() @@ -139,17 +120,12 @@ export function SettingsScreen(): JSX.Element { : t('settings.setting.appearance.option.light.title'), icon: , }, - - ...(currencyConversionEnabled - ? ([ - { - modal: ModalName.FiatCurrencySelector, - text: t('settings.setting.currency.title'), - currentSetting: currentFiatCurrencyInfo.code, - icon: , - }, - ] as SettingsSectionItem[]) - : []), + { + modal: ModalName.FiatCurrencySelector, + text: t('settings.setting.currency.title'), + currentSetting: currentFiatCurrencyInfo.code, + icon: , + }, { modal: ModalName.LanguageSelector, text: t('settings.setting.language.title'), @@ -283,7 +259,6 @@ export function SettingsScreen(): JSX.Element { colors.neutral2, t, currentAppearanceSetting, - currencyConversionEnabled, currentFiatCurrencyInfo.code, currentLanguage, hideSmallBalances, @@ -339,160 +314,3 @@ export function SettingsScreen(): JSX.Element { } const renderItemSeparator = (): JSX.Element => - -function OnboardingRow({ iconProps }: { iconProps: SvgProps }): JSX.Element { - const dispatch = useDispatch() - const navigation = useSettingsStackNavigation() - - return ( - { - navigation.goBack() - dispatch(resetWallet()) - dispatch(setFinishedOnboarding({ finishedOnboarding: false })) - }} - > - - - - - - - Onboarding - - - - - - ) -} - -const DEFAULT_ACCOUNTS_TO_DISPLAY = 6 - -function WalletSettings(): JSX.Element { - const { t } = useTranslation() - const navigation = useSettingsStackNavigation() - const addressToAccount = useAccounts() - const [showAll, setShowAll] = useState(false) - - const allAccounts = useMemo(() => { - const accounts = Object.values(addressToAccount) - const _mnemonicWallets = accounts - .filter((a): a is SignerMnemonicAccount => a.type === AccountType.SignerMnemonic) - .sort((a, b) => { - return a.derivationIndex - b.derivationIndex - }) - const _viewOnlyWallets = accounts - .filter((a) => a.type === AccountType.Readonly) - .sort((a, b) => { - return a.timeImportedMs - b.timeImportedMs - }) - return [..._mnemonicWallets, ..._viewOnlyWallets] - }, [addressToAccount]) - - const toggleViewAll = (): void => { - setShowAll(!showAll) - } - - const handleNavigation = (address: string): void => { - navigation.navigate(MobileScreens.SettingsWallet, { address }) - } - - return ( - - - - {t('settings.section.wallet.title')} - - - {allAccounts.slice(0, showAll ? allAccounts.length : DEFAULT_ACCOUNTS_TO_DISPLAY).map((account) => { - const isViewOnlyWallet = account.type === AccountType.Readonly - - return ( - handleNavigation(account.address)} - > - - - - - - ) - })} - {allAccounts.length > DEFAULT_ACCOUNTS_TO_DISPLAY && ( - - )} - - ) -} - -function FooterSettings(): JSX.Element { - const { t } = useTranslation() - const [showSignature, setShowSignature] = useState(false) - const isDarkMode = useIsDarkMode() - - // Fade out signature after duration - useTimeout( - showSignature - ? (): void => { - setShowSignature(false) - } - : (): void => undefined, - SIGNATURE_VISIBLE_DURATION, - ) - - return ( - - {showSignature ? ( - - - - {t('settings.footer')} - - - {isDarkMode ? ( - - ) : ( - - )} - - ) : null} - { - setShowSignature(true) - }} - > - {t('settings.version', { appVersion: getFullAppVersion() })} - - - ) -} - -const ImageStyles = StyleSheet.create({ - responsiveImage: { - aspectRatio: 135 / 76, - height: undefined, - width: '100%', - }, -}) - -const SIGNATURE_VISIBLE_DURATION = ONE_SECOND_MS * 10 diff --git a/apps/mobile/src/screens/TokenDetailsScreen.tsx b/apps/mobile/src/screens/TokenDetailsScreen.tsx index f2518b5e759..22ce66ea92d 100644 --- a/apps/mobile/src/screens/TokenDetailsScreen.tsx +++ b/apps/mobile/src/screens/TokenDetailsScreen.tsx @@ -3,10 +3,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ContextMenu from 'react-native-context-menu-view' import { FadeIn, FadeInDown, FadeOutDown } from 'react-native-reanimated' -import { useAppSelector } from 'src/app/hooks' +import { useSelector } from 'react-redux' import { AppStackScreenProp } from 'src/app/navigation/types' import { PriceExplorer } from 'src/components/PriceExplorer/PriceExplorer' import { useTokenPriceHistory } from 'src/components/PriceExplorer/usePriceHistory' +import { BuyNativeTokenModal } from 'src/components/TokenDetails/BuyNativeTokenModal' import { TokenBalances } from 'src/components/TokenDetails/TokenBalances' import { TokenDetailsActionButtons } from 'src/components/TokenDetails/TokenDetailsActionButtons' import { TokenDetailsFavoriteButton } from 'src/components/TokenDetails/TokenDetailsFavoriteButton' @@ -26,6 +27,7 @@ import { fonts, iconSizes, spacing } from 'ui/src/theme' import { useExtractedTokenColor } from 'ui/src/utils/colors' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' +import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { PollingInterval } from 'uniswap/src/constants/misc' import { SafetyLevel, @@ -39,17 +41,26 @@ import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName } from 'uniswap/src/features/telemetry/constants' import TokenWarningModal from 'uniswap/src/features/tokens/TokenWarningModal' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { UniverseChainId } from 'uniswap/src/types/chains' import { MobileScreens } from 'uniswap/src/types/screens/mobile' -import { currencyIdToAddress, currencyIdToChain } from 'uniswap/src/utils/currencyId' +import { + buildCurrencyId, + currencyIdToAddress, + currencyIdToChain, + isNativeCurrencyAddress, +} from 'uniswap/src/utils/currencyId' import { NumberType } from 'utilities/src/format/types' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils' +import { useIsSupportedFiatOnRampCurrency } from 'wallet/src/features/fiatOnRamp/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { Language } from 'wallet/src/features/language/constants' import { useCurrentLanguage } from 'wallet/src/features/language/hooks' +import { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api' import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu' import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' function HeaderTitleElement({ data, @@ -178,6 +189,22 @@ function TokenDetails({ const crossChainTokens = token?.project?.tokens const { currentChainBalance, otherChainBalances } = useCrossChainBalances(_currencyId, crossChainTokens) + const hasTokenBalance = Boolean(currentChainBalance) + const isNativeCurrency = isNativeCurrencyAddress(currencyChainId, currencyAddress) + + const activeAddress = useActiveAccountAddressWithThrow() + const { balance: nativeCurrencyBalance } = useOnChainNativeCurrencyBalance(currencyChainId, activeAddress) + const hasZeroNativeBalance = nativeCurrencyBalance && nativeCurrencyBalance.equalTo('0') + const nativeCurrencyAddress = UNIVERSE_CHAIN_INFO[currencyChainId].nativeCurrency.address + const nativeFiatOnRampCurrency = useIsSupportedFiatOnRampCurrency( + buildCurrencyId(currencyChainId, nativeCurrencyAddress), + isNativeCurrency || !hasZeroNativeBalance, + ) + + const fiatOnRampCurrency = useIsSupportedFiatOnRampCurrency(_currencyId, !isNativeCurrency) + const shouldNavigateToFiatOnRampOnBuy = !hasTokenBalance && Boolean(fiatOnRampCurrency) && isNativeCurrency + const shouldOpenBuyNativeTokenModalOnBuy = + Boolean(nativeFiatOnRampCurrency) && !isNativeCurrency && hasZeroNativeBalance const { tokenColor, tokenColorLoading } = useExtractedTokenColor( tokenLogoUrl, @@ -193,16 +220,22 @@ function TokenDetails({ retry() }, [error, retry]) - const { navigateToSwapFlow, navigateToSend } = useWalletNavigation() + const { navigateToFiatOnRamp, navigateToSwapFlow, navigateToSend } = useWalletNavigation() // set if attempting buy or sell, use for warning modal const [activeTransactionType, setActiveTransactionType] = useState(undefined) const [showWarningModal, setShowWarningModal] = useState(false) + const [showBuyNativeTokenModal, setShowBuyNativeTokenModal] = useState(false) const { tokenWarningDismissed, dismissWarningCallback } = useTokenWarningDismissed(_currencyId) const safetyLevel = token?.project?.safetyLevel + const onPressSend = useCallback(() => { + // Do not show warning modal speedbump if user is trying to send tokens they own + navigateToSend({ currencyAddress, chainId: currencyChainId }) + }, [currencyAddress, currencyChainId, navigateToSend]) + const onPressSwap = useCallback( (currencyField: CurrencyField) => { if (safetyLevel === SafetyLevel.Blocked) { @@ -219,10 +252,29 @@ function TokenDetails({ [currencyAddress, currencyChainId, navigateToSwapFlow, safetyLevel, tokenWarningDismissed], ) - const onPressSend = useCallback(() => { - // Do not show warning modal speedbump if user is trying to send tokens they own - navigateToSend({ currencyAddress, chainId: currencyChainId }) - }, [currencyAddress, currencyChainId, navigateToSend]) + const onPressBuyFiatOnRamp = useCallback((): void => { + navigateToFiatOnRamp({ prefilledCurrency: fiatOnRampCurrency }) + }, [navigateToFiatOnRamp, fiatOnRampCurrency]) + + const onPressBuyZeroBalance = useCallback(() => { + setShowBuyNativeTokenModal(true) + }, []) + + const onPressBuy = useCallback(() => { + if (shouldOpenBuyNativeTokenModalOnBuy) { + onPressBuyZeroBalance() + } else if (shouldNavigateToFiatOnRampOnBuy) { + onPressBuyFiatOnRamp() + } else { + onPressSwap(CurrencyField.OUTPUT) + } + }, [ + onPressBuyFiatOnRamp, + onPressBuyZeroBalance, + shouldOpenBuyNativeTokenModalOnBuy, + shouldNavigateToFiatOnRampOnBuy, + onPressSwap, + ]) const onAcceptWarning = useCallback(() => { dismissWarningCallback() @@ -232,7 +284,7 @@ function TokenDetails({ } }, [activeTransactionType, currencyAddress, currencyChainId, dismissWarningCallback, navigateToSwapFlow]) - const inModal = useAppSelector(selectModalState(ModalName.Explore)).isOpen + const inModal = useSelector(selectModalState(ModalName.Explore)).isOpen const loadingColor = isDarkMode ? colors.neutral3.val : colors.surface3.val @@ -300,8 +352,8 @@ function TokenDetails({ onPressSwap(CurrencyField.OUTPUT)} + userHasBalance={hasTokenBalance} + onPressBuy={onPressBuy} onPressSell={(): void => onPressSwap(CurrencyField.INPUT)} /> @@ -319,6 +371,16 @@ function TokenDetails({ setShowWarningModal(false) }} /> + + {showBuyNativeTokenModal && ( + { + setShowWarningModal(false) + }} + /> + )} ) } @@ -381,6 +443,7 @@ function HeaderRightElement({ hapticFeedback hitSlop={{ right: 5, left: 20, top: 20, bottom: 20 }} style={{ padding: spacing.spacing8, marginRight: -spacing.spacing8 }} + testID={TestID.TokenDetailsMoreButton} onLongPress={disableOnPress} onPress={disableOnPress} > diff --git a/apps/mobile/src/screens/WebViewScreen.tsx b/apps/mobile/src/screens/WebViewScreen.tsx index 96464583161..2761d6425f4 100644 --- a/apps/mobile/src/screens/WebViewScreen.tsx +++ b/apps/mobile/src/screens/WebViewScreen.tsx @@ -33,9 +33,7 @@ function ZendeskWebView({ uriLink }: { uriLink: string }): JSX.Element { const onNavigationStateChange = useCallback( ({ url }: { url: string }): void => { - // Ok to ignore because `uniswapUrls.helpUrl` is hardcoded into our code. - // eslint-disable-next-line security/detect-non-literal-regexp - if (zendeskInjectJs && new RegExp(`${uniswapUrls.helpUrl}.+/requests/new`).test(url)) { + if (zendeskInjectJs && url.startsWith(`${uniswapUrls.helpUrl}/requests/new`)) { webviewRef.current?.injectJavaScript(zendeskInjectJs) } }, diff --git a/apps/mobile/src/test/fixtures/explore.ts b/apps/mobile/src/test/fixtures/explore.ts index e07b29ee36e..544e2c96921 100644 --- a/apps/mobile/src/test/fixtures/explore.ts +++ b/apps/mobile/src/test/fixtures/explore.ts @@ -1,4 +1,4 @@ -import { TokenItemData } from 'src/components/explore/TokenItem' +import { TokenItemData } from 'src/components/explore/TokenItemData' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { createFixture } from 'uniswap/src/test/utils' diff --git a/apps/mobile/src/utils/useSagaStatus.ts b/apps/mobile/src/utils/useSagaStatus.ts index 2b8e6129ea2..9291cc1c4e7 100644 --- a/apps/mobile/src/utils/useSagaStatus.ts +++ b/apps/mobile/src/utils/useSagaStatus.ts @@ -1,13 +1,13 @@ import { useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useDispatch, useSelector } from 'react-redux' +import { MobileState } from 'src/app/reducer' import { monitoredSagas } from 'src/app/saga' import { SagaState, SagaStatus } from 'wallet/src/utils/saga' // Convenience hook to get the status + error of an active saga export function useSagaStatus(sagaName: string, onSuccess?: () => void, resetSagaOnSuccess = true): SagaState { const dispatch = useDispatch() - const sagaState = useAppSelector((s): SagaState | undefined => s.saga[sagaName]) + const sagaState = useSelector((s: MobileState): SagaState | undefined => s.saga[sagaName]) if (!sagaState) { throw new Error(`No saga state found, is sagaName valid? Name: ${sagaName}`) } diff --git a/apps/web/.depcheckrc b/apps/web/.depcheckrc index c5218e31f77..c58a83ff836 100644 --- a/apps/web/.depcheckrc +++ b/apps/web/.depcheckrc @@ -67,5 +67,6 @@ ignores: [ "utils", "i18n", "tamagui.config", - "setupRive" + "setupRive", + "sideEffects" ] diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 713f9fe66e3..6464eb029e0 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -58,6 +58,14 @@ module.exports = { 'error', { paths: [ + { + name: 'react-i18next', + message: 'Import from `uniswap/src/i18n` instead.' + }, + { + name: 'i18next', + message: 'Import from `uniswap/src/i18n` instead.' + }, { name: 'styled-components', message: 'Styled components is deprecated, please use Flex or styled from "ui/src" instead.' @@ -108,6 +116,16 @@ module.exports = { importNames: ['useConnect'], message: 'Import wrapped useConnect util from `hooks/useConnect` instead.', }, + { + name: 'wagmi', + importNames: ['useDisconnect'], + message: 'Import wrapped useDisconnect util from `hooks/useDisconnect` instead.', + }, + { + name: 'wagmi', + importNames: ['useBlockNumber', 'useWatchBlockNumber'], + message: 'Import wrapped useBlockNumber util from `hooks/useBlockNumber` instead.', + }, ], }, ], diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 6b51f83165f..e71747f7c4c 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,5 +1,6 @@ # See https://help.github.com/ignore-files/ for more about ignoring files. +.tamagui # generated contract types /src/i18n/locales/translations diff --git a/apps/web/craco.config.cjs b/apps/web/craco.config.cjs index ab2a64563eb..496e42d7526 100644 --- a/apps/web/craco.config.cjs +++ b/apps/web/craco.config.cjs @@ -6,12 +6,13 @@ const { readFileSync } = require('fs') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const path = require('path') const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin') -const { IgnorePlugin, ProvidePlugin } = require('webpack') +const { IgnorePlugin, ProvidePlugin, DefinePlugin } = require('webpack') const { RetryChunkLoadPlugin } = require('webpack-retry-chunk-load-plugin') const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer') const commitHash = execSync('git rev-parse HEAD').toString().trim() const isProduction = process.env.NODE_ENV === 'production' +const isDev = process.env.NODE_ENV === 'development' process.env.REACT_APP_GIT_COMMIT_HASH = commitHash @@ -64,7 +65,7 @@ module.exports = { cacheDirectory: getCacheDirectory('jest'), transform: { ...Object.entries(jestConfig.transform).reduce((transform, [key, value]) => { - if (value.match(/babel/)) return transform + if (value.match(/babel/)) {return transform} return { ...transform, [key]: value } }, {}), // Transform vanilla-extract using its own transformer. @@ -85,6 +86,9 @@ module.exports = { }, webpack: { plugins: [ + new DefinePlugin({ + __DEV__: isDev, + }), // Webpack 5 does not polyfill node globals, so we do so for those necessary: new ProvidePlugin({ // - react-markdown requires process.cwd @@ -103,6 +107,10 @@ module.exports = { }), ], configure: (webpackConfig) => { + webpackConfig.resolve.extensions.unshift('.web.tsx') + webpackConfig.resolve.extensions.unshift('.web.ts') + webpackConfig.resolve.extensions.unshift('.web.js') + if (isProduction || process.env.UNISWAP_ANALYZE_BUNDLE_SIZE) { // do bundle analysis webpackConfig.plugins.push( @@ -133,10 +141,10 @@ module.exports = { .filter((plugin) => { // Case sensitive paths are already enforced by TypeScript. // See https://www.typescriptlang.org/tsconfig#forceConsistentCasingInFileNames. - if (plugin instanceof CaseSensitivePathsPlugin) return false + if (plugin instanceof CaseSensitivePathsPlugin) {return false} // IgnorePlugin is used to tree-shake moment locales, but we do not use moment in this project. - if (plugin instanceof IgnorePlugin) return false + if (plugin instanceof IgnorePlugin) {return false} return true }) @@ -192,6 +200,47 @@ module.exports = { return rule }) + // add tamagui compiler for web files + // it does three stages (loaders run last to first): + // 1. esbuild-loader just to strip types + // 2. tamagui-loader optimizes and adds helpful dev data- attributes + // 3. then swc finishes using our options + webpackConfig.module.rules[1].oneOf.unshift({ + test: /.tsx?$/, + exclude: (file) => file.includes('node_modules'), + use: [ + // one after to remove the jsx + { + loader: 'swc-loader', + options: swcrc + }, + + // tamagui optimizes the jsx + { + loader: 'tamagui-loader', + options: { + config: '../../packages/ui/src/tamagui.config.ts', + components: ['ui'], + // add files here that should be parsed by the compiler from within any of the apps/* + // for example if you have constants.ts then constants.js goes here and it will eval them + // at build time and if it can flatten views even if they use imports from that file + importsWhitelist: ['constants.js'], + disableExtraction: process.env.NODE_ENV === 'development', + }, + }, + + // one before just to remove types + { + loader: 'esbuild-loader', + options: { + target: 'es2022', + jsx: 'preserve', + minify: false, + }, + }, + ], + }) + // since wallet package uses react-native-dotenv and that needs a babel plugin // adding this before the swc loader webpackConfig.module.rules[1].oneOf.unshift({ diff --git a/apps/web/cypress/e2e/add-liquidity.test.ts b/apps/web/cypress/e2e/add-liquidity.test.ts index cee79d0b7ed..e3c05fdd661 100644 --- a/apps/web/cypress/e2e/add-liquidity.test.ts +++ b/apps/web/cypress/e2e/add-liquidity.test.ts @@ -1,5 +1,4 @@ -import { getTestSelector, resetHardhatChain } from '../utils' -import { waitsForActiveChain } from './wallet-connection/switch-network.test' +import { getTestSelector, resetHardhatChain, waitsForActiveChain } from '../utils' describe('Add Liquidity', () => { it('loads the token pair', () => { diff --git a/apps/web/cypress/e2e/buy-crypto-form.test.ts b/apps/web/cypress/e2e/buy-crypto-form.test.ts new file mode 100644 index 00000000000..7d56c898614 --- /dev/null +++ b/apps/web/cypress/e2e/buy-crypto-form.test.ts @@ -0,0 +1,44 @@ +import { FeatureFlags } from "uniswap/src/features/gating/flags" +import { getTestSelector } from "../utils" + +describe('Buy Crypto Form', () => { + beforeEach(() => { + cy.intercept('*/fiat-on-ramp/get-country', { fixture: 'fiatOnRamp/get-country.json' }) + cy.intercept('*/fiat-on-ramp/supported-fiat-currencies*', { fixture: 'fiatOnRamp/supported-fiat-currencies.json' }) + cy.intercept('*/fiat-on-ramp/supported-countries*', { fixture: 'fiatOnRamp/supported-countries.json' }) + cy.intercept('*/fiat-on-ramp/supported-tokens*', { fixture: 'fiatOnRamp/supported-tokens.json' }) + cy.intercept('*/fiat-on-ramp/quote*', { fixture: 'fiatOnRamp/quotes.json' }) + cy.visit('/buy', {featureFlags: [{flag: FeatureFlags.ForAggregator, value: true}]}) + }) + + it('quick amount select', () => { + cy.contains('$100').click() + cy.contains('Continue').click() + + cy.get('#ChooseProviderModal').should('be.visible') + }) + + it('user input amount', () => { + cy.get(getTestSelector('buy-form-amount-input')).type('123').should('have.value', '123') + cy.contains('Continue').click() + + cy.get('#ChooseProviderModal').should('be.visible') + }) + + it('change input token', () => { + cy.contains('ETH').click() + cy.contains('DAI').click() + cy.get(getTestSelector('buy-form-amount-input')).type('123').should('have.value', '123') + cy.contains('123.00') + cy.contains('Continue').click() + cy.get('#ChooseProviderModal').should('be.visible') + }) + + it('change country', () => { + cy.get(getTestSelector('FiatOnRampCountryPicker')).click() + cy.contains('Argentina').click() + cy.get(getTestSelector('buy-form-amount-input')).type('123').should('have.value', '123') + cy.contains('Continue').click() + cy.get('#ChooseProviderModal').should('be.visible') + }) +}) diff --git a/apps/web/cypress/e2e/buy-crypto-modal.test.ts b/apps/web/cypress/e2e/buy-crypto-modal.test.ts deleted file mode 100644 index 2a53eb1c6f5..00000000000 --- a/apps/web/cypress/e2e/buy-crypto-modal.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getTestSelector } from '../utils' - -describe('Buy Crypto Modal', () => { - it('should open and close', () => { - cy.intercept('https://api.moonpay.com/v4/ip_address?apiKey=*', { fixture: 'moonpay/ip_address_valid.json' }) - cy.visit('/') - - // Open the fiat onramp modal - cy.get(getTestSelector('buy-fiat-button')).click() - cy.get(getTestSelector('fiat-onramp-modal')).should('be.visible') - - // Click on a location that should be outside the modal, which should close it - cy.get('body').click(0, 100) - cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist') - }) - - it('should open and close, mobile viewport', () => { - cy.intercept('https://api.moonpay.com/v4/ip_address?apiKey=*', { fixture: 'moonpay/ip_address_valid.json' }) - cy.viewport('iphone-6') - cy.visit('/') - - // Open the fiat onramp modal - cy.get(getTestSelector('buy-fiat-button')).click() - cy.get(getTestSelector('fiat-onramp-modal')).should('be.visible') - - // Click on a location that should be outside the modal, which should close it - cy.get('body').click(10, 10) - cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist') - }) - - it("should not open if the user's region is not supported", () => { - cy.intercept('https://api.moonpay.com/v4/ip_address?apiKey=*', { fixture: 'moonpay/ip_address_invalid.json' }) - cy.visit('/') - - // Try to open the fiat onramp modal - cy.get(getTestSelector('buy-fiat-button')).click() - cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist') - }) -}) diff --git a/apps/web/cypress/e2e/landing.test.ts b/apps/web/cypress/e2e/landing.test.ts index 29db41702a1..e4673e05c8f 100644 --- a/apps/web/cypress/e2e/landing.test.ts +++ b/apps/web/cypress/e2e/landing.test.ts @@ -1,5 +1,7 @@ import { getTestSelector } from '../utils' +const DOWNLOAD_APP_MODAL_TITLE = 'Get started with Uniswap' + describe('Landing Page', () => { it('shows landing page when no user state exists', () => { cy.visit('/', { eagerlyConnect: false }) @@ -34,15 +36,18 @@ describe('Landing Page', () => { it('allows navigation to pool', () => { cy.viewport(2000, 1600) cy.visit('/swap') - cy.get(getTestSelector('pool-nav-link')).first().click() + cy.get(getTestSelector('Pool-tab')).first().click() cy.url().should('include', '/pool') }) it('allows navigation to pool on mobile', () => { cy.viewport('iphone-6') cy.visit('/swap') - cy.get(getTestSelector('pool-nav-link')).last().click() - cy.url().should('include', '/pool') + cy.get(getTestSelector('nav-company-menu')).should('be.visible').click() + cy.get(getTestSelector('company-menu-mobile-drawer')).should('be.visible').within(() => { + cy.contains('Pool').should('be.visible').click() + cy.url().should('include', '/pool') + }) }) it('does not render landing page when / path is blocked', () => { @@ -106,7 +111,7 @@ describe('Landing Page', () => { it('hides call to action text on small screen sizes', () => { cy.viewport('iphone-8') cy.visit('/?intro=true') - cy.get(getTestSelector('get-the-app-cta')).should('not.be.visible') + cy.contains('Get the app').should('not.exist') }) it('opens modal when Get-the-App button is selected', () => { @@ -114,7 +119,7 @@ describe('Landing Page', () => { cy.get('nav').within(() => { cy.contains('Get the app').should('exist').click() }) - cy.contains('Download the Uniswap app').should('exist') + cy.contains(DOWNLOAD_APP_MODAL_TITLE).should('exist') }) it('closes modal when close button is selected', () => { @@ -122,9 +127,9 @@ describe('Landing Page', () => { cy.get('nav').within(() => { cy.contains('Get the app').should('exist').click() }) - cy.contains('Download the Uniswap app').should('exist') + cy.contains(DOWNLOAD_APP_MODAL_TITLE).should('exist') cy.get(getTestSelector('get-the-app-close-button')).click() - cy.contains('Download the Uniswap app').should('not.exist') + cy.contains(DOWNLOAD_APP_MODAL_TITLE).should('not.exist') }) it('closes modal when user selects area outside of modal', () => { @@ -132,8 +137,8 @@ describe('Landing Page', () => { cy.get('nav').within(() => { cy.contains('Get the app').should('exist').click() }) - cy.contains('Download the Uniswap app').should('exist') + cy.contains(DOWNLOAD_APP_MODAL_TITLE).should('exist') cy.get('nav').click({ force: true }) - cy.contains('Download the Uniswap app').should('not.exist') + cy.contains(DOWNLOAD_APP_MODAL_TITLE).should('not.exist') }) }) diff --git a/apps/web/cypress/e2e/mini-portfolio/accounts.test.ts b/apps/web/cypress/e2e/mini-portfolio/accounts.test.ts index b8794abbd67..3b71d52c5a3 100644 --- a/apps/web/cypress/e2e/mini-portfolio/accounts.test.ts +++ b/apps/web/cypress/e2e/mini-portfolio/accounts.test.ts @@ -115,7 +115,7 @@ describe('Mini Portfolio account drawer', () => { cy.get(getTestSelector('close-account-drawer')).click() // Switch chain to Polygon - cy.get(getTestSelector('chain-selector')).eq(1).click() + cy.get(getTestSelector('chain-selector')).click() cy.contains('Polygon').click() //Reopen account drawer diff --git a/apps/web/cypress/e2e/navigation.test.ts b/apps/web/cypress/e2e/navigation.test.ts index 43442192a1c..59421f0f38d 100644 --- a/apps/web/cypress/e2e/navigation.test.ts +++ b/apps/web/cypress/e2e/navigation.test.ts @@ -1,69 +1,197 @@ import { getTestSelector } from '../utils' +const companyMenu = [{ + label: 'Company', + items: [{ + label: 'Careers', + href: 'https://boards.greenhouse.io/uniswaplabs' + }, { + label: 'Blog', + href: 'https://blog.uniswap.org/' + }] +}, { + label: 'Protocol', + items: [{ + label: 'Governance', + href: 'https://uniswap.org/governance' + }, { + label: 'Developers', + href: 'https://uniswap.org/developers' + }] +}, { + label: 'Need help?', + items: [{ + label: 'Help center', + href: 'https://support.uniswap.org/hc/en-us' + }, { + label: 'Contact us', + href: 'https://support.uniswap.org/hc/en-us/requests/new' + }] +}] + +const tabs = [{ + label: 'Trade', + path: '/swap', + dropdown: [{ + label: 'Swap', + path: '/swap' + }, { + label: 'Limit', + path: '/limit' + }, { + label: 'Send', + path: '/send' + }] +}, { + label: 'Explore', + path: '/explore', + dropdown: [{ + label: 'Tokens', + path: '/explore/tokens' + }, { + label: 'Pools', + path: '/explore/pools' + }, { + label: 'Transactions', + path: '/explore/transactions' + }, { + label: 'NFTs', + path: '/nfts' + }] +}, { + label: 'Pool', + path: '/pool', + dropdown: [{ + label: 'View position', + path: '/pool' + }, { + label: 'Create position', + path: '/add' + }] +}] + +const socialMediaLinks = [ + 'https://github.com/Uniswap', + 'https://x.com/Uniswap', + 'https://discord.com/invite/uniswap' +] + describe('Navigation', () => { beforeEach(() => { cy.viewport(1400, 900) cy.visit('/?intro=true') }) - it('displays Swap tab', () => { + + it('clicking nav icon redirects to home page', () => { cy.get('nav').within(() => { - cy.contains('Swap').should('be.visible').click() + cy.visit('/swap') + cy.get(getTestSelector('nav-uniswap-logo')).click() + cy.url().should('include', '/?intro=true') }) - cy.url().should('include', '/swap') }) - it('displays Explore tab', () => { - cy.get('nav').within(() => { - cy.contains('Explore').should('be.visible').click() + describe('company menu', () => { + it('contains appropriate sections and links', () => { + cy.get(getTestSelector('nav-company-menu')).should('be.visible').trigger('mouseenter') + cy.get(getTestSelector('nav-company-dropdown')).within(() => { + companyMenu.forEach((section) => { + cy.contains(section.label).should('be.visible') + section.items.forEach((item) => { + cy.contains('a', item.label).invoke('attr','href').should('equal', item.href) + }) + }) + }) + }) + it('Download Uniswap opens the app modal', () => { + cy.get(getTestSelector('nav-company-menu')).should('be.visible').trigger('mouseenter') + cy.get(getTestSelector('nav-dropdown-download-app')).should('be.visible').click() + cy.get(getTestSelector('download-uniswap-modal')).should('be.visible') }) - cy.url().should('include', '/explore') }) - it('displays NFTs tab', () => { - cy.get('nav').within(() => { - cy.contains('NFTs').should('be.visible').click() + tabs.forEach((tab) => { + describe(`${tab.label} tab`, () => { + it(`displays tab`, () => { + cy.get('nav').within(() => { + cy.contains(tab.label).should('be.visible').click() + }) + cy.url().should('include', tab.path) + }) + it('expands tab with appropriate links', () => { + tab.dropdown.forEach((item) => { + cy.get(getTestSelector(`${tab.label}-tab`)).should('be.visible').trigger('mouseenter') + cy.get(getTestSelector(`${tab.label}-menu`)).should('be.visible').within(() => { + cy.contains(item.label).should('be.visible').click() + cy.url().should('include', item.path) + }) + }) + }) }) - cy.url().should('include', '/nfts') }) - it('displays Pool tab', () => { - cy.get('nav').within(() => { - cy.contains('Pool').should('be.visible').click() + it('includes social media links', () => { + socialMediaLinks.forEach((link) => { + cy.get(`a[href='${link}']`).should('be.visible') }) - cy.url().should('include', '/pool') + }) +}) + +describe('Mobile navigation', () => { + beforeEach(() => { + cy.viewport(449, 900) + cy.visit('/?intro=true') }) - describe('More Menu', () => { - it('displays more menu for additional pages and resources', () => { - cy.get('nav').within(() => { - cy.get(getTestSelector('nav-more-button')).should('be.visible').click() + it('tabs are accessible in mobile drawer', () => { + const nftLink = { label: 'NFTs', path: '/nfts' } + const tabsPlusNftLink = [...tabs, nftLink] + tabsPlusNftLink.forEach((tab) => { + cy.get(getTestSelector('nav-company-menu')).should('be.visible').click() + cy.get(getTestSelector('company-menu-mobile-drawer')).should('be.visible').within(() => { + cy.contains(tab.label).should('be.visible').click() + cy.url().should('include', tab.path) }) }) + }) - it('moves pools tab to more menu on smaller screen sizes', () => { - cy.viewport(1200, 900) - cy.visit('/?intro=true') - cy.get('nav').within(() => { - cy.contains('Pool').should('not.be.visible') - cy.get(getTestSelector('nav-more-button')).should('be.visible').click() - cy.get(getTestSelector('nav-more-menu')).within(() => { - cy.contains('Pool').should('be.visible').click() - cy.url().should('include', '/pool') + it('display settings are visible in mobile menu', () => { + cy.get(getTestSelector('nav-company-menu')).should('be.visible').click() + cy.contains('Display settings').should('be.visible').click() + const settings = ['Theme', 'Language', 'Currency'] + settings.forEach((label) => { + cy.contains(label).should('be.visible') + }) + }) + + it('contains appropriate sections and links', () => { + cy.get(getTestSelector('nav-company-menu')).should('be.visible').click() + cy.get(getTestSelector('company-menu-mobile-drawer')).should('be.visible').within(() => { + companyMenu.forEach((section) => { + cy.contains(section.label).should('be.visible').click() + section.items.forEach((item) => { + cy.contains('a', item.label).invoke('attr','href').should('equal', item.href) }) }) }) + }) - it('lets user open app download modal', () => { - cy.get('nav') - .within(() => { - cy.get(getTestSelector('nav-more-button')).should('be.visible').click() - cy.get(getTestSelector('nav-more-menu')).within(() => { - cy.contains('Download Uniswap').should('be.visible').click() - }) - }) - .then(() => { - cy.contains('Download the Uniswap app').should('be.visible') - }) + it('Download Uniswap is visible', () => { + cy.get(getTestSelector('nav-company-menu')).should('be.visible').click() + cy.get(getTestSelector('nav-dropdown-download-app')).should('be.visible') + }) + + it('includes social media links', () => { + socialMediaLinks.forEach((link) => { + cy.get(`a[href="${link}"]`).should('be.visible') + }) + }) + + it('shows bottom bar on token details page on mobile', () => { + cy.visit('/explore/tokens/ethereum/NATIVE') + cy.get(getTestSelector('tdp-mobile-bottom-bar')).should('be.visible').within(() => { + cy.contains('Buy').should('be.visible') + cy.contains('Sell').should('be.visible') + cy.contains('Send').should('be.visible') }) }) }) diff --git a/apps/web/cypress/e2e/token-explore.test.ts b/apps/web/cypress/e2e/token-explore.test.ts index f420c471aae..8f4ca8b4053 100644 --- a/apps/web/cypress/e2e/token-explore.test.ts +++ b/apps/web/cypress/e2e/token-explore.test.ts @@ -1,5 +1,4 @@ -import { getTestSelector, getTestSelectorStartsWith } from '../utils' -import { waitsForActiveChain } from './wallet-connection/switch-network.test' +import { getTestSelector, getTestSelectorStartsWith, waitsForActiveChain } from '../utils' describe('Token explore', () => { before(() => { diff --git a/apps/web/cypress/e2e/universal-search.test.ts b/apps/web/cypress/e2e/universal-search.test.ts index e7f958c43f1..0e8a509e2d8 100644 --- a/apps/web/cypress/e2e/universal-search.test.ts +++ b/apps/web/cypress/e2e/universal-search.test.ts @@ -7,7 +7,7 @@ const UNI_ADDRESS = UNI[UniverseChainId.Mainnet].address.toLowerCase() describe('Universal search bar', () => { function openSearch() { // can't just type "/" because on mobile it doesn't respond to that - return cy.get('[data-cy="right-search-container"] [data-cy="magnifying-icon"]').click() + return cy.get('[data-cy="nav-search-container"] [data-cy="nav-search-icon"]').click() } beforeEach(() => { @@ -15,7 +15,7 @@ describe('Universal search bar', () => { }) function getSearchBar() { - return cy.get('[data-cy="right-search-container"] [data-cy="search-bar-input"]').click() + return cy.get('[data-cy="nav-search-container"] input').click() } it('should yield clickable result that is then added to recent searches', () => { diff --git a/apps/web/cypress/e2e/wallet-connection/switch-network.test.ts b/apps/web/cypress/e2e/wallet-connection/switch-network.test.ts index 469f42cadaf..6cfe6cf25b8 100644 --- a/apps/web/cypress/e2e/wallet-connection/switch-network.test.ts +++ b/apps/web/cypress/e2e/wallet-connection/switch-network.test.ts @@ -1,12 +1,8 @@ import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { getTestSelector, resetHardhatChain } from '../../utils' - -export function waitsForActiveChain(chain: string) { - cy.get(getTestSelector('chain-selector-logo')).invoke('attr', 'alt').should('eq', `${chain} logo`) -} +import { getTestSelector, resetHardhatChain, waitsForActiveChain } from '../../utils' function switchChain(chain: string) { - cy.get(getTestSelector('chain-selector')).eq(1).click() + cy.get(getTestSelector('chain-selector')).click() cy.contains(chain).click() } diff --git a/apps/web/cypress/e2e/wallet-dropdown.test.ts b/apps/web/cypress/e2e/wallet-dropdown.test.ts index 891234e10b7..dea3c859419 100644 --- a/apps/web/cypress/e2e/wallet-dropdown.test.ts +++ b/apps/web/cypress/e2e/wallet-dropdown.test.ts @@ -1,4 +1,3 @@ -import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { getTestSelector } from '../utils' describe('Wallet Dropdown', () => { @@ -74,7 +73,7 @@ describe('Wallet Dropdown', () => { describe('should change locale with feature flag', () => { beforeEach(() => { - cy.visit('/', { featureFlags: [{ flag: FeatureFlags.CurrencyConversion, value: true }] }) + cy.visit('/') cy.get(getTestSelector('web3-status-connected')).click() cy.get(getTestSelector('wallet-settings')).click() }) @@ -106,10 +105,10 @@ describe('Wallet Dropdown', () => { cy.get(getTestSelector('wallet-disconnect')).click() cy.get(getTestSelector('wallet-disconnect')).should('contain', 'Disconnect') cy.get(getTestSelector('wallet-disconnect')).click() - cy.get(getTestSelector('wallet-settings')).click() }) - itChangesTheme() - itChangesLocale() + it('wallet settings should not be accessible', () => { + cy.get(getTestSelector('wallet-settings')).should('not.exist') + }) }) // TODO(WEB-3905): Fix tamagui error causing these tests to fail. @@ -169,19 +168,19 @@ describe('Wallet Dropdown', () => { describe('local currency', () => { it('loads local currency from the query param', () => { - cy.visit('/', { featureFlags: [{ flag: FeatureFlags.CurrencyConversion, value: true }] }) + cy.visit('/') cy.get(getTestSelector('web3-status-connected')).click() cy.get(getTestSelector('wallet-settings')).click() cy.contains('USD') - cy.visit('/?cur=AUD', { featureFlags: [{ flag: FeatureFlags.CurrencyConversion, value: true }] }) + cy.visit('/?cur=AUD') cy.get(getTestSelector('web3-status-connected')).click() cy.get(getTestSelector('wallet-settings')).click() cy.contains('AUD') }) it('loads local currency from menu', () => { - cy.visit('/', { featureFlags: [{ flag: FeatureFlags.CurrencyConversion, value: true }] }) + cy.visit('/') cy.get(getTestSelector('web3-status-connected')).click() cy.get(getTestSelector('wallet-settings')).click() cy.contains('USD') diff --git a/apps/web/cypress/fixtures/fiatOnRamp/get-country.json b/apps/web/cypress/fixtures/fiatOnRamp/get-country.json new file mode 100644 index 00000000000..7128241327f --- /dev/null +++ b/apps/web/cypress/fixtures/fiatOnRamp/get-country.json @@ -0,0 +1,5 @@ +{ + "countryCode": "US", + "displayName": "United States", + "state": "US-NY" +} diff --git a/apps/web/cypress/fixtures/fiatOnRamp/quotes.json b/apps/web/cypress/fixtures/fiatOnRamp/quotes.json new file mode 100644 index 00000000000..5cbc3231544 --- /dev/null +++ b/apps/web/cypress/fixtures/fiatOnRamp/quotes.json @@ -0,0 +1,80 @@ +{ + "error": null, + "message": null, + "quotes": [ + { + "countryCode": "US", + "destinationAmount": 0.03131402821107613, + "destinationCurrencyCode": "ETH", + "isMostRecentlyUsedProvider": false, + "serviceProvider": "STRIPE", + "serviceProviderDetails": { + "logos": { + "darkLogo": "https://images-serviceprovider.meld.io/STRIPE/short_logo_light.png", + "lightLogo": "https://images-serviceprovider.meld.io/STRIPE/short_logo_light.png" + }, + "name": "Stripe", + "paymentMethods": [ + "Debit Card", + "ACH" + ], + "serviceProvider": "STRIPE", + "supportUrl": "https://support.stripe.com/", + "url": "http://www.stripe.com" + }, + "sourceAmount": 100, + "sourceCurrencyCode": "USD", + "totalFee": 2.19 + }, + { + "countryCode": "US", + "destinationAmount": 0.03034965, + "destinationCurrencyCode": "ETH", + "isMostRecentlyUsedProvider": true, + "serviceProvider": "COINBASEPAY", + "serviceProviderDetails": { + "logos": { + "darkLogo": "https://images-serviceprovider.meld.io/COINBASEPAY/short_logo_light.png", + "lightLogo": "https://images-serviceprovider.meld.io/COINBASEPAY/short_logo_light.png" + }, + "name": "Coinbase", + "paymentMethods": [ + "Debit Card", + "ACH" + ], + "serviceProvider": "COINBASEPAY", + "supportUrl": "https://help.coinbase.com/", + "url": "https://www.coinbase.com/" + }, + "sourceAmount": 100, + "sourceCurrencyCode": "USD", + "totalFee": 2.83 + }, + { + "countryCode": "US", + "destinationAmount": 0.0303, + "destinationCurrencyCode": "ETH", + "isMostRecentlyUsedProvider": false, + "serviceProvider": "MOONPAY", + "serviceProviderDetails": { + "logos": { + "darkLogo": "https://images-serviceprovider.meld.io/MOONPAY/short_logo_light.png", + "lightLogo": "https://images-serviceprovider.meld.io/MOONPAY/short_logo_light.png" + }, + "name": "MoonPay", + "serviceProvider": "MOONPAY", + "supportUrl": "https://www.moonpay.com/contact-us", + "url": "https://buy.moonpay.com", + "paymentMethods": [ + "Debit Card", + "Apple Pay", + "Google Pay", + "PayPal" + ] + }, + "sourceAmount": 100, + "sourceCurrencyCode": "USD", + "totalFee": 5.040000000000006 + } + ] +} diff --git a/apps/web/cypress/fixtures/fiatOnRamp/supported-countries.json b/apps/web/cypress/fixtures/fiatOnRamp/supported-countries.json new file mode 100644 index 00000000000..81d89b26f69 --- /dev/null +++ b/apps/web/cypress/fixtures/fiatOnRamp/supported-countries.json @@ -0,0 +1,596 @@ +{ + "supportedCountries": [ + { + "countryCode": "DZ", + "displayName": "Algeria" + }, + { + "countryCode": "AD", + "displayName": "Andorra" + }, + { + "countryCode": "AO", + "displayName": "Angola" + }, + { + "countryCode": "AG", + "displayName": "Antigua and Barbuda" + }, + { + "countryCode": "AR", + "displayName": "Argentina" + }, + { + "countryCode": "AM", + "displayName": "Armenia" + }, + { + "countryCode": "AU", + "displayName": "Australia" + }, + { + "countryCode": "AT", + "displayName": "Austria" + }, + { + "countryCode": "AZ", + "displayName": "Azerbaijan" + }, + { + "countryCode": "BH", + "displayName": "Bahrain" + }, + { + "countryCode": "BE", + "displayName": "Belgium" + }, + { + "countryCode": "BZ", + "displayName": "Belize" + }, + { + "countryCode": "BJ", + "displayName": "Benin" + }, + { + "countryCode": "BT", + "displayName": "Bhutan" + }, + { + "countryCode": "BA", + "displayName": "Bosnia and Herzegovina" + }, + { + "countryCode": "BR", + "displayName": "Brazil" + }, + { + "countryCode": "BN", + "displayName": "Brunei Darussalam" + }, + { + "countryCode": "BG", + "displayName": "Bulgaria" + }, + { + "countryCode": "BI", + "displayName": "Burundi" + }, + { + "countryCode": "CV", + "displayName": "Cabo Verde" + }, + { + "countryCode": "CM", + "displayName": "Cameroon" + }, + { + "countryCode": "CA", + "displayName": "Canada" + }, + { + "countryCode": "CF", + "displayName": "Central African Republic" + }, + { + "countryCode": "TD", + "displayName": "Chad" + }, + { + "countryCode": "CL", + "displayName": "Chile" + }, + { + "countryCode": "CO", + "displayName": "Colombia" + }, + { + "countryCode": "KM", + "displayName": "Comoros" + }, + { + "countryCode": "CG", + "displayName": "Congo" + }, + { + "countryCode": "CR", + "displayName": "Costa Rica" + }, + { + "countryCode": "HR", + "displayName": "Croatia" + }, + { + "countryCode": "CY", + "displayName": "Cyprus" + }, + { + "countryCode": "CZ", + "displayName": "Czechia" + }, + { + "countryCode": "DK", + "displayName": "Denmark" + }, + { + "countryCode": "DJ", + "displayName": "Djibouti" + }, + { + "countryCode": "DM", + "displayName": "Dominica" + }, + { + "countryCode": "DO", + "displayName": "Dominican Republic" + }, + { + "countryCode": "EG", + "displayName": "Egypt" + }, + { + "countryCode": "SV", + "displayName": "El Salvador" + }, + { + "countryCode": "GQ", + "displayName": "Equatorial Guinea" + }, + { + "countryCode": "ER", + "displayName": "Eritrea" + }, + { + "countryCode": "EE", + "displayName": "Estonia" + }, + { + "countryCode": "SZ", + "displayName": "Eswatini" + }, + { + "countryCode": "ET", + "displayName": "Ethiopia" + }, + { + "countryCode": "FJ", + "displayName": "Fiji" + }, + { + "countryCode": "FI", + "displayName": "Finland" + }, + { + "countryCode": "FR", + "displayName": "France" + }, + { + "countryCode": "GA", + "displayName": "Gabon" + }, + { + "countryCode": "GM", + "displayName": "Gambia" + }, + { + "countryCode": "GE", + "displayName": "Georgia" + }, + { + "countryCode": "DE", + "displayName": "Germany" + }, + { + "countryCode": "GI", + "displayName": "Gibraltar" + }, + { + "countryCode": "GR", + "displayName": "Greece" + }, + { + "countryCode": "GD", + "displayName": "Grenada" + }, + { + "countryCode": "GT", + "displayName": "Guatemala" + }, + { + "countryCode": "GN", + "displayName": "Guinea" + }, + { + "countryCode": "GY", + "displayName": "Guyana" + }, + { + "countryCode": "HN", + "displayName": "Honduras" + }, + { + "countryCode": "HK", + "displayName": "Hong Kong" + }, + { + "countryCode": "HU", + "displayName": "Hungary" + }, + { + "countryCode": "IN", + "displayName": "India" + }, + { + "countryCode": "ID", + "displayName": "Indonesia" + }, + { + "countryCode": "IE", + "displayName": "Ireland" + }, + { + "countryCode": "IL", + "displayName": "Israel" + }, + { + "countryCode": "IT", + "displayName": "Italy" + }, + { + "countryCode": "JO", + "displayName": "Jordan" + }, + { + "countryCode": "KZ", + "displayName": "Kazakhstan" + }, + { + "countryCode": "KE", + "displayName": "Kenya" + }, + { + "countryCode": "KI", + "displayName": "Kiribati" + }, + { + "countryCode": "KR", + "displayName": "Korea, Republic of" + }, + { + "countryCode": "KW", + "displayName": "Kuwait" + }, + { + "countryCode": "KG", + "displayName": "Kyrgyzstan" + }, + { + "countryCode": "LA", + "displayName": "Lao People's Democratic Republic" + }, + { + "countryCode": "LV", + "displayName": "Latvia" + }, + { + "countryCode": "LS", + "displayName": "Lesotho" + }, + { + "countryCode": "LI", + "displayName": "Liechtenstein" + }, + { + "countryCode": "LT", + "displayName": "Lithuania" + }, + { + "countryCode": "LU", + "displayName": "Luxembourg" + }, + { + "countryCode": "MG", + "displayName": "Madagascar" + }, + { + "countryCode": "MW", + "displayName": "Malawi" + }, + { + "countryCode": "MV", + "displayName": "Maldives" + }, + { + "countryCode": "ML", + "displayName": "Mali" + }, + { + "countryCode": "MH", + "displayName": "Marshall Islands" + }, + { + "countryCode": "MR", + "displayName": "Mauritania" + }, + { + "countryCode": "MX", + "displayName": "Mexico" + }, + { + "countryCode": "FM", + "displayName": "Micronesia (Federated States of)" + }, + { + "countryCode": "MD", + "displayName": "Moldova, Republic of" + }, + { + "countryCode": "MC", + "displayName": "Monaco" + }, + { + "countryCode": "ME", + "displayName": "Montenegro" + }, + { + "countryCode": "MZ", + "displayName": "Mozambique" + }, + { + "countryCode": "NA", + "displayName": "Namibia" + }, + { + "countryCode": "NR", + "displayName": "Nauru" + }, + { + "countryCode": "NP", + "displayName": "Nepal" + }, + { + "countryCode": "NL", + "displayName": "Netherlands" + }, + { + "countryCode": "NZ", + "displayName": "New Zealand" + }, + { + "countryCode": "NE", + "displayName": "Niger" + }, + { + "countryCode": "NG", + "displayName": "Nigeria" + }, + { + "countryCode": "MK", + "displayName": "North Macedonia" + }, + { + "countryCode": "NO", + "displayName": "Norway" + }, + { + "countryCode": "OM", + "displayName": "Oman" + }, + { + "countryCode": "PW", + "displayName": "Palau" + }, + { + "countryCode": "PS", + "displayName": "Palestine, State of" + }, + { + "countryCode": "PG", + "displayName": "Papua New Guinea" + }, + { + "countryCode": "PY", + "displayName": "Paraguay" + }, + { + "countryCode": "PE", + "displayName": "Peru" + }, + { + "countryCode": "PH", + "displayName": "Philippines" + }, + { + "countryCode": "PL", + "displayName": "Poland" + }, + { + "countryCode": "PT", + "displayName": "Portugal" + }, + { + "countryCode": "QA", + "displayName": "Qatar" + }, + { + "countryCode": "RO", + "displayName": "Romania" + }, + { + "countryCode": "RW", + "displayName": "Rwanda" + }, + { + "countryCode": "KN", + "displayName": "Saint Kitts and Nevis" + }, + { + "countryCode": "LC", + "displayName": "Saint Lucia" + }, + { + "countryCode": "WS", + "displayName": "Samoa" + }, + { + "countryCode": "SM", + "displayName": "San Marino" + }, + { + "countryCode": "ST", + "displayName": "Sao Tome and Principe" + }, + { + "countryCode": "SA", + "displayName": "Saudi Arabia" + }, + { + "countryCode": "RS", + "displayName": "Serbia" + }, + { + "countryCode": "SC", + "displayName": "Seychelles" + }, + { + "countryCode": "SL", + "displayName": "Sierra Leone" + }, + { + "countryCode": "SG", + "displayName": "Singapore" + }, + { + "countryCode": "SK", + "displayName": "Slovakia" + }, + { + "countryCode": "SI", + "displayName": "Slovenia" + }, + { + "countryCode": "SB", + "displayName": "Solomon Islands" + }, + { + "countryCode": "ZA", + "displayName": "South Africa" + }, + { + "countryCode": "ES", + "displayName": "Spain" + }, + { + "countryCode": "LK", + "displayName": "Sri Lanka" + }, + { + "countryCode": "VC", + "displayName": "St. Vincent & Grenadines" + }, + { + "countryCode": "SR", + "displayName": "Suriname" + }, + { + "countryCode": "SE", + "displayName": "Sweden" + }, + { + "countryCode": "CH", + "displayName": "Switzerland" + }, + { + "countryCode": "TW", + "displayName": "Taiwan" + }, + { + "countryCode": "TJ", + "displayName": "Tajikistan" + }, + { + "countryCode": "TZ", + "displayName": "Tanzania, United Republic of" + }, + { + "countryCode": "TH", + "displayName": "Thailand" + }, + { + "countryCode": "TL", + "displayName": "Timor-Leste" + }, + { + "countryCode": "TG", + "displayName": "Togo" + }, + { + "countryCode": "TO", + "displayName": "Tonga" + }, + { + "countryCode": "TN", + "displayName": "Tunisia" + }, + { + "countryCode": "TR", + "displayName": "Turkey" + }, + { + "countryCode": "TM", + "displayName": "Turkmenistan" + }, + { + "countryCode": "TV", + "displayName": "Tuvalu" + }, + { + "countryCode": "AE", + "displayName": "United Arab Emirates" + }, + { + "countryCode": "US", + "displayName": "United States of America" + }, + { + "countryCode": "UY", + "displayName": "Uruguay" + }, + { + "countryCode": "UZ", + "displayName": "Uzbekistan" + }, + { + "countryCode": "VA", + "displayName": "Vatican City" + }, + { + "countryCode": "VN", + "displayName": "Vietnam" + }, + { + "countryCode": "ZM", + "displayName": "Zambia" + } + ] +} diff --git a/apps/web/cypress/fixtures/fiatOnRamp/supported-fiat-currencies.json b/apps/web/cypress/fixtures/fiatOnRamp/supported-fiat-currencies.json new file mode 100644 index 00000000000..00d37a7c486 --- /dev/null +++ b/apps/web/cypress/fixtures/fiatOnRamp/supported-fiat-currencies.json @@ -0,0 +1,329 @@ +{ + "fiatCurrencies": [ + { + "displayName": "Argentine Peso", + "fiatCurrencyCode": "ARS", + "symbol": "https://images-currency.meld.io/fiat/ARS/symbol.png" + }, + { + "displayName": "Australian Dollar", + "fiatCurrencyCode": "AUD", + "symbol": "https://images-currency.meld.io/fiat/AUD/symbol.png" + }, + { + "displayName": "Azerbaijan Manat", + "fiatCurrencyCode": "AZN", + "symbol": "https://images-currency.meld.io/fiat/AZN/symbol.png" + }, + { + "displayName": "Balboa", + "fiatCurrencyCode": "PAB", + "symbol": "https://images-currency.meld.io/fiat/PAB/symbol.png" + }, + { + "displayName": "Boliviano", + "fiatCurrencyCode": "BOB", + "symbol": "https://images-currency.meld.io/fiat/BOB/symbol.png" + }, + { + "displayName": "Brazilian Real", + "fiatCurrencyCode": "BRL", + "symbol": "https://images-currency.meld.io/fiat/BRL/symbol.png" + }, + { + "displayName": "Bulgarian Lev", + "fiatCurrencyCode": "BGN", + "symbol": "https://images-currency.meld.io/fiat/BGN/symbol.png" + }, + { + "displayName": "CFA Franc BCEAO", + "fiatCurrencyCode": "XOF", + "symbol": "https://images-currency.meld.io/fiat/XOF/symbol.png" + }, + { + "displayName": "CFA Franc BEAC", + "fiatCurrencyCode": "XAF", + "symbol": "https://images-currency.meld.io/fiat/XAF/symbol.png" + }, + { + "displayName": "Canadian Dollar", + "fiatCurrencyCode": "CAD", + "symbol": "https://images-currency.meld.io/fiat/CAD/symbol.png" + }, + { + "displayName": "Chilean Peso", + "fiatCurrencyCode": "CLP", + "symbol": "https://images-currency.meld.io/fiat/CLP/symbol.png" + }, + { + "displayName": "Colombian Peso", + "fiatCurrencyCode": "COP", + "symbol": "https://images-currency.meld.io/fiat/COP/symbol.png" + }, + { + "displayName": "Costa Rican Colon", + "fiatCurrencyCode": "CRC", + "symbol": "https://images-currency.meld.io/fiat/CRC/symbol.png" + }, + { + "displayName": "Croatian kuna", + "fiatCurrencyCode": "HRK", + "symbol": "https://images-currency.meld.io/fiat/HRK/symbol.png" + }, + { + "displayName": "Czech Koruna", + "fiatCurrencyCode": "CZK", + "symbol": "https://images-currency.meld.io/fiat/CZK/symbol.png" + }, + { + "displayName": "Danish Krone", + "fiatCurrencyCode": "DKK", + "symbol": "https://images-currency.meld.io/fiat/DKK/symbol.png" + }, + { + "displayName": "Dominican Peso", + "fiatCurrencyCode": "DOP", + "symbol": "https://images-currency.meld.io/fiat/DOP/symbol.png" + }, + { + "displayName": "Euro", + "fiatCurrencyCode": "EUR", + "symbol": "https://images-currency.meld.io/fiat/EUR/symbol.png" + }, + { + "displayName": "Forint", + "fiatCurrencyCode": "HUF", + "symbol": "https://images-currency.meld.io/fiat/HUF/symbol.png" + }, + { + "displayName": "Ghana Cedi", + "fiatCurrencyCode": "GHS", + "symbol": "https://images-currency.meld.io/fiat/GHS/symbol.png" + }, + { + "displayName": "Hong Kong Dollar", + "fiatCurrencyCode": "HKD", + "symbol": "https://images-currency.meld.io/fiat/HKD/symbol.png" + }, + { + "displayName": "Hryvnia", + "fiatCurrencyCode": "UAH", + "symbol": "https://images-currency.meld.io/fiat/UAH/symbol.png" + }, + { + "displayName": "Iceland Krona", + "fiatCurrencyCode": "ISK", + "symbol": "https://images-currency.meld.io/fiat/ISK/symbol.png" + }, + { + "displayName": "Indian Rupee", + "fiatCurrencyCode": "INR", + "symbol": "https://images-currency.meld.io/fiat/INR/symbol.png" + }, + { + "displayName": "Jamaican Dollar", + "fiatCurrencyCode": "JMD", + "symbol": "https://images-currency.meld.io/fiat/JMD/symbol.png" + }, + { + "displayName": "Jordanian Dinar", + "fiatCurrencyCode": "JOD", + "symbol": "https://images-currency.meld.io/fiat/JOD/symbol.png" + }, + { + "displayName": "Kenyan Shilling", + "fiatCurrencyCode": "KES", + "symbol": "https://images-currency.meld.io/fiat/KES/symbol.png" + }, + { + "displayName": "Kuwaiti Dinar", + "fiatCurrencyCode": "KWD", + "symbol": "https://images-currency.meld.io/fiat/KWD/symbol.png" + }, + { + "displayName": "Malagasy Ariary", + "fiatCurrencyCode": "MGA", + "symbol": "https://images-currency.meld.io/fiat/MGA/symbol.png" + }, + { + "displayName": "Mexican Peso", + "fiatCurrencyCode": "MXN", + "symbol": "https://images-currency.meld.io/fiat/MXN/symbol.png" + }, + { + "displayName": "Naira", + "fiatCurrencyCode": "NGN", + "symbol": "https://images-currency.meld.io/fiat/NGN/symbol.png" + }, + { + "displayName": "Nepalese Rupee", + "fiatCurrencyCode": "NPR", + "symbol": "https://images-currency.meld.io/fiat/NPR/symbol.png" + }, + { + "displayName": "New Taiwan Dollar", + "fiatCurrencyCode": "TWD", + "symbol": "https://images-currency.meld.io/fiat/TWD/symbol.png" + }, + { + "displayName": "New Zealand Dollar", + "fiatCurrencyCode": "NZD", + "symbol": "https://images-currency.meld.io/fiat/NZD/symbol.png" + }, + { + "displayName": "Norwegian Krone", + "fiatCurrencyCode": "NOK", + "symbol": "https://images-currency.meld.io/fiat/NOK/symbol.png" + }, + { + "displayName": "Pakistan Rupee", + "fiatCurrencyCode": "PKR", + "symbol": "https://images-currency.meld.io/fiat/PKR/symbol.png" + }, + { + "displayName": "Peso Uruguayo", + "fiatCurrencyCode": "UYU", + "symbol": "https://images-currency.meld.io/fiat/UYU/symbol.png" + }, + { + "displayName": "Philippine Peso", + "fiatCurrencyCode": "PHP", + "symbol": "https://images-currency.meld.io/fiat/PHP/symbol.png" + }, + { + "displayName": "Pound Sterling", + "fiatCurrencyCode": "GBP", + "symbol": "https://images-currency.meld.io/fiat/GBP/symbol.png" + }, + { + "displayName": "Quetzal", + "fiatCurrencyCode": "GTQ", + "symbol": "https://images-currency.meld.io/fiat/GTQ/symbol.png" + }, + { + "displayName": "Rand", + "fiatCurrencyCode": "ZAR", + "symbol": "https://images-currency.meld.io/fiat/ZAR/symbol.png" + }, + { + "displayName": "Riel", + "fiatCurrencyCode": "KHR", + "symbol": "https://images-currency.meld.io/fiat/KHR/symbol.png" + }, + { + "displayName": "Romanian Leu", + "fiatCurrencyCode": "RON", + "symbol": "https://images-currency.meld.io/fiat/RON/symbol.png" + }, + { + "displayName": "Rupiah", + "fiatCurrencyCode": "IDR", + "symbol": "https://images-currency.meld.io/fiat/IDR/symbol.png" + }, + { + "displayName": "Serbian Dinar", + "fiatCurrencyCode": "RSD", + "symbol": "https://images-currency.meld.io/fiat/RSD/symbol.png" + }, + { + "displayName": "Singapore Dollar", + "fiatCurrencyCode": "SGD", + "symbol": "https://images-currency.meld.io/fiat/SGD/symbol.png" + }, + { + "displayName": "Sol", + "fiatCurrencyCode": "PEN", + "symbol": "https://images-currency.meld.io/fiat/PEN/symbol.png" + }, + { + "displayName": "Sri Lanka Rupee", + "fiatCurrencyCode": "LKR", + "symbol": "https://images-currency.meld.io/fiat/LKR/symbol.png" + }, + { + "displayName": "Swedish Krona", + "fiatCurrencyCode": "SEK", + "symbol": "https://images-currency.meld.io/fiat/SEK/symbol.png" + }, + { + "displayName": "Swiss Franc", + "fiatCurrencyCode": "CHF", + "symbol": "https://images-currency.meld.io/fiat/CHF/symbol.png" + }, + { + "displayName": "Tenge", + "fiatCurrencyCode": "KZT", + "symbol": "https://images-currency.meld.io/fiat/KZT/symbol.png" + }, + { + "displayName": "Tugrik", + "fiatCurrencyCode": "MNT", + "symbol": "https://images-currency.meld.io/fiat/MNT/symbol.png" + }, + { + "displayName": "Turkish Lira", + "fiatCurrencyCode": "TRY", + "symbol": "https://images-currency.meld.io/fiat/TRY/symbol.png" + }, + { + "displayName": "UAE Dirham", + "fiatCurrencyCode": "AED", + "symbol": "https://images-currency.meld.io/fiat/AED/symbol.png" + }, + { + "displayName": "US Dollar", + "fiatCurrencyCode": "USD", + "symbol": "https://images-currency.meld.io/fiat/USD/symbol.png" + }, + { + "displayName": "Uganda Shilling", + "fiatCurrencyCode": "UGX", + "symbol": "https://images-currency.meld.io/fiat/UGX/symbol.png" + }, + { + "displayName": "Uzbekistan Sum", + "fiatCurrencyCode": "UZS", + "symbol": "https://images-currency.meld.io/fiat/UZS/symbol.png" + }, + { + "displayName": "Yemeni Rial", + "fiatCurrencyCode": "YER", + "symbol": "https://images-currency.meld.io/fiat/YER/symbol.png" + }, + { + "displayName": "Zambian Kwacha", + "fiatCurrencyCode": "ZMW", + "symbol": "https://images-currency.meld.io/fiat/ZMW/symbol.png" + }, + { + "displayName": "Zloty", + "fiatCurrencyCode": "PLN", + "symbol": "https://images-currency.meld.io/fiat/PLN/symbol.png" + }, + { + "fiatCurrencyCode": "EGP", + "displayName": "Egyptian Pound", + "symbol": "https://images-currency.meld.io/fiat/EGP/symbol.png" + }, + { + "fiatCurrencyCode": "ILS", + "displayName": "Israeli New Shekel", + "symbol": "https://images-currency.meld.io/fiat/ILS/symbol.png" + }, + { + "fiatCurrencyCode": "OMR", + "displayName": "Omani Rial", + "symbol": "https://images-currency.meld.io/fiat/OMR/symbol.png" + }, + { + "fiatCurrencyCode": "THB", + "displayName": "Thai Baht", + "symbol": "https://images-currency.meld.io/fiat/THB/symbol.png" + }, + { + "fiatCurrencyCode": "VND", + "displayName": "Vietnamese Dong", + "symbol": "https://images-currency.meld.io/fiat/VND/symbol.png" + } + ] +} diff --git a/apps/web/cypress/fixtures/fiatOnRamp/supported-tokens.json b/apps/web/cypress/fixtures/fiatOnRamp/supported-tokens.json new file mode 100644 index 00000000000..fda2dd50fa1 --- /dev/null +++ b/apps/web/cypress/fixtures/fiatOnRamp/supported-tokens.json @@ -0,0 +1,108 @@ +{ + "supportedTokens": [ + { + "cryptoCurrencyCode": "ETH", + "displayName": "Ethereum", + "address": null, + "cryptoCurrencyChain": "Ethereum", + "chainId": "1", + "symbol": "https://images-currency.meld.io/crypto/ETH/symbol.png" + }, + { + "cryptoCurrencyCode": "USDC", + "displayName": "USD Coin", + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "cryptoCurrencyChain": "Ethereum", + "chainId": "1", + "symbol": "https://images-currency.meld.io/crypto/USDC/symbol.png" + }, + { + "cryptoCurrencyCode": "USDT", + "displayName": "Tether", + "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "cryptoCurrencyChain": "Ethereum", + "chainId": "1", + "symbol": "https://images-currency.meld.io/crypto/USDT/symbol.png" + }, + { + "cryptoCurrencyCode": "DAI", + "displayName": "Dai", + "address": "0x6b175474e89094c44da98b954eedeac495271d0f", + "cryptoCurrencyChain": "Ethereum", + "chainId": "1", + "symbol": "https://images-currency.meld.io/crypto/DAI/symbol.png" + }, + { + "cryptoCurrencyCode": "WBTC", + "displayName": "Wrapped Bitcoin", + "address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "cryptoCurrencyChain": "Ethereum", + "chainId": "1", + "symbol": "https://images-currency.meld.io/crypto/WBTC/symbol.png" + }, + { + "cryptoCurrencyCode": "WETH", + "displayName": "Wrapped Ether (ERC-20)", + "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "cryptoCurrencyChain": "ethereum", + "chainId": "1", + "symbol": "" + }, + { + "cryptoCurrencyCode": "ETH_BASE", + "displayName": "Ethereum", + "address": null, + "cryptoCurrencyChain": "Base", + "chainId": "8453", + "symbol": "https://images-currency.meld.io/crypto/ETH_BASE/symbol.png" + }, + { + "cryptoCurrencyCode": "USDC_BASE", + "displayName": "USD Coin", + "address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "cryptoCurrencyChain": "Base", + "chainId": "8453", + "symbol": "https://images-currency.meld.io/crypto/USDC_BASE/symbol.png" + }, + { + "cryptoCurrencyCode": "ETH_OPTIMISM", + "displayName": "Ethereum", + "address": null, + "cryptoCurrencyChain": "Optimism", + "chainId": "10", + "symbol": "https://images-currency.meld.io/crypto/ETH_OPTIMISM/symbol.png" + }, + { + "cryptoCurrencyCode": "USDC_OPTIMISM", + "displayName": "USD Coin", + "address": "0x7f5c764cbc14f9669b88837ca1490cca17c31607", + "cryptoCurrencyChain": "Optimism", + "chainId": "10", + "symbol": "https://images-currency.meld.io/crypto/USDC_OPTIMISM/symbol.png" + }, + { + "cryptoCurrencyCode": "ETH_ARBITRUM", + "displayName": "Ethereum", + "address": null, + "cryptoCurrencyChain": "Arbitrum", + "chainId": "42161", + "symbol": "https://images-currency.meld.io/crypto/ETH_ARBITRUM/symbol.png" + }, + { + "cryptoCurrencyCode": "USDC_ARBITRUM", + "displayName": "USD Coin", + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "cryptoCurrencyChain": "Arbitrum", + "chainId": "42161", + "symbol": "https://images-currency.meld.io/crypto/USDC_ARBITRUM/symbol.png" + }, + { + "cryptoCurrencyCode": "MATIC", + "displayName": "Matic", + "address": null, + "cryptoCurrencyChain": "Polygon", + "chainId": "137", + "symbol": "https://images-currency.meld.io/crypto/MATIC/symbol.png" + } + ] +} diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts index 44bc9c99b52..eba5c26b3b5 100644 --- a/apps/web/cypress/support/commands.ts +++ b/apps/web/cypress/support/commands.ts @@ -4,6 +4,9 @@ import { Eip1193 } from 'cypress-hardhat/lib/browser/eip1193' import { FeatureFlagClient, FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' import { UserState, initialState } from '../../src/state/user/reducer' import { setInitialUserState } from '../utils/user-state' +import { + ALLOW_ANALYTICS_ATOM_KEY, +} from 'utilities/src/telemetry/analytics/constants' declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -51,105 +54,108 @@ declare global { } } -// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index -// eslint-disable-next-line no-undef -Cypress.Commands.overwrite( - 'visit', - (original, url: string | Partial, options?: Partial) => { - if (typeof url !== 'string') { - throw new Error('Invalid arguments. The first argument to cy.visit must be the path.') - } +export function registerCommands() { + // sets up the injected provider to be a mock ethereum provider with the given mnemonic/index + // eslint-disable-next-line no-undef + Cypress.Commands.overwrite( + 'visit', + (original, url: string | Partial, options?: Partial) => { + if (typeof url !== 'string') { + throw new Error('Invalid arguments. The first argument to cy.visit must be the path.') + } - // Parse overrides - const flagsOn: FeatureFlags[] = [] - const flagsOff: FeatureFlags[] = [] - options?.featureFlags?.forEach((f) => { - if (f.value) { - flagsOn.push(f.flag) - } else { - flagsOff.push(f.flag) + // Parse overrides + const flagsOn: FeatureFlags[] = [] + const flagsOff: FeatureFlags[] = [] + options?.featureFlags?.forEach((f) => { + if (f.value) { + flagsOn.push(f.flag) + } else { + flagsOff.push(f.flag) + } + }) + + // Format into URL parameters + const overrideParams = new URLSearchParams() + if (flagsOn.length > 0) { + overrideParams.append( + 'featureFlagOverride', + flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), + ) + } + if (flagsOff.length > 0) { + overrideParams.append( + 'featureFlagOverrideOff', + flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(','), + ) } - }) - // Format into URL parameters - const overrideParams = new URLSearchParams() - if (flagsOn.length > 0) { - overrideParams.append( - 'featureFlagOverride', - flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(',') - ) - } - if (flagsOff.length > 0) { - overrideParams.append( - 'featureFlagOverrideOff', - flagsOn.map((flag) => getFeatureFlagName(flag, FeatureFlagClient.Web)).join(',') - ) - } + return cy.provider().then((provider) => + original({ + ...options, + url: + [...overrideParams.entries()].length === 0 + ? url + : url.includes('?') + ? `${url}&${overrideParams.toString()}` + : `${url}?${overrideParams.toString()}`, + onBeforeLoad(win) { + options?.onBeforeLoad?.(win) - return cy.provider().then((provider) => - original({ - ...options, - url: - [...overrideParams.entries()].length === 0 - ? url - : url.includes('?') - ? `${url}&${overrideParams.toString()}` - : `${url}?${overrideParams.toString()}`, - onBeforeLoad(win) { - options?.onBeforeLoad?.(win) + setInitialUserState(win, { + ...initialState, + ...(options?.userState ?? {}), + }) - setInitialUserState(win, { - ...initialState, - ...(options?.userState ?? {}), - }) + win.ethereum = provider + win.Cypress.eagerlyConnect = options?.eagerlyConnect ?? true + win.localStorage.setItem(ALLOW_ANALYTICS_ATOM_KEY, 'true') + }, + }), + ) + }, + ) - win.ethereum = provider - win.Cypress.eagerlyConnect = options?.eagerlyConnect ?? true - }, + Cypress.Commands.add('waitForAmplitudeEvent', (eventName, requiredProperties) => { + function findAndDiscardEventsUpToTarget() { + const events = Cypress.env('amplitudeEventCache') + const targetEventIndex = events.findIndex((event) => { + if (event.event_type !== eventName) { + return false + } + if (requiredProperties) { + return requiredProperties.every((prop) => event.event_properties[prop]) + } + return true }) - ) - } -) -Cypress.Commands.add('waitForAmplitudeEvent', (eventName, requiredProperties) => { - function findAndDiscardEventsUpToTarget() { - const events = Cypress.env('amplitudeEventCache') - const targetEventIndex = events.findIndex((event) => { - if (event.event_type !== eventName) { - return false - } - if (requiredProperties) { - return requiredProperties.every((prop) => event.event_properties[prop]) + if (targetEventIndex !== -1) { + const event = events[targetEventIndex] + Cypress.env('amplitudeEventCache', events.slice(targetEventIndex + 1)) + return cy.wrap(event) + } else { + // If not found, retry after waiting for more events to be sent. + return cy.wait('@amplitude').then(findAndDiscardEventsUpToTarget) } - return true - }) - - if (targetEventIndex !== -1) { - const event = events[targetEventIndex] - Cypress.env('amplitudeEventCache', events.slice(targetEventIndex + 1)) - return cy.wrap(event) - } else { - // If not found, retry after waiting for more events to be sent. - return cy.wait('@amplitude').then(findAndDiscardEventsUpToTarget) } - } - return findAndDiscardEventsUpToTarget() -}) + return findAndDiscardEventsUpToTarget() + }) -Cypress.Commands.add('interceptGraphqlOperation', (operationName, fixturePath) => { - return cy.intercept(/(?:interface|beta).gateway.uniswap.org\/v1\/graphql/, (req) => { - req.headers['origin'] = 'https://app.uniswap.org' - if (req.body.operationName === operationName) { - req.reply({ fixture: fixturePath }) - } else { - req.continue() - } + Cypress.Commands.add('interceptGraphqlOperation', (operationName, fixturePath) => { + return cy.intercept(/(?:interface|beta).gateway.uniswap.org\/v1\/graphql/, (req) => { + req.headers['origin'] = 'https://app.uniswap.org' + if (req.body.operationName === operationName) { + req.reply({ fixture: fixturePath }) + } else { + req.continue() + } + }) }) -}) -Cypress.Commands.add('interceptQuoteRequest', (fixturePath) => { - return cy.intercept(/(?:interface|beta).gateway.uniswap.org\/v2\/quote/, (req) => { - req.headers['origin'] = 'https://app.uniswap.org' - req.reply({ fixture: fixturePath }) + Cypress.Commands.add('interceptQuoteRequest', (fixturePath) => { + return cy.intercept(/(?:interface|beta).gateway.uniswap.org\/v2\/quote/, (req) => { + req.headers['origin'] = 'https://app.uniswap.org' + req.reply({ fixture: fixturePath }) + }) }) -}) +} diff --git a/apps/web/cypress/support/e2e.ts b/apps/web/cypress/support/e2e.ts index c6345ce169f..cd54cee8719 100644 --- a/apps/web/cypress/support/e2e.ts +++ b/apps/web/cypress/support/e2e.ts @@ -5,8 +5,13 @@ // https://on.cypress.io/configuration // *********************************************************** -import './commands' -import './setupTests' +import { registerCommands } from './commands' +import { registerSetupTests } from './setupTests' +// In order to use cypress commands with sideEffects set in package.json +// we need to import the commands and setupTests files here. +// See: https://github.com/cypress-io/cypress-documentation/pull/5454/files +registerCommands() +registerSetupTests() // Squelch logs from fetches, as they clutter the logs so much as to make them unusable. // See https://docs.cypress.io/api/commands/intercept#Disabling-logs-for-a-request. diff --git a/apps/web/cypress/support/setupTests.ts b/apps/web/cypress/support/setupTests.ts index 1504b8a2304..c185ff249cf 100644 --- a/apps/web/cypress/support/setupTests.ts +++ b/apps/web/cypress/support/setupTests.ts @@ -2,54 +2,56 @@ import { CyHttpMessages } from 'cypress/types/net-stubbing' import { UniverseChainId } from 'uniswap/src/types/chains' import { revertHardhat, setupHardhat } from '../utils' -beforeEach(() => { - // Many API calls enforce that requests come from our app, so we must mock Origin and Referer. - cy.intercept('*', (req) => { - req.headers['referer'] = 'https://app.uniswap.org' - req.headers['origin'] = 'https://app.uniswap.org' - }) +export function registerSetupTests() { + beforeEach(() => { + // Many API calls enforce that requests come from our app, so we must mock Origin and Referer. + cy.intercept('*', (req) => { + req.headers['referer'] = 'https://app.uniswap.org' + req.headers['origin'] = 'https://app.uniswap.org' + }) - // Network RPCs are disabled for cypress tests - calls should be routed through the connected wallet instead. - cy.intercept(/infura.io/, { statusCode: 404 }) - cy.intercept(/quiknode.pro/, { statusCode: 404 }) + // Network RPCs are disabled for cypress tests - calls should be routed through the connected wallet instead. + cy.intercept(/infura.io/, { statusCode: 404 }) + cy.intercept(/quiknode.pro/, { statusCode: 404 }) - // Log requests to hardhat. - cy.intercept(/:8545/, logJsonRpc) + // Log requests to hardhat. + cy.intercept(/:8545/, logJsonRpc) - Cypress.env('amplitudeEventCache', []) + Cypress.env('amplitudeEventCache', []) - // Mock analytics responses to avoid analytics in tests. - cy.intercept('https://metrics.interface.gateway.uniswap.org/v1/amplitude-proxy', (req) => { - const requestBody = JSON.stringify(req.body) - const byteSize = new Blob([requestBody]).size - req.alias = 'amplitude' - req.reply( - JSON.stringify({ - code: 200, - server_upload_time: Date.now(), - payload_size_bytes: byteSize, - events_ingested: req.body.events.length, - }), - { - 'origin-country': 'US', - } - ) + // Mock analytics responses to avoid analytics in tests. + cy.intercept('https://metrics.interface.gateway.uniswap.org/v1/amplitude-proxy', (req) => { + const requestBody = JSON.stringify(req.body) + const byteSize = new Blob([requestBody]).size + req.alias = 'amplitude' + req.reply( + JSON.stringify({ + code: 200, + server_upload_time: Date.now(), + payload_size_bytes: byteSize, + events_ingested: req.body.events.length, + }), + { + 'origin-country': 'US', + }, + ) - Cypress.env('amplitudeEventCache').push(...req.body.events) - }).intercept('https://*.sentry.io', { statusCode: 200 }) + Cypress.env('amplitudeEventCache').push(...req.body.events) + }).intercept('https://*.sentry.io', { statusCode: 200 }) - // Mock statsig to allow us to mock flags. - cy.intercept(/statsig/, { statusCode: 409 }) -}) + // Mock statsig to allow us to mock flags. + cy.intercept(/statsig/, { statusCode: 409 }) + }) -// Reset hardhat between suites to ensure isolation. -// This resets the fork, as well as options like automine. -before(() => cy.hardhat().then((hardhat) => hardhat.reset(UniverseChainId.Mainnet))) + // Reset hardhat between suites to ensure isolation. + // This resets the fork, as well as options like automine. + before(() => cy.hardhat().then((hardhat) => hardhat.reset(UniverseChainId.Mainnet))) -// Reverts hardhat between tests to ensure isolation. -// This reverts the fork, but not options like automine. -setupHardhat() -beforeEach(revertHardhat) + // Reverts hardhat between tests to ensure isolation. + // This reverts the fork, but not options like automine. + setupHardhat() + beforeEach(revertHardhat) +} function logJsonRpc(req: CyHttpMessages.IncomingHttpRequest) { req.alias = req.body.method @@ -57,7 +59,7 @@ function logJsonRpc(req: CyHttpMessages.IncomingHttpRequest) { autoEnd: false, name: req.body.method, message: req.body.params?.map((param: any) => - typeof param === 'object' ? '{...}' : param?.toString().substring(0, 10) + typeof param === 'object' ? '{...}' : param?.toString().substring(0, 10), ), }) req.on('after:response', (res) => { diff --git a/apps/web/cypress/utils/index.ts b/apps/web/cypress/utils/index.ts index d647dfaa95a..05b1bc8fda8 100644 --- a/apps/web/cypress/utils/index.ts +++ b/apps/web/cypress/utils/index.ts @@ -72,3 +72,7 @@ export function resetHardhatChain() { hardhat.send('wallet_switchEthereumChain', [{ chainId: '0x1' }]) }) } + +export function waitsForActiveChain(chain: string) { + cy.get(getTestSelector('chain-selector-logo')).invoke('attr', 'alt').should('eq', `${chain} logo`) +} diff --git a/apps/web/package.json b/apps/web/package.json index 859aa1f5a81..ec5a63b9a45 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,12 +8,9 @@ "check:deps:usage": "depcheck", "check:circular": "concurrently \"../../scripts/check-circular-imports.sh ./src/pages/App.tsx 7\" \"../../scripts/check-circular-imports.sh ./src/setupTests.ts 0\"", "sitemap:generate": "node scripts/generate-sitemap.js", - "i18n:upload": "./scripts/crowdin.sh upload", - "i18n:download": "./scripts/crowdin.sh download", - "i18n:download:if-missing": "ONLY_IF_MISSING=1 ./scripts/crowdin.sh download", "start": "craco start", "start:cloud": "NODE_OPTIONS=--dns-result-order=ipv4first PORT=3001 REACT_APP_SKIP_CSP=1 npx wrangler pages dev --compatibility-flags=nodejs_compat --compatibility-date=2023-08-01 --proxy=3001 --port=3000 -- yarn start", - "build:production": "yarn i18n:download:if-missing && craco build", + "build:production": "(cd ../.. && yarn i18n:web:download:if-missing) && craco build", "analyze": "source-map-explorer 'build/static/js/*.js' --no-border-checks --gzip", "serve": "serve build -s -l 3000", "format": "../../scripts/prettier.sh", @@ -118,8 +115,8 @@ "babel-jest": "29.6.1", "browser-cache-mock": "0.1.7", "concurrently": "^8.0.1", - "cypress": "12.12.0", - "cypress-hardhat": "2.5.0", + "cypress": "12.17.4", + "cypress-hardhat": "2.5.3", "dotenv": "16.0.3", "dotenv-cli": "^7.0.0", "esbuild-register": "^3.5.0", @@ -181,7 +178,7 @@ "@tamagui/core": "1.95.1", "@tamagui/portal": "1.95.1", "@tamagui/react-native-svg": "1.95.1", - "@tamagui/remove-scroll": "1.95.1", + "@tamagui/remove-scroll": "1.102.1", "@tanstack/react-query": "5.28.14", "@tanstack/react-table": "8.10.7", "@types/poisson-disk-sampling": "2.2.4", @@ -231,10 +228,7 @@ "focus-visible": "5.2.0", "framer-motion": "10.17.6", "graphql": "16.6.0", - "i18next": "23.10.0", - "i18next-resources-to-backend": "^1.2.0", "immer": "9.0.6", - "inter-ui": "3.19.3", "jotai": "1.3.7", "jpeg-js": "0.4.4", "jsbi": "3.2.5", @@ -248,7 +242,6 @@ "poisson-disk-sampling": "2.3.1", "polished": "3.3.2", "polyfill-object.fromentries": "1.0.1", - "qrcode.react": "3.1.0", "qs": "6.9.4", "query-string": "7.1.3", "rc-slider": "10.4.0", @@ -256,7 +249,6 @@ "react-dom": "18.2.0", "react-feather": "2.0.10", "react-helmet-async": "2.0.4", - "react-i18next": "14.1.0", "react-infinite-scroll-component": "6.1.0", "react-is": "18.2.0", "react-markdown": "4.3.1", @@ -295,5 +287,10 @@ "npm": "please-use-yarn", "node": "18.x", "yarn": ">=1.22" - } + }, + "sideEffects": [ + "*.css", + "**/sideEffects.ts", + "**/tracing/index.ts" + ] } diff --git a/apps/web/public/images/extension_promo/announcement_modal_desktop.png b/apps/web/public/images/extension_promo/announcement_modal_desktop.png index a96addc5312..c1778ee1662 100644 Binary files a/apps/web/public/images/extension_promo/announcement_modal_desktop.png and b/apps/web/public/images/extension_promo/announcement_modal_desktop.png differ diff --git a/apps/web/public/images/extension_promo/background_connector.png b/apps/web/public/images/extension_promo/background_connector.png index c8edcdc9f77..dddebd5ff1d 100644 Binary files a/apps/web/public/images/extension_promo/background_connector.png and b/apps/web/public/images/extension_promo/background_connector.png differ diff --git a/apps/web/src/assets/images/extensionIllustration.png b/apps/web/src/assets/images/extensionIllustration.png index cdc547efab9..2c53c063430 100644 Binary files a/apps/web/src/assets/images/extensionIllustration.png and b/apps/web/src/assets/images/extensionIllustration.png differ diff --git a/apps/web/src/assets/images/walletIllustration.png b/apps/web/src/assets/images/walletIllustration.png index a96addc5312..d3cbab40423 100644 Binary files a/apps/web/src/assets/images/walletIllustration.png and b/apps/web/src/assets/images/walletIllustration.png differ diff --git a/apps/web/src/components/AccountDetails/AddressDisplay.tsx b/apps/web/src/components/AccountDetails/AddressDisplay.tsx index 3881e73b17e..82970fb6775 100644 --- a/apps/web/src/components/AccountDetails/AddressDisplay.tsx +++ b/apps/web/src/components/AccountDetails/AddressDisplay.tsx @@ -20,7 +20,7 @@ export function AddressDisplay({ address, enableCopyAddress }: { address: Addres const uniswapUsername = unitag?.username const AddressDisplay = ( - + {uniswapUsername ?? ENSName ?? shortenAddress(address)} {uniswapUsername && } diff --git a/apps/web/src/components/AccountDetails/TransactionSummary.tsx b/apps/web/src/components/AccountDetails/TransactionSummary.tsx index 14d961448d6..48477aadf72 100644 --- a/apps/web/src/components/AccountDetails/TransactionSummary.tsx +++ b/apps/web/src/components/AccountDetails/TransactionSummary.tsx @@ -3,7 +3,6 @@ import { nativeOnChain } from 'constants/tokens' import { BigNumber } from 'ethers/lib/ethers' import { useCurrency, useToken } from 'hooks/Tokens' import useENSName from 'hooks/useENSName' -import { Trans } from 'i18n' import JSBI from 'jsbi' import { VoteOption } from 'state/governance/types' import { @@ -26,6 +25,7 @@ import { VoteTransactionInfo, WrapTransactionInfo, } from 'state/transactions/types' +import { Trans } from 'uniswap/src/i18n' function formatAmount(amountRaw: string, decimals: number, sigFigs: number): string { return new Fraction(amountRaw, JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(decimals))).toSignificant(sigFigs) @@ -71,7 +71,7 @@ function FormattedCurrencyAmountManaged({ function ClaimSummary({ info: { recipient, uniAmountRaw } }: { info: ClaimTransactionInfo }) { const { ENSName } = useENSName() - const name = ENSName ?? recipient + const username = ENSName ?? recipient return typeof uniAmountRaw === 'string' ? ( , }} values={{ - name, + username, }} /> ) : ( - + ) } @@ -95,7 +95,7 @@ function ApprovalSummary({ info }: { info: ApproveTransactionInfo }) { const token = useToken(info.tokenAddress) return BigNumber.from(info.amount)?.eq(0) ? ( - + ) : ( ) @@ -140,8 +140,8 @@ function ExecuteSummary({ info }: { info: ExecuteTransactionInfo }) { function DelegateSummary({ info: { delegatee } }: { info: DelegateTransactionInfo }) { const { ENSName } = useENSName(delegatee) - const name = ENSName ?? delegatee - return + const username = ENSName ?? delegatee + return } function WrapSummary({ info: { chainId, currencyAmountRaw, unwrapped } }: { info: WrapTransactionInfo }) { @@ -303,10 +303,10 @@ function AddLiquidityV2PoolSummary({ ), - quote: ( + quoteAmountAndToken: ( ), }} diff --git a/apps/web/src/components/AccountDrawer/AnalyticsToggle.tsx b/apps/web/src/components/AccountDrawer/AnalyticsToggle.tsx index 41af1e9630a..970fa09e367 100644 --- a/apps/web/src/components/AccountDrawer/AnalyticsToggle.tsx +++ b/apps/web/src/components/AccountDrawer/AnalyticsToggle.tsx @@ -1,6 +1,6 @@ import { SettingsToggle } from 'components/AccountDrawer/SettingsToggle' -import { t } from 'i18n' import { useState } from 'react' +import { t } from 'uniswap/src/i18n' // eslint-disable-next-line no-restricted-imports import { analytics, getAnalyticsAtomDirect } from 'utilities/src/telemetry/analytics/analytics' diff --git a/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx b/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx index 1210e9e0875..7f2825df3a1 100644 --- a/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx +++ b/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx @@ -19,9 +19,9 @@ import { DeltaArrow } from 'components/Tokens/TokenDetails/Delta' import { LoadingBubble } from 'components/Tokens/loading' import { useTokenBalancesQuery } from 'graphql/data/apollo/TokenBalancesProvider' import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' +import { useDisconnect } from 'hooks/useDisconnect' import useENSName from 'hooks/useENSName' import { useIsUniExtensionAvailable } from 'hooks/useUniswapWalletOptions' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hooks' import { ProfilePageStateType } from 'nft/types' @@ -37,9 +37,9 @@ import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' +import { Trans, t } from 'uniswap/src/i18n' import { isPathBlocked } from 'utils/blockedPaths' import { NumberType, useFormatter } from 'utils/formatNumbers' -import { useDisconnect } from 'wagmi' const AuthenticatedHeaderWrapper = styled.div<{ isUniExtensionAvailable?: boolean }>` padding: ${({ isUniExtensionAvailable }) => (isUniExtensionAvailable ? 16 : 20)}px 16px; @@ -112,7 +112,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account const { formatNumber, formatDelta } = useFormatter() const isUniExtensionAvailable = useIsUniExtensionAvailable() - const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregatorWeb) + const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregator) const shouldDisableNFTRoutes = useDisableNFTRoutes() const unclaimedAmount: CurrencyAmount | undefined = useUserUnclaimedAmount(account) diff --git a/apps/web/src/components/AccountDrawer/DefaultMenu.tsx b/apps/web/src/components/AccountDrawer/DefaultMenu.tsx index f3d925d21d4..043cda4fbb2 100644 --- a/apps/web/src/components/AccountDrawer/DefaultMenu.tsx +++ b/apps/web/src/components/AccountDrawer/DefaultMenu.tsx @@ -64,7 +64,7 @@ function DefaultMenu({ drawerOpen }: { drawerOpen: boolean }) { return account.address ? ( ) : ( - + ) case MenuState.SETTINGS: return ( diff --git a/apps/web/src/components/AccountDrawer/DownloadButton.tsx b/apps/web/src/components/AccountDrawer/DownloadButton.tsx deleted file mode 100644 index aea0f44b17d..00000000000 --- a/apps/web/src/components/AccountDrawer/DownloadButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { InterfaceElementName } from '@uniswap/analytics-events' -import styled from 'lib/styled-components' -import { PropsWithChildren, useCallback } from 'react' -import { ClickableStyle } from 'theme/components' -import { openDownloadApp } from 'utils/openDownloadApp' - -const StyledButton = styled.button<{ padded?: boolean; branded?: boolean }>` - ${ClickableStyle} - width: 100%; - display: flex; - justify-content: center; - flex-direction: row; - gap: 6px; - padding: 8px 24px; - border: none; - white-space: nowrap; - background: ${({ theme, branded }) => (branded ? theme.accent1 : theme.surface3)}; - border-radius: 12px; - - font-weight: 535; - font-size: 14px; - line-height: 16px; - color: ${({ theme, branded }) => (branded ? theme.deprecated_accentTextLightPrimary : theme.neutral1)}; -` - -function BaseButton({ onClick, branded, children }: PropsWithChildren<{ onClick?: () => void; branded?: boolean }>) { - return ( - - {children} - - ) -} - -// Launches App/Play Store if on an iOS/Android device, else navigates to Uniswap Wallet microsite -export function DownloadButton({ - onClick, - text = 'Download', - element, -}: { - onClick?: () => void - text?: string - element: InterfaceElementName -}) { - const onButtonClick = useCallback(() => { - // handles any actions required by the parent, i.e. cancelling wallet connection attempt or dismissing an ad - onClick?.() - openDownloadApp({ element }) - }, [element, onClick]) - - return ( - - {text} - - ) -} diff --git a/apps/web/src/components/AccountDrawer/GitVersionRow.tsx b/apps/web/src/components/AccountDrawer/GitVersionRow.tsx index f19b7936c8f..d2038cf4abd 100644 --- a/apps/web/src/components/AccountDrawer/GitVersionRow.tsx +++ b/apps/web/src/components/AccountDrawer/GitVersionRow.tsx @@ -1,8 +1,8 @@ import Tooltip from 'components/Tooltip' import useCopyClipboard from 'hooks/useCopyClipboard' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Container = styled.div` width: 100%; diff --git a/apps/web/src/components/AccountDrawer/LanguageMenu.tsx b/apps/web/src/components/AccountDrawer/LanguageMenu.tsx index 3af21e1bdfd..b2c29da6149 100644 --- a/apps/web/src/components/AccountDrawer/LanguageMenu.tsx +++ b/apps/web/src/components/AccountDrawer/LanguageMenu.tsx @@ -3,8 +3,8 @@ import { MenuColumn, MenuItem } from 'components/AccountDrawer/shared' import { LOCALE_LABEL, SUPPORTED_LOCALES, SupportedLocale } from 'constants/locales' import { useActiveLocale } from 'hooks/useActiveLocale' import { useLocationLinkProps } from 'hooks/useLocationLinkProps' -import { Trans } from 'i18n' import { useUserLocaleManager } from 'state/user/hooks' +import { Trans } from 'uniswap/src/i18n' function LanguageMenuItem({ locale, isActive }: { locale: SupportedLocale; isActive: boolean }) { const { to, onClick } = useLocationLinkProps(locale) diff --git a/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx b/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx index aeb887d83d9..428759c85ed 100644 --- a/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx +++ b/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx @@ -3,9 +3,9 @@ import { MenuColumn, MenuItem } from 'components/AccountDrawer/shared' import { SUPPORTED_LOCAL_CURRENCIES, SupportedLocalCurrency, getLocalCurrencyIcon } from 'constants/localCurrencies' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useLocalCurrencyLinkProps } from 'hooks/useLocalCurrencyLinkProps' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useMemo } from 'react' +import { Trans } from 'uniswap/src/i18n' const StyledLocalCurrencyIcon = styled.div` width: 20px; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx index e73c9e4fb82..c2c8ef96d7d 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx @@ -8,14 +8,12 @@ import Column from 'components/Column' import AlertTriangleFilled from 'components/Icons/AlertTriangleFilled' import { LoaderV2 } from 'components/Icons/LoadingSpinner' import Row from 'components/Row' -import useENSName from 'hooks/useENSName' import styled from 'lib/styled-components' import { useCallback } from 'react' import { SignatureType } from 'state/signatures/types' import { EllipsisStyle, ThemedText } from 'theme/components' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import Trace from 'uniswap/src/features/telemetry/Trace' -import { shortenAddress } from 'utilities/src/addresses' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' const ActivityRowDescriptor = styled(ThemedText.BodySmall)` @@ -48,6 +46,7 @@ function StatusIndicator({ activity: { status, timestamp, offchainOrderDetails } } } +// TODO WEB-4550 - Fix regression where ENS name is not displayed in activity row export function ActivityRow({ activity }: { activity: Activity }) { const { chainId, @@ -64,7 +63,6 @@ export function ActivityRow({ activity }: { activity: Activity }) { const openOffchainActivityModal = useOpenOffchainActivityModal() - const { ENSName } = useENSName(otherAccount) const explorerUrl = getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION) const onClick = useCallback(() => { @@ -98,12 +96,7 @@ export function ActivityRow({ activity }: { activity: Activity }) { {suffixIconSrc && } } - descriptor={ - - {descriptor} - {ENSName ?? shortenAddress(otherAccount)} - - } + descriptor={{descriptor}} right={} onClick={onClick} /> diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx index c1c51fb0bac..353b3c97c1c 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx @@ -10,11 +10,11 @@ import Row from 'components/Row' import { DetailLineItem } from 'components/swap/DetailLineItem' import { nativeOnChain } from 'constants/tokens' import { useStablecoinValue } from 'hooks/useStablecoinPrice' -import { Plural, Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { Slash } from 'react-feather' import { SignatureType, UniswapXOrderDetails } from 'state/signatures/types' import { ExternalLink, ThemedText } from 'theme/components' +import { Plural, Trans, t } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -47,17 +47,12 @@ function useCancelOrdersDialogContent( switch (state) { case CancellationState.REVIEWING_CANCELLATION: return { - title: ( - - ), + title: + orders.length === 1 && orders[0].type === SignatureType.SIGN_LIMIT ? ( + + ) : ( + + ), icon: , } case CancellationState.PENDING_SIGNATURE: diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx index 2ad628133d6..5d0892ba27f 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx @@ -22,7 +22,6 @@ import { SwapModalHeaderAmount } from 'components/swap/SwapModalHeaderAmount' import { Field } from 'components/swap/constants' import { useCurrency } from 'hooks/Tokens' import { useUSDPrice } from 'hooks/useUSDPrice' -import { Trans } from 'i18n' import { atom } from 'jotai' import { useAtomValue, useUpdateAtom } from 'jotai/utils' import styled, { useTheme } from 'lib/styled-components' @@ -34,6 +33,7 @@ import { Divider, ThemedText } from 'theme/components' import { UniswapXOrderStatus } from 'types/uniswapx' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -266,7 +266,7 @@ export function OrderContent({ {Boolean(order.status === UniswapXOrderStatus.OPEN && order.encodedOrder) && ( {order.type === SignatureType.SIGN_LIMIT ? ( - + ) : ( )} diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainOrderLineItem.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainOrderLineItem.tsx index c01849fe635..9b2a6f066c4 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainOrderLineItem.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainOrderLineItem.tsx @@ -2,9 +2,9 @@ import { Currency, CurrencyAmount, Price } from '@uniswap/sdk-core' import { formatTimestamp } from 'components/AccountDrawer/MiniPortfolio/formatTimestamp' import { DetailLineItem, LineItemData } from 'components/swap/DetailLineItem' import TradePrice from 'components/swap/TradePrice' -import { Trans } from 'i18n' import { UniswapXOrderDetails } from 'state/signatures/types' import { ExternalLink } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { ellipseMiddle } from 'utilities/src/addresses' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap index 3378ff6b8ec..0314a791a72 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap @@ -317,19 +317,23 @@ exports[`OrderContent should render without error, filled order 1`] = ` class="c3" >
- - + > + + +
- - + > + + +
@@ -451,15 +459,19 @@ exports[`OrderContent should render without error, filled order 1`] = ` class="c3" >
- - + > + + +
@@ -947,19 +959,23 @@ exports[`OrderContent should render without error, limit order 1`] = ` class="c3" >
- - + > + + +
- - + > + + +
@@ -1081,15 +1101,19 @@ exports[`OrderContent should render without error, limit order 1`] = ` class="c3" >
- - + > + + +
@@ -1575,19 +1599,23 @@ exports[`OrderContent should render without error, open order 1`] = ` class="c3" >
- - + > + + +
- - + > + + +
@@ -1709,15 +1741,19 @@ exports[`OrderContent should render without error, open order 1`] = ` class="c3" >
- - + > + + +
diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/parseRemote.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/parseRemote.test.tsx.snap index b20cbfc1ea5..3a04c734b67 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/parseRemote.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/parseRemote.test.tsx.snap @@ -51,7 +51,7 @@ exports[`parseRemote parseRemoteActivities should parse NFT receive 1`] = ` Object { "chainId": 1, "currencies": undefined, - "descriptor": "1 SomeCollectionName from ", + "descriptor": "1 SomeCollectionName from 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "from": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3", "hash": "someHash", "isSpam": false, @@ -59,7 +59,7 @@ Object { "imageUrl", ], "nonce": 12345, - "otherAccount": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3", + "otherAccount": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "status": "CONFIRMED", "timestamp": 10000, "title": "Received", @@ -296,7 +296,7 @@ Object { "symbol": "WETH", }, ], - "descriptor": "100 WETH from ", + "descriptor": "100 WETH from 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "from": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3", "hash": "someHash", "isSpam": false, @@ -304,7 +304,7 @@ Object { "logoUrl", ], "nonce": 12345, - "otherAccount": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3", + "otherAccount": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "status": "CONFIRMED", "timestamp": 10000, "title": "Received", @@ -369,7 +369,7 @@ Object { "symbol": "DAI", }, ], - "descriptor": "100 DAI to ", + "descriptor": "100 DAI to 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "from": "0x50EC05ADe8280758E2077fcBC08D878D4aef79C3", "hash": "someHash", "isSpam": false, diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts index 3033e42c30e..8656ebb9b09 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.ts @@ -12,7 +12,6 @@ import { } from 'components/AccountDrawer/MiniPortfolio/constants' import { SupportedInterfaceChainId } from 'constants/chains' import { nativeOnChain } from 'constants/tokens' -import { t } from 'i18n' import { isOnChainOrder, useAllSignatures } from 'state/signatures/hooks' import { SignatureDetails, SignatureType } from 'state/signatures/types' import { isConfirmedTx, useMultichainTransactions } from 'state/transactions/hooks' @@ -32,6 +31,7 @@ import { WrapTransactionInfo, } from 'state/transactions/types' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' import { isAddress } from 'utilities/src/addresses' import { logger } from 'utilities/src/logger/logger' @@ -45,7 +45,7 @@ function buildCurrencyDescriptor( currencyB: Currency | undefined, amtB: string, formatNumber: FormatNumberFunctionType, - delimiter = t('for'), + isSwap = false, ) { const formattedA = currencyA ? formatNumber({ @@ -53,15 +53,27 @@ function buildCurrencyDescriptor( type: NumberType.TokenNonTx, }) : t('common.unknown') - const symbolA = currencyA?.symbol ?? '' + const symbolA = currencyA?.symbol ? ` ${currencyA?.symbol}` : '' const formattedB = currencyB ? formatNumber({ input: parseFloat(CurrencyAmount.fromRawAmount(currencyB, amtB).toSignificant()), type: NumberType.TokenNonTx, }) : t('common.unknown') - const symbolB = currencyB?.symbol ?? '' - return [formattedA, symbolA, delimiter, formattedB, symbolB].filter(Boolean).join(' ') + const symbolB = currencyB?.symbol ? ` ${currencyB?.symbol}` : '' + + const amountWithSymbolA = `${formattedA}${symbolA}` + const amountWithSymbolB = `${formattedB}${symbolB}` + + return isSwap + ? t('activity.transaction.swap.descriptor', { + amountWithSymbolA, + amountWithSymbolB, + }) + : t('activity.transaction.tokens.descriptor', { + amountWithSymbolA, + amountWithSymbolB, + }) } async function parseSwap( @@ -79,7 +91,7 @@ async function parseSwap( : [swap.expectedInputCurrencyAmountRaw, swap.outputCurrencyAmountRaw] return { - descriptor: buildCurrencyDescriptor(tokenIn, inputRaw, tokenOut, outputRaw, formatNumber, undefined), + descriptor: buildCurrencyDescriptor(tokenIn, inputRaw, tokenOut, outputRaw, formatNumber, true), currencies: [tokenIn, tokenOut], prefixIconSrc: swap.isUniswapXOrder ? UniswapXBolt : undefined, } @@ -101,6 +113,7 @@ function parseWrap( output, wrap.currencyAmountRaw, formatNumber, + true, ) const title = getActivityTitle(TransactionType.WRAP, status, wrap.unwrapped) const currencies = wrap.unwrapped ? [wrapped, native] : [native, wrapped] @@ -140,14 +153,7 @@ async function parseLP( getCurrency(lp.quoteCurrencyId, chainId), ]) const [baseRaw, quoteRaw] = [lp.expectedAmountBaseRaw, lp.expectedAmountQuoteRaw] - const descriptor = buildCurrencyDescriptor( - baseCurrency, - baseRaw, - quoteCurrency, - quoteRaw, - formatNumber, - t('common.endAdornment'), - ) + const descriptor = buildCurrencyDescriptor(baseCurrency, baseRaw, quoteCurrency, quoteRaw, formatNumber) return { descriptor, currencies: [baseCurrency, quoteCurrency] } } @@ -181,7 +187,10 @@ async function parseMigrateCreateV3( ]) const baseSymbol = baseCurrency?.symbol ?? t('common.unknown') const quoteSymbol = quoteCurrency?.symbol ?? t('common.unknown') - const descriptor = t(`{{baseSymbol}} and {{quoteSymbol}}`, { baseSymbol, quoteSymbol }) + const descriptor = t('activity.transaction.tokens.descriptor', { + amountWithSymbolA: baseSymbol, + amountWithSymbolB: quoteSymbol, + }) return { descriptor, currencies: [baseCurrency, quoteCurrency] } } @@ -199,10 +208,14 @@ async function parseSend( type: NumberType.TokenNonTx, }) : t('common.unknown') + const otherAccount = isAddress(recipient) || undefined return { - descriptor: `${formattedAmount} ${currency?.symbol} ${t('common.to')} `, - otherAccount: isAddress(recipient) || undefined, + descriptor: t('activity.transaction.send.descriptor', { + amountWithSymbol: `${formattedAmount} ${currency?.symbol}`, + walletAddress: recipient, + }), + otherAccount, currencies: [currency], } } diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx index 1f0a8c0e7ac..d45fba13588 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx @@ -11,7 +11,6 @@ import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens' import { BigNumber } from 'ethers/lib/ethers' import { formatUnits, parseUnits } from 'ethers/lib/utils' import { gqlToCurrency, supportedChainIdFromGQLChain } from 'graphql/data/util' -import { t } from 'i18n' import ms from 'ms' import { useEffect, useState } from 'react' import { parseRemote as parseRemoteSignature } from 'state/signatures/parseRemote' @@ -32,6 +31,7 @@ import { TransactionDetailsPartsFragment, TransactionType, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { isAddress, isSameAddress } from 'utilities/src/addresses' import { logger } from 'utilities/src/logger/logger' @@ -161,7 +161,10 @@ function getSwapDescriptor({ tokenOut: TokenAssetPartsFragment inputAmount: string }) { - return `${inputAmount} ${tokenIn.symbol} for ${outputAmount} ${tokenOut.symbol}` + return t('activity.transaction.swap.descriptor', { + amountWithSymbolA: `${inputAmount} ${tokenIn.symbol}`, + amountWithSymbolB: `${outputAmount} ${tokenOut.symbol}`, + }) } /** @@ -351,7 +354,10 @@ function parseLPTransfers(changes: TransactionChanges, formatNumberOrString: For const tokenBQuantity = formatNumberOrString({ input: poolTokenB.quantity, type: NumberType.TokenNonTx }) return { - descriptor: `${tokenAQuanitity} ${poolTokenA.asset.symbol} and ${tokenBQuantity} ${poolTokenB.asset.symbol}`, + descriptor: t('activity.transaction.tokens.descriptor', { + amountWithSymbolA: `${tokenAQuanitity} ${poolTokenA.asset.symbol}`, + amountWithSymbolB: `${tokenBQuantity} ${poolTokenB.asset.symbol}`, + }), logos: [poolTokenA.asset.project?.logo?.url, poolTokenB.asset.project?.logo?.url], currencies: [gqlToCurrency(poolTokenA.asset), gqlToCurrency(poolTokenB.asset)], } @@ -388,29 +394,39 @@ function parseSendReceive( if (transfer && assetName && amount) { const isMoonpayPurchase = MOONPAY_SENDER_ADDRESSES.some((address) => isSameAddress(address, transfer?.sender)) + const otherAccount = isAddress(transfer.recipient) || undefined if (transfer.direction === 'IN') { return isMoonpayPurchase && transfer.__typename === 'TokenTransfer' ? { title: t('common.purchased'), - descriptor: `${amount} ${assetName} ${t('for')} ${formatNumberOrString({ - input: getTransactedValue(transfer.transactedValue), - type: NumberType.FiatTokenPrice, - })}`, + descriptor: t('activity.transaction.swap.descriptor', { + amountWithSymbolA: `${amount} ${assetName}`, + amountWithSymbolB: formatNumberOrString({ + input: getTransactedValue(transfer.transactedValue), + type: NumberType.FiatTokenPrice, + }), + }), logos: [moonpayLogoSrc], currencies, } : { title: t('common.received'), - descriptor: `${amount} ${assetName} ${t('common.from')} `, - otherAccount: isAddress(transfer.sender) || undefined, + descriptor: t('activity.transaction.receive.descriptor', { + amountWithSymbol: `${amount} ${assetName}`, + walletAddress: otherAccount, + }), + otherAccount, currencies, } } else { return { title: t('common.sent'), - descriptor: `${amount} ${assetName} ${t('common.to')} `, - otherAccount: isAddress(transfer.recipient) || undefined, + descriptor: t('activity.transaction.send.descriptor', { + amountWithSymbol: `${amount} ${assetName}`, + walletAddress: otherAccount, + }), + otherAccount, currencies, } } diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.ts index 2fd81d01218..85073f7bf82 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.ts @@ -7,7 +7,6 @@ import { getYear, isSameDay, isSameMonth, isSameWeek, isSameYear } from 'date-fn import { ContractTransaction } from 'ethers/lib/ethers' import { useContract } from 'hooks/useContract' import { useEthersWeb3Provider } from 'hooks/useEthersProvider' -import { t } from 'i18n' import { useCallback } from 'react' import store from 'state' import { updateSignature } from 'state/signatures/reducer' @@ -18,6 +17,7 @@ import { Permit2 } from 'uniswap/src/abis/types' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { t } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { useAsyncData } from 'utilities/src/react/hooks' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx index 1edf1419eee..48c2856f54c 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx @@ -1,4 +1,3 @@ -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { useCallback, useMemo } from 'react' import { Flex, Text, useIsDarkMode } from 'ui/src' @@ -6,6 +5,7 @@ import { CRYPTO_PURCHASE_BACKGROUND_DARK, CRYPTO_PURCHASE_BACKGROUND_LIGHT } fro import { ArrowDownCircle, Buy as BuyIcon } from 'ui/src/components/icons' import { ActionCard, ActionCardItem } from 'uniswap/src/components/misc/ActionCard' import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { Trans, t } from 'uniswap/src/i18n' export const EmptyWallet = ({ handleBuyCryptoClick, diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx index 691556bd2b7..e014faf6f23 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx @@ -1,10 +1,10 @@ import Column from 'components/Column' import Row from 'components/Row' -import { t } from 'i18n' import styled from 'lib/styled-components' import { PropsWithChildren } from 'react' import { ChevronDown } from 'react-feather' import { ThemedText } from 'theme/components' +import { t } from 'uniswap/src/i18n' const ExpandIcon = styled(ChevronDown)<{ $expanded: boolean }>` color: ${({ theme }) => theme.neutral2}; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx index a808712f0c8..0aeaf222fb1 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx @@ -2,9 +2,9 @@ import { MenuState, miniPortfolioMenuStateAtom } from 'components/AccountDrawer/ import { useOpenLimitOrders, usePendingActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' import { useFilterPossiblyMaliciousPositionInfo } from 'components/AccountDrawer/MiniPortfolio/Pools' import useMultiChainPositions from 'components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions' +import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { Pool } from 'components/Icons/Pool' import { ExtensionRequestMethods, useUniswapExtensionConnector } from 'components/WalletModal/useOrderedConnections' -import { t } from 'i18n' import { useUpdateAtom } from 'jotai/utils' import { useTheme } from 'lib/styled-components' import { useEffect, useState } from 'react' @@ -12,6 +12,7 @@ import { Button, Flex, Image, Text } from 'ui/src' import { UNISWAP_LOGO } from 'ui/src/assets' import { ArrowRightToLine, RotatableChevron, TimePast } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme/iconSizes' +import { t } from 'uniswap/src/i18n' const UnreadIndicator = () => { const theme = useTheme() @@ -36,12 +37,13 @@ const DeepLinkButton = ({ Icon, Label, onPress }: { Icon: JSX.Element; Label: st py="$spacing12" fontSize="$large" onPress={onPress} + hoverStyle={{ opacity: 0.9 }} > {Icon} {Label} - + ) } @@ -49,6 +51,7 @@ const DeepLinkButton = ({ Icon, Label, onPress }: { Icon: JSX.Element; Label: st export function ExtensionDeeplinks({ account }: { account: string }) { const theme = useTheme() const uniswapExtensionConnector = useUniswapExtensionConnector() + const accountDrawer = useAccountDrawer() const setMenu = useUpdateAtom(miniPortfolioMenuStateAtom) const { openLimitOrders } = useOpenLimitOrders(account) @@ -74,6 +77,7 @@ export function ExtensionDeeplinks({ account }: { account: string }) { Label={t('extension.open')} onPress={() => { uniswapExtensionConnector.extensionRequest(ExtensionRequestMethods.OPEN_SIDEBAR, 'Tokens') + accountDrawer.close() }} /> { uniswapExtensionConnector.extensionRequest(ExtensionRequestMethods.OPEN_SIDEBAR, 'Activity') + accountDrawer.close() setActivityUnread(false) }} /> diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx index 103c2f58e98..0e1927acc77 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx @@ -11,13 +11,13 @@ import Row from 'components/Row' import { parseUnits } from 'ethers/lib/utils' import { useCurrencyInfo } from 'hooks/Tokens' import { useScreenSize } from 'hooks/screenSize' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { Checkbox } from 'nft/components/layout/Checkbox' import { useMemo, useState } from 'react' import { ArrowRight } from 'react-feather' import { EllipsisStyle, ThemedText } from 'theme/components' import { UniswapXOrderStatus } from 'types/uniswapx' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' const StyledPortfolioRow = styled(PortfolioRow)` diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx index 06dbf0679ba..3b57c63df98 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx @@ -10,11 +10,11 @@ import { SlideOutMenu } from 'components/AccountDrawer/SlideOutMenu' import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button' import Column from 'components/Column' import { LimitDisclaimer } from 'components/swap/LimitDisclaimer' -import { Plural, Trans, t } from 'i18n' import styled from 'lib/styled-components' import { useMemo, useState } from 'react' import { UniswapXOrderDetails } from 'state/signatures/types' import { UniswapXOrderStatus } from 'types/uniswapx' +import { Trans, t } from 'uniswap/src/i18n' const Container = styled(Column)` height: 100%; @@ -72,11 +72,7 @@ export function LimitsMenu({ onClose, account }: { account: string; onClose: () size={ButtonSize.medium} disabled={cancelState !== CancellationState.NOT_STARTED || selectedOrders.length === 0} > - + {t('common.limit.cancel', { count: selectedOrders.length })} )} diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx index 7e36e0aaebe..6554587c961 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx @@ -1,8 +1,8 @@ import { useOpenLimitOrders } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' import { TabButton } from 'components/AccountDrawer/MiniPortfolio/shared' -import { Plural, Trans, t } from 'i18n' import { useTheme } from 'lib/styled-components' import { Clock } from 'react-feather' +import { Trans, useTranslation } from 'uniswap/src/i18n' function getExtraWarning(openLimitOrders: any[]) { if (openLimitOrders.length >= 100) { @@ -25,6 +25,7 @@ export function OpenLimitOrdersButton({ disabled?: boolean className?: string }) { + const { t } = useTranslation() const { openLimitOrders } = useOpenLimitOrders(account) const theme = useTheme() const extraWarning = getExtraWarning(openLimitOrders) @@ -35,13 +36,7 @@ export function OpenLimitOrdersButton({ return ( - } + text={t('limit.open.count', { count: openLimitOrders.length })} icon={} extraWarning={extraWarning} onClick={openLimitsMenu} diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/OpenLimitOrdersButton.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/OpenLimitOrdersButton.test.tsx.snap index da72cda05b3..d422c4caa77 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/OpenLimitOrdersButton.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/OpenLimitOrdersButton.test.tsx.snap @@ -117,7 +117,7 @@ exports[`OpenLimitOrdersButton should render if there are open limit orders 1`] class="c4" > 1 open limit diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx index 3787f70b3e8..b81eedeb1a8 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx @@ -3,7 +3,6 @@ import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import Column from 'components/Column' import Row from 'components/Row' import { MouseFollowTooltip, TooltipSize } from 'components/Tooltip' -import { t } from 'i18next' import styled from 'lib/styled-components' import { Box } from 'nft/components/Box' import { NftCard } from 'nft/components/card' @@ -15,6 +14,7 @@ import { ThemedText } from 'theme/components' import { capitalize } from 'tsafe' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { t } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx index e23566da226..bf632098a74 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx @@ -3,7 +3,6 @@ import { DEFAULT_NFT_QUERY_AMOUNT } from 'components/AccountDrawer/MiniPortfolio import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { TabButton } from 'components/AccountDrawer/MiniPortfolio/shared' import { useNftBalance } from 'graphql/data/nft/NftBalance' -import { t } from 'i18n' import styled from 'lib/styled-components' import { LoadingAssets } from 'nft/components/collection/CollectionAssetLoading' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' @@ -16,6 +15,7 @@ import { Gallery } from 'ui/src/components/icons' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { t } from 'uniswap/src/i18n' const StyledTabButton = styled(TabButton)` width: calc(100% - 32px); @@ -23,7 +23,7 @@ const StyledTabButton = styled(TabButton)` ` export default function NFTs({ account }: { account: string }) { - const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregatorWeb) + const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregator) const accountDrawer = useAccountDrawer() const navigate = useNavigate() const setSellPageState = useProfilePageState((state) => state.setProfilePageState) diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx index 85ff437a5be..74da16261f4 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx @@ -1,8 +1,8 @@ import Pools from 'components/AccountDrawer/MiniPortfolio/Pools' import { SlideOutMenu } from 'components/AccountDrawer/SlideOutMenu' import Column from 'components/Column' -import { Trans } from 'i18n' import styled from 'lib/styled-components' +import { Trans } from 'uniswap/src/i18n' const Container = styled(Column)` height: 100%; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx index 93913c03cb7..1ff2fd56cc1 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx @@ -16,13 +16,13 @@ import { BIPS_BASE } from 'constants/misc' import { useAccount } from 'hooks/useAccount' import { useFilterPossiblyMaliciousPositions } from 'hooks/useFilterPossiblyMaliciousPositions' import { useSwitchChain } from 'hooks/useSwitchChain' -import { t } from 'i18n' import styled from 'lib/styled-components' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useCallback, useMemo, useReducer } from 'react' import { useNavigate } from 'react-router-dom' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { t } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' /** @@ -86,7 +86,7 @@ export default function Pools({ account }: { account: string }) { /> ))} size?: number style?: React.CSSProperties + loading?: boolean } function SquareL2Logo({ chainId, size }: { chainId: InterfaceChainId; size: number }) { @@ -49,6 +51,14 @@ function SquareL2Logo({ chainId, size }: { chainId: InterfaceChainId; size: numb const LOGO_DEFAULT_SIZE = 40 +const AbsoluteCenteredElement = TamaguiStyled(Flex, { + position: 'absolute', + ml: 'auto', + mr: 'auto', + left: -4.5, + top: -4.5, +}) + // TODO(WEB-2983) /** * Renders an image by prioritizing a list of sources, and then eventually a fallback contract icon @@ -56,7 +66,14 @@ const LOGO_DEFAULT_SIZE = 40 export function PortfolioLogo(props: PortfolioLogoProps) { return ( - {getLogo(props)} + + {props.size && props.loading && ( + + + + )} + {getLogo(props)} + ) diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/__snapshots__/PortfolioLogo.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/__snapshots__/PortfolioLogo.test.tsx.snap index afeba20be0d..0be95fae79b 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/__snapshots__/PortfolioLogo.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/__snapshots__/PortfolioLogo.test.tsx.snap @@ -82,19 +82,23 @@ exports[`PortfolioLogo renders with L2 icon 1`] = ` class="c0" >
- - + > + + +
- - + > + + +
diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/constants.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/constants.tsx index 96555d8c74e..badb602d930 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/constants.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/constants.tsx @@ -1,7 +1,7 @@ -import { t } from 'i18n' import { TransactionType } from 'state/transactions/types' import { UniswapXOrderStatus } from 'types/uniswapx' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' // use even number because rows are in groups of 2 export const DEFAULT_NFT_QUERY_AMOUNT = 26 @@ -48,7 +48,7 @@ const TransactionTitleTable: { [key in TransactionType]: { [state in Transaction [TransactionStatus.Failed]: t('common.claim.failed'), }, [TransactionType.BUY]: { - [TransactionStatus.Pending]: t('Buying'), + [TransactionStatus.Pending]: t('common.buying'), [TransactionStatus.Confirmed]: t('common.bought'), [TransactionStatus.Failed]: t('common.buy.failed'), }, diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx index 4cc631dd607..ee67065ab46 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx @@ -10,13 +10,13 @@ import { LoaderV2 } from 'components/Icons/LoadingSpinner' import { AutoRow } from 'components/Row' import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' import { useIsNftPage } from 'hooks/useIsNftPage' -import { Trans } from 'i18n' import { atom, useAtom } from 'jotai' import styled, { useTheme } from 'lib/styled-components' import { useEffect, useState } from 'react' import { BREAKPOINTS } from 'theme' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' const lastPageAtom = atom(0) diff --git a/apps/web/src/components/AccountDrawer/SettingsMenu.tsx b/apps/web/src/components/AccountDrawer/SettingsMenu.tsx index a2259883571..b33f07bc790 100644 --- a/apps/web/src/components/AccountDrawer/SettingsMenu.tsx +++ b/apps/web/src/components/AccountDrawer/SettingsMenu.tsx @@ -10,14 +10,12 @@ import Row from 'components/Row' import { LOCALE_LABEL } from 'constants/locales' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useActiveLocale } from 'hooks/useActiveLocale' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ReactNode } from 'react' import { ChevronRight } from 'react-feather' import { ClickableStyle, ThemedText } from 'theme/components' import ThemeToggle from 'theme/components/ThemeToggle' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { Trans } from 'uniswap/src/i18n' const Container = styled(Column)` height: 100%; @@ -33,7 +31,7 @@ const ToggleWrapper = styled.div<{ currencyConversionEnabled?: boolean }>` display: flex; flex-direction: column; gap: 16px; - margin-bottom: ${({ currencyConversionEnabled }) => (currencyConversionEnabled ? '10px' : '24px')}; + margin-bottom: '10px'; ` const SettingsButtonWrapper = styled(Row)` @@ -78,7 +76,6 @@ export default function SettingsMenu({ openLanguageSettings: () => void openLocalCurrencySettings: () => void }) { - const currencyConversionEnabled = useFeatureFlag(FeatureFlags.CurrencyConversion) const activeLocale = useActiveLocale() const activeLocalCurrency = useActiveLocalCurrency() @@ -86,38 +83,34 @@ export default function SettingsMenu({ } onClose={onClose}>
- + - {!currencyConversionEnabled && ( - <> - - - - - - )} + <> + + + + + - {currencyConversionEnabled && ( - - } - currentState={LOCALE_LABEL[activeLocale]} - onClick={openLanguageSettings} - testId="language-settings-button" - /> - } - currentState={activeLocalCurrency} - onClick={openLocalCurrencySettings} - testId="local-currency-settings-button" - /> - - )} + + } + currentState={LOCALE_LABEL[activeLocale]} + onClick={openLanguageSettings} + testId="language-settings-button" + /> + } + currentState={activeLocalCurrency} + onClick={openLocalCurrencySettings} + testId="local-currency-settings-button" + /> +
diff --git a/apps/web/src/components/AccountDrawer/SmallBalanceToggle.tsx b/apps/web/src/components/AccountDrawer/SmallBalanceToggle.tsx index 145862922f9..51bd71e95e1 100644 --- a/apps/web/src/components/AccountDrawer/SmallBalanceToggle.tsx +++ b/apps/web/src/components/AccountDrawer/SmallBalanceToggle.tsx @@ -1,7 +1,7 @@ import { SettingsToggle } from 'components/AccountDrawer/SettingsToggle' -import { t } from 'i18n' import { useAtom } from 'jotai' import { atomWithStorage } from 'jotai/utils' +import { t } from 'uniswap/src/i18n' export const hideSmallBalancesAtom = atomWithStorage('hideSmallBalances', true) diff --git a/apps/web/src/components/AccountDrawer/SpamToggle.tsx b/apps/web/src/components/AccountDrawer/SpamToggle.tsx index e0e1947cbe5..e61ab9bd027 100644 --- a/apps/web/src/components/AccountDrawer/SpamToggle.tsx +++ b/apps/web/src/components/AccountDrawer/SpamToggle.tsx @@ -1,9 +1,9 @@ import { SettingsToggle } from 'components/AccountDrawer/SettingsToggle' -import { Trans } from 'i18n' import { useAtom } from 'jotai' import { atomWithStorage } from 'jotai/utils' +import { Trans } from 'uniswap/src/i18n' -export const hideSpamAtom = atomWithStorage('hideSmallBalances', true) +export const hideSpamAtom = atomWithStorage('hideSpamBalances', true) export function SpamToggle() { const [hideSpam, updateHideSpam] = useAtom(hideSpamAtom) diff --git a/apps/web/src/components/AccountDrawer/TestnetsToggle.tsx b/apps/web/src/components/AccountDrawer/TestnetsToggle.tsx index 25e79e3f839..41b07a57608 100644 --- a/apps/web/src/components/AccountDrawer/TestnetsToggle.tsx +++ b/apps/web/src/components/AccountDrawer/TestnetsToggle.tsx @@ -1,7 +1,7 @@ import { SettingsToggle } from 'components/AccountDrawer/SettingsToggle' -import { t } from 'i18n' import { useAtom } from 'jotai' import { atomWithStorage } from 'jotai/utils' +import { t } from 'uniswap/src/i18n' export const showTestnetsAtom = atomWithStorage('showTestnets', false) diff --git a/apps/web/src/components/AccountDrawer/UniwalletModal.tsx b/apps/web/src/components/AccountDrawer/UniwalletModal.tsx index 170e3d11755..728211a8170 100644 --- a/apps/web/src/components/AccountDrawer/UniwalletModal.tsx +++ b/apps/web/src/components/AccountDrawer/UniwalletModal.tsx @@ -1,41 +1,19 @@ import { InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events' -import MobileAppLogo from 'assets/svg/mobile-app-qr-logo.svg' -import { DownloadButton } from 'components/AccountDrawer/DownloadButton' -import Column, { AutoColumn } from 'components/Column' +import MobileAppLogo from 'assets/svg/uniswap_app_logo.svg' import Modal from 'components/Modal' -import { RowBetween } from 'components/Row' import { useConnectorWithId } from 'components/WalletModal/useOrderedConnections' import { CONNECTION } from 'components/Web3Provider/constants' import { useConnect } from 'hooks/useConnect' -import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' -import { QRCodeSVG } from 'qrcode.react' import { useCallback, useEffect, useState } from 'react' -import { CloseIcon, ThemedText } from 'theme/components' +import { CloseIcon } from 'theme/components' +import { Button, Flex, Image, QRCodeDisplay, Separator, Text, useSporeColors } from 'ui/src' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useTranslation } from 'uniswap/src/i18n' import { isWebAndroid, isWebIOS } from 'utilities/src/platform' - -const UniwalletConnectWrapper = styled(RowBetween)` - display: flex; - flex-direction: column; - padding: 20px 16px 16px; -` -const HeaderRow = styled(RowBetween)` - display: flex; -` -const QRCodeWrapper = styled(RowBetween)` - aspect-ratio: 1; - border-radius: 12px; - background-color: ${({ theme }) => theme.white}; - margin: 24px 32px 20px; - padding: 10px; -` -const Divider = styled.div` - border-bottom: 1px solid ${({ theme }) => theme.surface3}; - width: 100%; -` +import { openDownloadApp } from 'utils/openDownloadApp' export default function UniwalletModal() { + const { t } = useTranslation() const [uri, setUri] = useState() const connection = useConnect() @@ -74,61 +52,52 @@ export default function UniwalletModal() { } }, [open]) - const theme = useTheme() + const colors = useSporeColors() return ( - - - - - + + + {t('account.drawer.modal.scan')} - - +
+ + {uri && ( - + + + + + )} - - - - - - ) -} - -const InfoSectionWrapper = styled(RowBetween)` - display: flex; - flex-direction: row; - padding-top: 20px; - gap: 20px; -` + + + + + {t('account.drawer.modal.dont')} + + {t('account.drawer.modal.body')} + + -function InfoSection() { - return ( - - - - - - - - - - - - - + + +
+ ) } diff --git a/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap b/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap index 41b371ff8d7..2eef857bcf0 100644 --- a/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap @@ -9,7 +9,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i width: 100%; } -.c14 { +.c12 { box-sizing: border-box; margin: 0; min-width: 0; @@ -35,7 +35,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: space-between; } -.c15 { +.c13 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -74,7 +74,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i letter-spacing: -0.01em; } -.c24 { +.c22 { color: #7D7D7D; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -82,7 +82,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i letter-spacing: -0.01em; } -.c25 { +.c23 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -93,11 +93,11 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i font-weight: 500; } -.c25:hover { +.c23:hover { opacity: 0.6; } -.c25:active { +.c23:active { opacity: 0.4; } @@ -115,7 +115,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: flex-start; } -.c13 { +.c11 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -133,7 +133,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i flex: 1; } -.c22 { +.c20 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -148,57 +148,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i gap: 12px; } -.c11 { - background-color: #FFFFFF; - -webkit-transition: width ease-in-out 125ms; - transition: width ease-in-out 125ms; - border-radius: 12px; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - cursor: pointer; - position: relative; - overflow: hidden; - height: 32px; - width: 32px; - color: #7D7D7D; - border: none; - outline: none; -} - -.c11:hover { - background-color: #F9F9F9; - -webkit-transition: 125ms background-color ease-in, width ease-in-out 125ms; - transition: 125ms background-color ease-in, width ease-in-out 125ms; -} - -.c11:active { - background-color: #FFFFFF; - -webkit-transition: background-color 125ms linear, width ease-in-out 125ms; - transition: background-color 125ms linear, width ease-in-out 125ms; -} - -.c12 { - width: 24px; - height: 24px; - margin: auto; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c19 { +.c17 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -215,7 +165,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i align-items: center; } -.c18 { +.c16 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -243,7 +193,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i transition: 125ms; } -.c21 { +.c19 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -265,7 +215,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i padding: 0 8px; } -.c20 { +.c18 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -283,18 +233,18 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: center; } -.c20 img { +.c18 img { border: 1px solid #22222212; border-radius: 12px; } -.c20 > img, -.c20 span { +.c18 > img, +.c18 span { height: 40px; width: 40px; } -.c17 { +.c15 { -webkit-align-items: stretch; -webkit-box-align: stretch; -ms-flex-align: stretch; @@ -315,16 +265,16 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i background-color: #F9F9F9; } -.c17:hover { +.c15:hover { cursor: pointer; background-color: #22222212; } -.c17:focus { +.c15:focus { background-color: #22222212; } -.c26 { +.c24 { font-weight: 535; color: #7D7D7D; } @@ -339,14 +289,14 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i flex-flow: column nowrap; background-color: #FFFFFF; width: 100%; - padding: 14px 16px 16px; + padding: 14px 16px 20px; -webkit-flex: 1; -ms-flex: 1; flex: 1; gap: 16px; } -.c16 { +.c14 { display: grid; -webkit-flex: 1; -ms-flex: 1; @@ -360,7 +310,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; } -.c23 { +.c21 { padding: 0 4px; } @@ -394,10 +344,6 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i -ms-scrollbar-color: #22222212 transparent; scrollbar-color: #22222212 transparent; height: 100%; - -webkit-scrollbar-gutter: stable; - -moz-scrollbar-gutter: stable; - -ms-scrollbar-gutter: stable; - scrollbar-gutter: stable; overscroll-behavior: contain; border-radius: 12px; } @@ -422,9 +368,8 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i -ms-flex-direction: row; flex-direction: row; height: calc(100% - 2 * 8px); - overflow: hidden; position: fixed; - right: 8px; + right: 0; top: 8px; z-index: 1030; } @@ -445,7 +390,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i } @media (max-width:960px) { - .c20 { + .c18 { -webkit-align-items: flex-end; -webkit-box-align: flex-end; -ms-flex-align: flex-end; @@ -454,7 +399,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i } @media (max-width:960px) { - .c16 { + .c14 { grid-template-columns: 1fr; } } @@ -543,51 +488,29 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i > Connect a wallet -
- By connecting a wallet, you agree to Uniswap Labs’ + By connecting a wallet, you agree to Uniswap Labs’ - Terms of Service + Terms of Service - and consent to its + and consent to its - Privacy Policy. + Privacy Policy + .
@@ -706,20 +630,20 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i width: 100%; } -.c15 { +.c13 { box-sizing: border-box; margin: 0; min-width: 0; } -.c20 { +.c17 { box-sizing: border-box; margin: 0; min-width: 0; padding: 8px 0px; } -.c24 { +.c21 { box-sizing: border-box; margin: 0; min-width: 0; @@ -727,7 +651,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i margin-right: 18px; } -.c28 { +.c25 { box-sizing: border-box; margin: 0; min-width: 0; @@ -753,7 +677,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: space-between; } -.c16 { +.c14 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -771,7 +695,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i gap: 12px; } -.c18 { +.c16 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -789,7 +713,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i gap: 4px; } -.c21 { +.c18 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -807,7 +731,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i padding: 8px 0px; } -.c25 { +.c22 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -824,7 +748,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: flex-start; } -.c29 { +.c26 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -863,7 +787,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i letter-spacing: -0.01em; } -.c37 { +.c34 { color: #7D7D7D; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -871,7 +795,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i letter-spacing: -0.01em; } -.c38 { +.c35 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -882,11 +806,11 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i font-weight: 500; } -.c38:hover { +.c35:hover { opacity: 0.6; } -.c38:active { +.c35:active { opacity: 0.4; } @@ -904,7 +828,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: flex-start; } -.c13 { +.c11 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -919,7 +843,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i gap: 16px; } -.c14 { +.c12 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -934,7 +858,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i gap: 12px; } -.c27 { +.c24 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -952,57 +876,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i flex: 1; } -.c11 { - background-color: #FFFFFF; - -webkit-transition: width ease-in-out 125ms; - transition: width ease-in-out 125ms; - border-radius: 12px; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - cursor: pointer; - position: relative; - overflow: hidden; - height: 32px; - width: 32px; - color: #7D7D7D; - border: none; - outline: none; -} - -.c11:hover { - background-color: #F9F9F9; - -webkit-transition: 125ms background-color ease-in, width ease-in-out 125ms; - transition: 125ms background-color ease-in, width ease-in-out 125ms; -} - -.c11:active { - background-color: #FFFFFF; - -webkit-transition: background-color 125ms linear, width ease-in-out 125ms; - transition: background-color 125ms linear, width ease-in-out 125ms; -} - -.c12 { - width: 24px; - height: 24px; - margin: auto; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c33 { +.c30 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1019,7 +893,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i align-items: center; } -.c32 { +.c29 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -1047,7 +921,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i transition: 125ms; } -.c35 { +.c32 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1069,7 +943,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i padding: 0 8px; } -.c34 { +.c31 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1087,18 +961,18 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i justify-content: center; } -.c34 img { +.c31 img { border: 1px solid #22222212; border-radius: 12px; } -.c34 > img, -.c34 span { +.c31 > img, +.c31 span { height: 40px; width: 40px; } -.c31 { +.c28 { -webkit-align-items: stretch; -webkit-box-align: stretch; -ms-flex-align: stretch; @@ -1119,21 +993,21 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i background-color: #F9F9F9; } -.c31:hover { +.c28:hover { cursor: pointer; background-color: #22222212; } -.c31:focus { +.c28:focus { background-color: #22222212; } -.c39 { +.c36 { font-weight: 535; color: #7D7D7D; } -.c17 { +.c15 { padding: 16px; gap: 12px; border-radius: 16px; @@ -1149,7 +1023,7 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i z-index: 1; } -.c17:hover { +.c15:hover { background: #22222212; } @@ -1163,14 +1037,14 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i flex-flow: column nowrap; background-color: #FFFFFF; width: 100%; - padding: 0px 16px 16px; + padding: 0px 16px 20px; -webkit-flex: 1; -ms-flex: 1; flex: 1; gap: 16px; } -.c30 { +.c27 { display: grid; -webkit-flex: 1; -ms-flex: 1; @@ -1178,17 +1052,17 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i grid-gap: 2px; border-radius: 12px; overflow: hidden; - opacity: 0; - max-height: 0; + opacity: 1; + max-height: 100vh; -webkit-transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; } -.c36 { +.c33 { padding: 0 4px; } -.c22 { +.c19 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -1200,21 +1074,21 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i user-select: none; } -.c22:hover { +.c19:hover { opacity: 0.6; } -.c22:active { +.c19:active { opacity: 0.4; } -.c23 { +.c20 { height: 1px; width: 100%; background: #22222212; } -.c26 { +.c23 { height: 20px; width: 20px; fill: #7D7D7D; @@ -1253,10 +1127,6 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i -ms-scrollbar-color: #22222212 transparent; scrollbar-color: #22222212 transparent; height: 100%; - -webkit-scrollbar-gutter: stable; - -moz-scrollbar-gutter: stable; - -ms-scrollbar-gutter: stable; - scrollbar-gutter: stable; overscroll-behavior: contain; border-radius: 12px; } @@ -1281,14 +1151,13 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i -ms-flex-direction: row; flex-direction: row; height: calc(100% - 2 * 8px); - overflow: hidden; position: fixed; - right: 8px; + right: 0; top: 8px; z-index: 1030; height: auto; max-height: calc(100% - 88px); - right: 24px; + right: 12px; top: 72px; -webkit-scrollbar-width: thin; -moz-scrollbar-width: thin; @@ -1325,6 +1194,15 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i box-shadow: 8px 12px 20px rgba(51,53,72,0.04),4px 6px 12px rgba(51,53,72,0.02),4px 4px 8px rgba(51,53,72,0.04); -webkit-transition: margin-right 250ms; transition: margin-right 250ms; + -webkit-scrollbar-width: thin; + -moz-scrollbar-width: thin; + -ms-scrollbar-width: thin; + scrollbar-width: thin; + -webkit-scrollbar-color: #22222212 transparent; + -moz-scrollbar-color: #22222212 transparent; + -ms-scrollbar-color: #22222212 transparent; + scrollbar-color: #22222212 transparent; + height: 100%; height: -webkit-max-content; height: -moz-max-content; height: max-content; @@ -1332,7 +1210,6 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i width: 368px; max-width: 368px; border-radius: 20px; - box-shadow: 8px 12px 20px rgba(51,53,72,0.04),4px 6px 12px rgba(51,53,72,0.02),4px 4px 8px rgba(51,53,72,0.04); -webkit-transform: scale(0.96); -ms-transform: scale(0.96); transform: scale(0.96); @@ -1340,14 +1217,25 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i -ms-transform-origin: top right; transform-origin: top right; opacity: 0; - overflow-y: scroll; + overflow-y: auto; -webkit-transition: -webkit-transform 125ms ease-in-out, opacity 125ms ease-in-out; -webkit-transition: transform 125ms ease-in-out, opacity 125ms ease-in-out; transition: transform 125ms ease-in-out, opacity 125ms ease-in-out; } +.c2::-webkit-scrollbar { + background: transparent; + width: 4px; + overflow-y: scroll; +} + +.c2::-webkit-scrollbar-thumb { + background: #22222212; + border-radius: 8px; +} + @media (max-width:960px) { - .c34 { + .c31 { -webkit-align-items: flex-end; -webkit-box-align: flex-end; -ms-flex-align: flex-end; @@ -1355,14 +1243,8 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i } } -@media screen and (max-width:396px) { - .c19 { - display: none; - } -} - @media (max-width:960px) { - .c30 { + .c27 { grid-template-columns: 1fr; } } @@ -1451,149 +1333,70 @@ exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable i > Connect a wallet
-
- Mobile Wallet + Uniswap Mobile Scan QR code to connect
-
- - - - - - - - - -
Other wallets +
- By connecting a wallet, you agree to Uniswap Labs’ + By connecting a wallet, you agree to Uniswap Labs’ - Terms of Service + Terms of Service - and consent to its + and consent to its - Privacy Policy. + Privacy Policy + .
diff --git a/apps/web/src/components/AccountDrawer/index.tsx b/apps/web/src/components/AccountDrawer/index.tsx index 110a4d2b04e..e75d43280b6 100644 --- a/apps/web/src/components/AccountDrawer/index.tsx +++ b/apps/web/src/components/AccountDrawer/index.tsx @@ -72,18 +72,16 @@ const AccountDrawerScrollWrapper = styled.div` ${ScrollBarStyles} - scrollbar-gutter: stable; overscroll-behavior: contain; border-radius: 12px; ` -const Container = styled.div<{ isUniExtensionAvailable?: boolean }>` +const Container = styled.div<{ isUniExtensionAvailable?: boolean; $open?: boolean }>` display: flex; flex-direction: row; height: calc(100% - 2 * ${DRAWER_MARGIN}); - overflow: hidden; position: fixed; - right: ${DRAWER_MARGIN}; + right: ${({ $open }) => ($open ? DRAWER_MARGIN : 0)}; top: ${DRAWER_MARGIN}; z-index: ${Z_INDEX.fixed}; @@ -102,7 +100,7 @@ const Container = styled.div<{ isUniExtensionAvailable?: boolean }>` const ExtensionContainerStyles = css` height: auto; max-height: calc(100% - ${({ theme }) => theme.navHeight + 16}px); - right: 24px; + right: 12px; top: ${({ theme }) => theme.navHeight}px; ${ScrollBarStyles} ` @@ -148,16 +146,16 @@ const AccountDrawerWrapper = styled.div<{ open: boolean; isUniExtensionAvailable ` const ExtensionDrawerWrapperStyles = css<{ open: boolean }>` + ${ScrollBarStyles} height: max-content; max-height: 100%; width: ${MODAL_WIDTH}; max-width: ${MODAL_WIDTH}; border-radius: 20px; - box-shadow: ${({ theme }) => theme.deprecated_deepShadow}; transform: scale(${({ open }) => (open ? 1 : 0.96)}); transform-origin: top right; opacity: ${({ open }) => (open ? 1 : 0)}; - overflow-y: scroll; + overflow-y: auto; transition: ${({ theme }) => `transform ${theme.transition.duration.fast} ${theme.transition.timing.inOut}, opacity ${theme.transition.duration.fast} ${theme.transition.timing.inOut}`}; ` @@ -276,7 +274,7 @@ function AccountDrawer() { }) return ( - + {accountDrawer.isOpen && !isUniExtensionAvailable && ( diff --git a/apps/web/src/components/AddressInputPanel/index.tsx b/apps/web/src/components/AddressInputPanel/index.tsx index 948b123442a..52b92062ae2 100644 --- a/apps/web/src/components/AddressInputPanel/index.tsx +++ b/apps/web/src/components/AddressInputPanel/index.tsx @@ -2,11 +2,11 @@ import { AutoColumn } from 'components/Column' import { RowBetween } from 'components/Row' import { useAccount } from 'hooks/useAccount' import useENS from 'hooks/useENS' -import { Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ChangeEvent, ReactNode, useCallback } from 'react' import { ExternalLink, ThemedText } from 'theme/components' import { flexColumnNoWrap } from 'theme/styles' +import { Trans, t } from 'uniswap/src/i18n' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' const InputPanel = styled.div` diff --git a/apps/web/src/components/AddressQRModal/index.tsx b/apps/web/src/components/AddressQRModal/index.tsx index 4c9ba407b31..b759241a33d 100644 --- a/apps/web/src/components/AddressQRModal/index.tsx +++ b/apps/web/src/components/AddressQRModal/index.tsx @@ -5,7 +5,6 @@ import Identicon from 'components/Identicon' import { GetHelpHeader } from 'components/Modal/GetHelpHeader' import { PRODUCTION_CHAIN_IDS } from 'constants/chains' import useENSName from 'hooks/useENSName' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useCallback } from 'react' import { useModalIsOpen, useOpenModal, useToggleModal } from 'state/application/hooks' @@ -14,6 +13,7 @@ import { ExternalLink, ThemedText } from 'theme/components' import { AdaptiveWebModalSheet, Flex, QRCodeDisplay, Text, useSporeColors } from 'ui/src' import { NetworkLogos } from 'uniswap/src/components/network/NetworkLogos' import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' +import { Trans } from 'uniswap/src/i18n' const HelpCenterLink = styled(ExternalLink)` font-size: 14px; @@ -82,7 +82,7 @@ export function AddressQRModal({ accountAddress }: { accountAddress: Address }) - + diff --git a/apps/web/src/components/Badge/RangeBadge.tsx b/apps/web/src/components/Badge/RangeBadge.tsx index 5befc16a237..a032f5facd1 100644 --- a/apps/web/src/components/Badge/RangeBadge.tsx +++ b/apps/web/src/components/Badge/RangeBadge.tsx @@ -1,7 +1,7 @@ import { MouseoverTooltip } from 'components/Tooltip' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { AlertTriangle, Slash } from 'react-feather' +import { Trans } from 'uniswap/src/i18n' const BadgeWrapper = styled.div` font-size: 14px; diff --git a/apps/web/src/components/Banner/Outage/OutageBanner.tsx b/apps/web/src/components/Banner/Outage/OutageBanner.tsx index 652b2f229e4..a25c0a4512d 100644 --- a/apps/web/src/components/Banner/Outage/OutageBanner.tsx +++ b/apps/web/src/components/Banner/Outage/OutageBanner.tsx @@ -1,12 +1,12 @@ import { Container, PopupContainer, StyledXButton, TextContainer } from 'components/Banner/shared/styled' import { chainIdToBackendChain } from 'constants/chains' import { ChainOutageData } from 'featureFlags/flags/outageBanner' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useState } from 'react' import { Globe } from 'react-feather' import { ExternalLink, ThemedText } from 'theme/components' import { capitalize } from 'tsafe' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' const IconContainer = styled.div` @@ -65,7 +65,7 @@ export function OutageBanner({ chainId, version }: ChainOutageData) { - + theme.neutral1}; - font-size: 12px; font-weight: 485; font-size: 14px; line-height: 20px; @@ -38,24 +42,43 @@ const Wrapper = styled.div` background-color: ${({ theme }) => theme.surface1}; border-radius: 12px; border: 1px solid ${({ theme }) => theme.surface3}; - bottom: 60px; z-index: 2; - display: none; + display: block; max-width: 348px; padding: 16px 20px; position: fixed; + bottom: 16px; right: 16px; - @media screen and (min-width: ${MEDIA_WIDTHS.deprecated_upToMedium}px) { - display: block; - } ` export function ChainConnectivityWarning() { const { chainId } = useAccount() - const supportedChain = useSupportedChainId(chainId) - const info = getChain({ chainId: supportedChain, withFallback: true }) + const info = getChain({ chainId, withFallback: true }) const label = info.label + const isNftPage = useIsNftPage() + const isLandingPage = useIsLandingPage() + + const waitMsBeforeWarning = useMemo( + () => (chainId ? UNIVERSE_CHAIN_INFO[chainId]?.blockWaitMsBeforeWarning : undefined) ?? DEFAULT_MS_BEFORE_WARNING, + [chainId], + ) + const machineTime = useMachineTimeMs(AVERAGE_L1_BLOCK_TIME) + const blockTime = useCurrentBlockTimestamp( + useMemo( + () => ({ + blocksPerFetch: + /* 5m / 12s = */ 25 * (chainId ? UNIVERSE_CHAIN_INFO[chainId].blockPerMainnetEpochForChainId : 1), + }), + [chainId], + ), + ) + const warning = Boolean(!!blockTime && machineTime - blockTime.mul(1000).toNumber() > waitMsBeforeWarning) + + if (!warning || isNftPage || isLandingPage) { + return null + } + return ( @@ -68,14 +91,11 @@ export function ChainConnectivityWarning() { {chainId === UniverseChainId.Mainnet ? ( ) : ( - + )}{' '} {info.statusPage !== undefined && ( - {' '} - - here. - + }} /> )} diff --git a/apps/web/src/components/Charts/ChartModel.tsx b/apps/web/src/components/Charts/ChartModel.tsx index 683cbf24c94..accb5407539 100644 --- a/apps/web/src/components/Charts/ChartModel.tsx +++ b/apps/web/src/components/Charts/ChartModel.tsx @@ -6,7 +6,6 @@ import { MissingDataBars } from 'components/Table/icons' import { useScreenSize } from 'hooks/screenSize' import { useActiveLocale } from 'hooks/useActiveLocale' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Trans } from 'i18n' import { useUpdateAtom } from 'jotai/utils' import styled, { DefaultTheme, useTheme } from 'lib/styled-components' import { @@ -24,6 +23,7 @@ import { ReactElement, useEffect, useMemo, useRef, useState } from 'react' import { ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { Z_INDEX } from 'theme/zIndex' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' import { v4 as uuidv4 } from 'uuid' diff --git a/apps/web/src/components/Charts/LoadingState.tsx b/apps/web/src/components/Charts/LoadingState.tsx index 98db0c3e61d..711f9831aca 100644 --- a/apps/web/src/components/Charts/LoadingState.tsx +++ b/apps/web/src/components/Charts/LoadingState.tsx @@ -2,13 +2,13 @@ import { ChartType } from 'components/Charts/utils' import Column from 'components/Column' import Row from 'components/Row' import { MissingDataIcon } from 'components/Table/icons' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { lighten } from 'polished' import { PropsWithChildren, ReactNode } from 'react' import { ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { opacify } from 'theme/utils' +import { Trans } from 'uniswap/src/i18n' const ChartErrorContainer = styled(Row)` position: absolute; diff --git a/apps/web/src/components/Charts/PriceChart/index.tsx b/apps/web/src/components/Charts/PriceChart/index.tsx index 5a4d6e43937..586270f94bf 100644 --- a/apps/web/src/components/Charts/PriceChart/index.tsx +++ b/apps/web/src/components/Charts/PriceChart/index.tsx @@ -8,7 +8,6 @@ import { getCandlestickPriceBounds } from 'components/Charts/PriceChart/utils' import { PriceChartType } from 'components/Charts/utils' import { RowBetween } from 'components/Row' import { DeltaArrow, DeltaText, calculateDelta } from 'components/Tokens/TokenDetails/Delta' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { AreaData, @@ -25,6 +24,7 @@ import { import { useMemo } from 'react' import { ThemedText } from 'theme/components' import { opacify } from 'theme/utils' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' export type PriceChartData = CandlestickData & AreaData @@ -238,19 +238,19 @@ function CandlestickTooltip({ data }: { data: PriceChartData }) { <> - +
{formatFiatPrice({ price: data.open })}
- +
{formatFiatPrice({ price: data.high })}
- +
{formatFiatPrice({ price: data.low })}
- +
{formatFiatPrice({ price: data.close })}
diff --git a/apps/web/src/components/Charts/VolumeChart/index.tsx b/apps/web/src/components/Charts/VolumeChart/index.tsx index 10a8df85615..1450c510fd2 100644 --- a/apps/web/src/components/Charts/VolumeChart/index.tsx +++ b/apps/web/src/components/Charts/VolumeChart/index.tsx @@ -9,11 +9,11 @@ import { getCumulativeVolume } from 'components/Charts/VolumeChart/utils' import { useHeaderDateFormatter } from 'components/Charts/hooks' import { BIPS_BASE } from 'constants/misc' import { TimePeriod, toHistoryDuration } from 'graphql/data/util' -import { t } from 'i18n' import { useTheme } from 'lib/styled-components' import { useMemo } from 'react' import { ThemedText } from 'theme/components' import { HistoryDuration } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' interface VolumeChartModelParams extends ChartModelParams, CustomVolumeChartModelParams { @@ -115,7 +115,7 @@ function FeesTooltipDisplay({ data, feeTier }: { data: SingleHistogramData; feeT return ( <> - {t(`Fees: {{amount}}`, { + {t(`token.chart.tooltip`, { amount: formatFiatPrice({ price: fees, type: NumberType.ChartFiatValue, diff --git a/apps/web/src/components/Charts/utils.tsx b/apps/web/src/components/Charts/utils.tsx index bfc59b970cc..e52e0b7a773 100644 --- a/apps/web/src/components/Charts/utils.tsx +++ b/apps/web/src/components/Charts/utils.tsx @@ -1,5 +1,5 @@ -import { Trans } from 'i18n' import { TickMarkType, UTCTimestamp } from 'lightweight-charts' +import { Trans } from 'uniswap/src/i18n' /** Compatible with ISeriesApi<'Area' | 'Candlestick'> */ export enum PriceChartType { diff --git a/apps/web/src/components/ConfirmSwapModal/Error.tsx b/apps/web/src/components/ConfirmSwapModal/Error.tsx index 5f2aa5d6449..58384ada1db 100644 --- a/apps/web/src/components/ConfirmSwapModal/Error.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Error.tsx @@ -3,11 +3,11 @@ import { TradeSummary } from 'components/ConfirmSwapModal/TradeSummary' import { DialogButtonType, DialogContent } from 'components/Dialog/Dialog' import AlertTriangleFilled from 'components/Icons/AlertTriangleFilled' import { SwapResult } from 'hooks/useSwapCallback' -import { Trans } from 'i18n' import { InterfaceTrade, TradeFillType } from 'state/routing/types' import { isLimitTrade, isUniswapXTrade } from 'state/routing/utils' import { ExternalLink } from 'theme/components' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { Trans } from 'uniswap/src/i18n' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' export enum PendingModalError { @@ -93,7 +93,7 @@ export default function Error({ errorType, trade, showTrade, swapResult, onRetry {showTrade && trade && } {supportArticleURL && ( - + )} {swapResult && swapResult.type === TradeFillType.Classic && ( diff --git a/apps/web/src/components/ConfirmSwapModal/Head.tsx b/apps/web/src/components/ConfirmSwapModal/Head.tsx index b052b9df029..26c75a0a621 100644 --- a/apps/web/src/components/ConfirmSwapModal/Head.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Head.tsx @@ -1,6 +1,6 @@ import { ConfirmModalState } from 'components/ConfirmSwapModal' import { GetHelpHeader } from 'components/Modal/GetHelpHeader' -import { Trans } from 'i18n' +import { Trans } from 'uniswap/src/i18n' export function SwapHead({ onDismiss, diff --git a/apps/web/src/components/ConfirmSwapModal/Pending.tsx b/apps/web/src/components/ConfirmSwapModal/Pending.tsx index 44bc3149000..db28698606a 100644 --- a/apps/web/src/components/ConfirmSwapModal/Pending.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Pending.tsx @@ -12,7 +12,6 @@ import Row from 'components/Row' import { useAccount } from 'hooks/useAccount' import { SwapResult } from 'hooks/useSwapCallback' import { useUnmountingAnimation } from 'hooks/useUnmountingAnimation' -import { Trans, t } from 'i18n' import styled, { css } from 'lib/styled-components' import { ReactNode, useMemo, useRef } from 'react' import { InterfaceTrade, TradeFillType } from 'state/routing/types' @@ -25,6 +24,7 @@ import { ThemedText } from 'theme/components/text' import { UniswapXOrderStatus } from 'types/uniswapx' import { uniswapUrls } from 'uniswap/src/constants/urls' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -135,7 +135,7 @@ export function Pending({ if (swapResult && swapResult.type === TradeFillType.Classic) { txHash = swapResult.response.hash } else if (uniswapXOrder && uniswapXOrder.status === UniswapXOrderStatus.FILLED) { - txHash = uniswapXOrder.orderHash + txHash = uniswapXOrder.txHash } else { return } diff --git a/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx b/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx index fc1c1c2f270..f9e026dc97b 100644 --- a/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx +++ b/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx @@ -8,7 +8,6 @@ import { useAccount } from 'hooks/useAccount' import { useBlockConfirmationTime } from 'hooks/useBlockConfirmationTime' import { useColor } from 'hooks/useColor' import { SwapResult } from 'hooks/useSwapCallback' -import { t } from 'i18n' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import styled, { useTheme } from 'lib/styled-components' import { useEffect, useMemo, useState } from 'react' @@ -21,6 +20,7 @@ import { Divider } from 'theme/components' import { UniswapXOrderStatus } from 'types/uniswapx' import { uniswapUrls } from 'uniswap/src/constants/urls' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' import { SignatureExpiredError } from 'utils/errors' const DividerContainer = styled(Column)` diff --git a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Error.test.tsx.snap b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Error.test.tsx.snap index 55f33e37942..0cfc3185d41 100644 --- a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Error.test.tsx.snap +++ b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Error.test.tsx.snap @@ -364,15 +364,19 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 0 class="c13" >
- - + > + + +
@@ -410,15 +414,19 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 0 class="c13" >
- - + > + + +
@@ -805,7 +813,7 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 1
- permit.approval.fail.message + Permit2 allows token approvals to be shared and managed across different applications.
- - + > + + +
@@ -867,15 +879,19 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 1 class="c13" >
- - + > + + +
@@ -1278,15 +1294,19 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 3 class="c13" >
- - + > + + +
@@ -1324,15 +1344,19 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 3 class="c13" >
- - + > + + +
@@ -1719,7 +1743,7 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 4
- Swaps on the Uniswap Protocol can start and end with ETH. However, during the swap ETH is wrapped into WETH. + Swaps on the Uniswap Protocol can start and end with ETH. However, during the swap, ETH is wrapped into WETH.
- - + > + + +
@@ -1781,15 +1809,19 @@ exports[`ConfirmSwapModal/Error renders "classic trade" correctly, with error= 4 class="c13" >
- - + > + + +
@@ -2190,15 +2222,19 @@ exports[`ConfirmSwapModal/Error renders "limit order" correctly, with error= 3 1 class="c13" >
- - + > + + +
@@ -2236,15 +2272,19 @@ exports[`ConfirmSwapModal/Error renders "limit order" correctly, with error= 3 1 class="c13" >
- - + > + + +
@@ -2631,7 +2671,7 @@ exports[`ConfirmSwapModal/Error renders "limit order" correctly, with error= 4 1
- Swaps on the Uniswap Protocol can start and end with ETH. However, during the swap ETH is wrapped into WETH. + Swaps on the Uniswap Protocol can start and end with ETH. However, during the swap, ETH is wrapped into WETH.
- - + > + + +
@@ -2693,15 +2737,19 @@ exports[`ConfirmSwapModal/Error renders "limit order" correctly, with error= 4 1 class="c13" >
- - + > + + +
diff --git a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap index 86623136beb..0e0ac4b1290 100644 --- a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap +++ b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap @@ -100,7 +100,7 @@ exports[`ConfirmSwapModal/Head should render correctly for a Limit order 1`] = ` class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0" > Review limit @@ -266,7 +266,7 @@ exports[`ConfirmSwapModal/Head should render correctly for a classic swap 1`] = class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0" > Review swap diff --git a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Pending.test.tsx.snap b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Pending.test.tsx.snap index 2882a1c2616..df9a8b5a22a 100644 --- a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Pending.test.tsx.snap +++ b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Pending.test.tsx.snap @@ -311,15 +311,19 @@ exports[`Pending - classic trade titles renders classic trade correctly, with ap class="c15" >
- - + > + + +
@@ -357,15 +361,19 @@ exports[`Pending - classic trade titles renders classic trade correctly, with ap class="c15" >
- - + > + + +
@@ -712,15 +720,19 @@ exports[`Pending - classic trade titles renders classic trade correctly, with ap class="c15" >
- - + > + + +
@@ -758,15 +770,19 @@ exports[`Pending - classic trade titles renders classic trade correctly, with ap class="c15" >
- - + > + + +
@@ -1118,15 +1134,19 @@ exports[`Pending - classic trade titles renders classic trade correctly, with ap class="c17" >
- - + > + + +
@@ -1164,15 +1184,19 @@ exports[`Pending - classic trade titles renders classic trade correctly, with ap class="c17" >
- - + > + + +
@@ -1468,15 +1492,19 @@ exports[`Pending - uniswapX trade titles renders limit order correctly, with app class="c15" >
- - + > + + +
@@ -1514,15 +1542,19 @@ exports[`Pending - uniswapX trade titles renders limit order correctly, with app class="c15" >
- - + > + + +
@@ -1852,15 +1884,19 @@ exports[`Pending - uniswapX trade titles renders limit order correctly, with app class="c15" >
- - + > + + +
@@ -1898,15 +1934,19 @@ exports[`Pending - uniswapX trade titles renders limit order correctly, with app class="c15" >
- - + > + + +
@@ -2258,15 +2298,19 @@ exports[`Pending - uniswapX trade titles renders limit order correctly, with app class="c17" >
- - + > + + +
@@ -2304,15 +2348,19 @@ exports[`Pending - uniswapX trade titles renders limit order correctly, with app class="c17" >
- - + > + + +
diff --git a/apps/web/src/components/ConnectedAccountBlocked/index.tsx b/apps/web/src/components/ConnectedAccountBlocked/index.tsx index 643190aeb48..2486250850e 100644 --- a/apps/web/src/components/ConnectedAccountBlocked/index.tsx +++ b/apps/web/src/components/ConnectedAccountBlocked/index.tsx @@ -1,10 +1,10 @@ import Column from 'components/Column' import Modal from 'components/Modal' import { BlockedIcon } from 'components/TokenSafety/TokenSafetyIcon' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { CopyHelper, ExternalLink, ThemedText } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' const ContentWrapper = styled(Column)` align-items: center; @@ -30,25 +30,29 @@ export default function ConnectedAccountBlocked(props: ConnectedAccountBlockedPr {props.account} - {' '} - - - - . + }} + /> - {' '} + + compliance@uniswap.org + + ), + }} + /> - - - compliance@uniswap.org - ) diff --git a/apps/web/src/components/CurrencyInputPanel/FiatValue.tsx b/apps/web/src/components/CurrencyInputPanel/FiatValue.tsx index 9ac5d193e1c..5585c148f8f 100644 --- a/apps/web/src/components/CurrencyInputPanel/FiatValue.tsx +++ b/apps/web/src/components/CurrencyInputPanel/FiatValue.tsx @@ -2,10 +2,10 @@ import { Percent } from '@uniswap/sdk-core' import Row from 'components/Row' import { LoadingBubble } from 'components/Tokens/loading' import { MouseoverTooltip } from 'components/Tooltip' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useMemo } from 'react' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' import { warningSeverity } from 'utils/prices' diff --git a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceButton.tsx b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceButton.tsx index 3553728d157..a136c3b57d4 100644 --- a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceButton.tsx +++ b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceButton.tsx @@ -1,9 +1,9 @@ import { Percent } from '@uniswap/sdk-core' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { X } from 'react-feather' import { ClickableStyle, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' interface LimitPriceButtonProps { diff --git a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputLabel.tsx b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputLabel.tsx index d4ed05cd83e..7ec7f2bb867 100644 --- a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputLabel.tsx +++ b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputLabel.tsx @@ -2,10 +2,10 @@ import { Currency } from '@uniswap/sdk-core' import CurrencyLogo from 'components/Logo/CurrencyLogo' import Row from 'components/Row' import { PrefetchBalancesWrapper } from 'graphql/data/apollo/TokenBalancesProvider' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ClickableStyle, ThemedText } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' const CurrencySymbolContainer = styled.span` display: inline-block; @@ -35,16 +35,21 @@ export function LimitPriceInputLabel({ return ( - {' '} - - - - - {currency.symbol} - - - {' '} - + + + + + {currency.symbol} + + + + ), + }} + /> ) diff --git a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.test.tsx b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.test.tsx index 2354915fd01..c33e4eaddee 100644 --- a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.test.tsx +++ b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.test.tsx @@ -14,9 +14,11 @@ const mockSwapAndLimitContextValue = { prefilledState: {}, setCurrencyState: jest.fn(), setSelectedChainId: jest.fn(), + setIsUserSelectedChainId: jest.fn(), currentTab: SwapTab.Limit, setCurrentTab: jest.fn(), isSwapAndLimitContext: true, + isUserSelectedChainId: false, } const mockLimitContextValue = { diff --git a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.tsx b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.tsx index 0e6f8c8d9ed..908859495b0 100644 --- a/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.tsx +++ b/apps/web/src/components/CurrencyInputPanel/LimitPriceInputPanel/LimitPriceInputPanel.tsx @@ -19,8 +19,8 @@ import { ReversedArrowsIcon } from 'nft/components/icons' import { LIMIT_FORM_CURRENCY_SEARCH_FILTERS } from 'pages/Swap/Limit/LimitForm' import { useCallback, useMemo, useState } from 'react' import { useLimitContext, useLimitPrice } from 'state/limit/LimitContext' -import { useSwapAndLimitContext } from 'state/swap/hooks' import { CurrencyState } from 'state/swap/types' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { ClickableStyle, ThemedText } from 'theme/components' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' diff --git a/apps/web/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx b/apps/web/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx index ae3c666b5c6..b24e63037b2 100644 --- a/apps/web/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx +++ b/apps/web/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx @@ -11,24 +11,25 @@ import { LoadingOpacityContainer } from 'components/Loader/styled' import CurrencyLogo from 'components/Logo/CurrencyLogo' import { StyledNumericalInput } from 'components/NumericalInput' import { RowBetween, RowFixed } from 'components/Row' -import { CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' import CurrencySearchModal from 'components/SearchModal/CurrencySearchModal' +import { CurrencySearchFilters } from 'components/SearchModal/DeprecatedCurrencySearch' import Tooltip from 'components/Tooltip' import { useIsSupportedChainId } from 'constants/chains' import { PrefetchBalancesWrapper } from 'graphql/data/apollo/TokenBalancesProvider' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import ms from 'ms' import { darken } from 'polished' import { ReactNode, forwardRef, useCallback, useEffect, useState } from 'react' import { Lock } from 'react-feather' import { useCurrencyBalance } from 'state/connection/hooks' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { ThemedText } from 'theme/components' import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles' -import { Text } from 'ui/src' +import { AnimatePresence, Flex, Text } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' +import { CurrencyField } from 'uniswap/src/types/currency' import { NumberType, useFormatter } from 'utils/formatNumbers' export const InputPanel = styled.div<{ hideInput?: boolean }>` @@ -221,6 +222,7 @@ interface SwapCurrencyInputPanelProps { label: ReactNode onCurrencySelect?: (currency: Currency) => void currency?: Currency | null + currencyField: CurrencyField hideBalance?: boolean pair?: Pair | null hideInput?: boolean @@ -238,6 +240,7 @@ interface SwapCurrencyInputPanelProps { onDisabledClick?: () => void disabledTooltipBody?: ReactNode } + initialCurrencyLoading?: boolean } const SwapCurrencyInputPanel = forwardRef( @@ -260,7 +263,9 @@ const SwapCurrencyInputPanel = forwardRef { const [modalOpen, setModalOpen] = useState(false) const account = useAccount() - const { chainId } = useSwapAndLimitContext() + const { chainId, isUserSelectedChainId } = useSwapAndLimitContext() const chainAllowed = useIsSupportedChainId(chainId) const selectedCurrencyBalance = useCurrencyBalance(account.address, currency ?? undefined) const theme = useTheme() @@ -291,6 +296,9 @@ const SwapCurrencyInputPanel = forwardRef setTooltipVisible(false), [currency]) + const showCurrencyLoadingSpinner = + initialCurrencyLoading && !otherCurrency && !isUserSelectedChainId && currencyField === CurrencyField.INPUT + return ( {locked && ( @@ -331,7 +339,7 @@ const SwapCurrencyInputPanel = forwardRef - {pair ? ( - - - - ) : currency ? ( - - ) : null} - {pair ? ( - - {pair?.token0.symbol}:{pair?.token1.symbol} - - ) : ( - - {currency ? formatCurrencySymbol(currency) : } - - )} + + + {pair ? ( + + + + ) : currency ? ( + + ) : null} + {pair ? ( + + {pair?.token0.symbol}:{pair?.token1.symbol} + + ) : ( + + {currency ? ( + formatCurrencySymbol(currency) + ) : ( + + )} + + )} + + {onCurrencySelect && } @@ -379,7 +400,7 @@ const SwapCurrencyInputPanel = forwardRef )} - {account ? ( + {account && !initialCurrencyLoading ? ( {onCurrencySelect && ( ` @@ -190,6 +191,7 @@ interface CurrencyInputPanelProps { locked?: boolean loading?: boolean currencySearchFilters?: CurrencySearchFilters + currencyField?: CurrencyField } export default function CurrencyInputPanel({ @@ -203,6 +205,7 @@ export default function CurrencyInputPanel({ id, currencySearchFilters, showCurrencyAmount, + currencyField, renderBalance, fiatValue, hideBalance = false, @@ -212,6 +215,7 @@ export default function CurrencyInputPanel({ loading = false, ...rest }: CurrencyInputPanelProps) { + const { t } = useTranslation() const [modalOpen, setModalOpen] = useState(false) const account = useAccount() const chainAllowed = useIsSupportedChainId(account.chainId) @@ -276,7 +280,7 @@ export default function CurrencyInputPanel({ ? currency.symbol.slice(0, 4) + '...' + currency.symbol.slice(currency.symbol.length - 5, currency.symbol.length) - : currency?.symbol) || } + : currency?.symbol) || } )} @@ -319,9 +323,7 @@ export default function CurrencyInputPanel({ eventOnTrigger={SwapEventName.SWAP_MAX_TOKEN_AMOUNT_SELECTED} element={InterfaceElementName.MAX_TOKEN_AMOUNT_BUTTON} > - - - + {t('common.max').toUpperCase()} )} @@ -341,6 +343,7 @@ export default function CurrencyInputPanel({ otherSelectedCurrency={otherCurrency} showCurrencyAmount={showCurrencyAmount} currencySearchFilters={currencySearchFilters} + currencyField={currencyField} /> )} diff --git a/apps/web/src/components/DropdownSelector/index.tsx b/apps/web/src/components/DropdownSelector/index.tsx index 4409d1dd543..15cb805a870 100644 --- a/apps/web/src/components/DropdownSelector/index.tsx +++ b/apps/web/src/components/DropdownSelector/index.tsx @@ -4,9 +4,10 @@ import { MouseoverTooltip, TooltipSize } from 'components/Tooltip' import { useOnClickOutside } from 'hooks/useOnClickOutside' import styled, { css } from 'lib/styled-components' import React, { useRef } from 'react' -import { ChevronDown } from 'react-feather' import { dropdownSlideDown } from 'theme/styles' import { Z_INDEX } from 'theme/zIndex' +import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' +import { iconSizes } from 'ui/src/theme/iconSizes' export const InternalMenuItem = styled.div<{ disabled?: boolean }>` display: flex; @@ -84,7 +85,7 @@ const StyledMenu = styled.div` text-align: left; width: 100%; ` -export const StyledMenuContent = styled.div` +const StyledMenuContent = styled.div` display: flex; justify-content: space-between; gap: 8px; @@ -95,12 +96,6 @@ export const StyledMenuContent = styled.div` vertical-align: middle; white-space: nowrap; ` -const Chevron = styled.span<{ open: boolean }>` - display: flex; - color: ${({ open, theme }) => (open ? theme.neutral1 : theme.neutral2)}; - rotate: ${({ open }) => (open ? '180deg' : '0deg')}; - transition: rotate ${({ theme }) => `${theme.transition.duration.fast} ${theme.transition.timing.inOut}`}; -` const StyledFilterButton = styled(FilterButton)<{ buttonCss?: string }>` ${({ buttonCss }) => buttonCss} ` @@ -152,9 +147,7 @@ export function DropdownSelector({ {menuLabel} {!hideChevron && ( - - - + )} diff --git a/apps/web/src/components/ErrorBoundary/index.tsx b/apps/web/src/components/ErrorBoundary/index.tsx index 3c05ec11515..b11e6b6114c 100644 --- a/apps/web/src/components/ErrorBoundary/index.tsx +++ b/apps/web/src/components/ErrorBoundary/index.tsx @@ -3,12 +3,12 @@ import { ButtonLight, SmallButtonPrimary } from 'components/Button' import { Column } from 'components/Column' import { useIsMobile } from 'hooks/screenSize' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ChevronUpIcon } from 'nft/components/icons' import { PropsWithChildren, useState } from 'react' import { Copy } from 'react-feather' import { CopyToClipboard, ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { isRemoteReportingEnabled } from 'utils/env' const FallbackWrapper = styled.div` diff --git a/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx b/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx index 50d1aaadfc5..b492b280713 100644 --- a/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx +++ b/apps/web/src/components/FeatureFlagModal/FeatureFlagModal.tsx @@ -219,14 +219,12 @@ export default function FeatureFlagModal() { label="Enable EIP-6963: Multi Injected Provider Discovery" /> - - - + diff --git a/apps/web/src/components/FeeSelector/FeeTierPercentageBadge.tsx b/apps/web/src/components/FeeSelector/FeeTierPercentageBadge.tsx index c738f660d1d..eeb0bdd281a 100644 --- a/apps/web/src/components/FeeSelector/FeeTierPercentageBadge.tsx +++ b/apps/web/src/components/FeeSelector/FeeTierPercentageBadge.tsx @@ -2,8 +2,8 @@ import { FeeAmount } from '@uniswap/v3-sdk' import Badge from 'components/Badge' import { useFeeTierDistribution } from 'hooks/useFeeTierDistribution' import { PoolState } from 'hooks/usePools' -import { Trans } from 'i18n' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' export function FeeTierPercentageBadge({ feeAmount, diff --git a/apps/web/src/components/FeeSelector/index.tsx b/apps/web/src/components/FeeSelector/index.tsx index 5cd69f5319b..1036236a2a1 100644 --- a/apps/web/src/components/FeeSelector/index.tsx +++ b/apps/web/src/components/FeeSelector/index.tsx @@ -12,13 +12,13 @@ import { useAccount } from 'hooks/useAccount' import { useFeeTierDistribution } from 'hooks/useFeeTierDistribution' import { PoolState, usePools } from 'hooks/usePools' import usePrevious from 'hooks/usePrevious' -import { Trans } from 'i18n' import styled, { keyframes } from 'lib/styled-components' import { DynamicSection } from 'pages/AddLiquidity/styled' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box } from 'rebass' import { ThemedText } from 'theme/components' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/FeeSelector/shared.tsx b/apps/web/src/components/FeeSelector/shared.tsx index 56060260953..25cdea3f752 100644 --- a/apps/web/src/components/FeeSelector/shared.tsx +++ b/apps/web/src/components/FeeSelector/shared.tsx @@ -1,6 +1,6 @@ import { FeeAmount } from '@uniswap/v3-sdk' -import { Trans } from 'i18n' import type { ReactNode } from 'react' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId, WEB_SUPPORTED_CHAIN_IDS } from 'uniswap/src/types/chains' export const FEE_AMOUNT_DETAIL: Record< diff --git a/apps/web/src/components/FiatOnrampModal/index.tsx b/apps/web/src/components/FiatOnrampModal/index.tsx index 69502f2dd1e..cecb8829ae2 100644 --- a/apps/web/src/components/FiatOnrampModal/index.tsx +++ b/apps/web/src/components/FiatOnrampModal/index.tsx @@ -4,7 +4,6 @@ import { getDefaultCurrencyCode, parsePathParts } from 'components/FiatOnrampMod import { getChain, getChainFromChainUrlParam, getChainUrlParam } from 'constants/chains' import { useAccount } from 'hooks/useAccount' import useParsedQueryString from 'hooks/useParsedQueryString' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useCallback, useEffect, useState } from 'react' import { useHref } from 'react-router-dom' @@ -13,6 +12,8 @@ import { ApplicationModal } from 'state/application/reducer' import { CustomLightSpinner, ThemedText } from 'theme/components' import { useIsDarkMode } from 'theme/components/ThemeToggle' import { AdaptiveWebModalSheet } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' + import { logger } from 'utilities/src/logger/logger' const MOONPAY_DARK_BACKGROUND = '#1c1c1e' diff --git a/apps/web/src/components/Icons/BraveBrowserLogo.tsx b/apps/web/src/components/Icons/BraveBrowserLogo.tsx deleted file mode 100644 index 1ef92dadfbf..00000000000 --- a/apps/web/src/components/Icons/BraveBrowserLogo.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ComponentProps } from 'react' - -export function BraveBrowserLogo(props: ComponentProps<'svg'>) { - return ( - - - - - - - - - - - - - - - - - - ) -} diff --git a/apps/web/src/components/Icons/Search.tsx b/apps/web/src/components/Icons/Search.tsx deleted file mode 100644 index 4f539228bc9..00000000000 --- a/apps/web/src/components/Icons/Search.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { ComponentProps } from 'react' - -export const Search = (props: ComponentProps<'svg'>) => ( - - - -) diff --git a/apps/web/src/components/Identicon/index.tsx b/apps/web/src/components/Identicon/index.tsx index d92d22dc3d8..c0f26818e83 100644 --- a/apps/web/src/components/Identicon/index.tsx +++ b/apps/web/src/components/Identicon/index.tsx @@ -21,12 +21,15 @@ export function useIdenticonType(account?: string) { if (!account) { return undefined } - if (unitagLoading || ensAvatarLoading) { + + if (unitagLoading) { return IdenticonType.LOADING } else if (unitag?.metadata?.avatar) { return IdenticonType.UNITAG_PROFILE_PICTURE } else if (avatar) { return IdenticonType.ENS_AVATAR + } else if (ensAvatarLoading) { + return IdenticonType.LOADING } else { return IdenticonType.UNICON } diff --git a/apps/web/src/components/InputStepCounter/InputStepCounter.tsx b/apps/web/src/components/InputStepCounter/InputStepCounter.tsx index 8ab6b7d7253..18ba783edc3 100644 --- a/apps/web/src/components/InputStepCounter/InputStepCounter.tsx +++ b/apps/web/src/components/InputStepCounter/InputStepCounter.tsx @@ -4,12 +4,12 @@ import { ButtonGray } from 'components/Button' import { OutlineCard } from 'components/Card' import { AutoColumn } from 'components/Column' import { Input as NumericalInput } from 'components/NumericalInput' -import { Trans } from 'i18n' import styled, { keyframes } from 'lib/styled-components' import { ReactNode, useCallback, useEffect, useState } from 'react' import { Minus, Plus } from 'react-feather' import { ThemedText } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' const pulse = (color: string) => keyframes` 0% { diff --git a/apps/web/src/components/LiquidityChartRangeInput/index.tsx b/apps/web/src/components/LiquidityChartRangeInput/index.tsx index 035fbed8570..77bcc2b40a0 100644 --- a/apps/web/src/components/LiquidityChartRangeInput/index.tsx +++ b/apps/web/src/components/LiquidityChartRangeInput/index.tsx @@ -6,7 +6,6 @@ import { Chart } from 'components/LiquidityChartRangeInput/Chart' import { useDensityChartData } from 'components/LiquidityChartRangeInput/hooks' import { ZoomLevels } from 'components/LiquidityChartRangeInput/types' import { useColor } from 'hooks/useColor' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { saturate } from 'polished' import { ReactNode, useCallback, useMemo } from 'react' @@ -14,6 +13,7 @@ import { BarChart2, CloudOff, Inbox } from 'react-feather' import { batch } from 'react-redux' import { Bound } from 'state/mint/v3/actions' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' const ZOOM_LEVELS: Record = { diff --git a/apps/web/src/components/Logo/AssetLogo.tsx b/apps/web/src/components/Logo/AssetLogo.tsx index a1eb5c8a415..d606463ea9a 100644 --- a/apps/web/src/components/Logo/AssetLogo.tsx +++ b/apps/web/src/components/Logo/AssetLogo.tsx @@ -26,6 +26,7 @@ export type AssetLogoBaseProps = { size?: number style?: React.CSSProperties currency?: Currency | null + loading?: boolean } type AssetLogoProps = AssetLogoBaseProps & { isNative?: boolean; address?: string | null; chainId?: number } @@ -37,10 +38,16 @@ const LogoContainer = styled.div` /** * Renders an image by prioritizing a list of sources, and then eventually a fallback triangle alert */ -export default function AssetLogo({ currency, chainId = UniverseChainId.Mainnet, size = 24, style }: AssetLogoProps) { +export default function AssetLogo({ + currency, + chainId = UniverseChainId.Mainnet, + size = 24, + style, + loading, +}: AssetLogoProps) { return ( - + ) } diff --git a/apps/web/src/components/Logo/HolidayUniIcon.tsx b/apps/web/src/components/Logo/HolidayUniIcon.tsx index eda98b02be5..5ea4bb8f7c2 100644 --- a/apps/web/src/components/Logo/HolidayUniIcon.tsx +++ b/apps/web/src/components/Logo/HolidayUniIcon.tsx @@ -1,7 +1,7 @@ import { ReactComponent as WinterUni } from 'assets/svg/winter-uni.svg' import { SVGProps } from 'components/Logo/UniIcon' -import { t } from 'i18n' import { ReactElement } from 'react' +import { t } from 'uniswap/src/i18n' const MONTH_TO_HOLIDAY_UNI: { [date: string]: (props: SVGProps) => ReactElement } = { '12': (props) => , diff --git a/apps/web/src/components/Logo/UniIcon.tsx b/apps/web/src/components/Logo/UniIcon.tsx index f820447f837..912dbc9c042 100644 --- a/apps/web/src/components/Logo/UniIcon.tsx +++ b/apps/web/src/components/Logo/UniIcon.tsx @@ -1,7 +1,6 @@ // ESLint reports `fill` is missing, whereas it exists on an SVGProps type export type SVGProps = React.SVGProps & { fill?: string - clickable?: boolean } export const UniIcon = (props: SVGProps) => ( diff --git a/apps/web/src/components/Logo/UniswapXBrandMark.tsx b/apps/web/src/components/Logo/UniswapXBrandMark.tsx index 29222147d5a..47b32537075 100644 --- a/apps/web/src/components/Logo/UniswapXBrandMark.tsx +++ b/apps/web/src/components/Logo/UniswapXBrandMark.tsx @@ -1,6 +1,6 @@ import UniswapXRouterLabel, { UnswapXRouterLabelProps } from 'components/RouterLabel/UniswapXRouterLabel' -import { Trans } from 'i18n' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' type UniswapXBrandMarkProps = Omit & { fontWeight?: 'bold' diff --git a/apps/web/src/components/ModalViews/index.tsx b/apps/web/src/components/ModalViews/index.tsx index 1778a07ceb7..9c2bd0cda57 100644 --- a/apps/web/src/components/ModalViews/index.tsx +++ b/apps/web/src/components/ModalViews/index.tsx @@ -2,10 +2,10 @@ import Circle from 'assets/images/blue-loader.svg' import { AutoColumn, ColumnCenter } from 'components/Column' import { RowBetween } from 'components/Row' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ArrowUpCircle } from 'react-feather' import { CloseIcon, CustomLightSpinner, ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' const ConfirmOrLoadingWrapper = styled.div` diff --git a/apps/web/src/components/NavBar/ChainSelector/ChainSelectorRow.tsx b/apps/web/src/components/NavBar/ChainSelector/ChainSelectorRow.tsx index cc54add5f0b..ab62bf7f57a 100644 --- a/apps/web/src/components/NavBar/ChainSelector/ChainSelectorRow.tsx +++ b/apps/web/src/components/NavBar/ChainSelector/ChainSelectorRow.tsx @@ -1,12 +1,12 @@ import Loader from 'components/Icons/LoadingSpinner' import { ChainLogo } from 'components/Logo/ChainLogo' import { getChain, useSupportedChainId } from 'constants/chains' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { Check } from 'react-feather' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import Trace from 'uniswap/src/features/telemetry/Trace' import { SectionName } from 'uniswap/src/features/telemetry/constants' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' const LOGO_SIZE = 20 diff --git a/apps/web/src/components/NavBar/ChainSelector/index.tsx b/apps/web/src/components/NavBar/ChainSelector/index.tsx index c86376516b9..b208af98837 100644 --- a/apps/web/src/components/NavBar/ChainSelector/index.tsx +++ b/apps/web/src/components/NavBar/ChainSelector/index.tsx @@ -1,9 +1,8 @@ import { showTestnetsAtom } from 'components/AccountDrawer/TestnetsToggle' -import Column from 'components/Column' -import { DropdownSelector, StyledMenuContent } from 'components/DropdownSelector' import { ChainLogo } from 'components/Logo/ChainLogo' import ChainSelectorRow from 'components/NavBar/ChainSelector/ChainSelectorRow' import { NavDropdown } from 'components/NavBar/NavDropdown/NavDropdown' +import { NavIcon } from 'components/NavBar/NavIcon' import { CONNECTION } from 'components/Web3Provider/constants' import { ALL_CHAIN_IDS, @@ -14,13 +13,12 @@ import { } from 'constants/chains' import { useAccount } from 'hooks/useAccount' import useSelectChain from 'hooks/useSelectChain' -import { t } from 'i18n' import { useAtomValue } from 'jotai/utils' -import styled, { css, useTheme } from 'lib/styled-components' +import { useTheme } from 'lib/styled-components' import { useCallback, useMemo, useRef, useState } from 'react' import { AlertTriangle } from 'react-feather' import { useSearchParams } from 'react-router-dom' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { Flex, Popover } from 'ui/src' import { NetworkFilter } from 'uniswap/src/components/network/NetworkFilter' import { FeatureFlags } from 'uniswap/src/features/gating/flags' @@ -28,32 +26,6 @@ import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' import { Connector } from 'wagmi' -const StyledDropdownButton = css` - display: flex; - flex-direction: row; - padding: 10px 8px; - background: none; - gap: 4px; - border: none; - & ${StyledMenuContent} { - gap: 4px; - } -` -const ChainsList = styled(Column)` - width: 240px; - @media screen and (max-width: ${({ theme }) => theme.breakpoint.xs}px) { - width: 100%; - } -` -const styledMobileMenuCss = css` - @media screen and (max-width: ${({ theme }) => theme.breakpoint.xs}px) { - bottom: 50px; - } -` -const ChainsDropdownWrapper = styled(Column)` - padding: 8px; -` - type WalletConnectConnector = Connector & { type: typeof CONNECTION.UNISWAP_WALLET_CONNECT_CONNECTOR_ID getNamespaceChainsIds: () => InterfaceChainId[] @@ -74,7 +46,7 @@ function useWalletSupportedChains(): InterfaceChainId[] { } } -export const ChainSelector = ({ leftAlign }: { leftAlign?: boolean }) => { +export const ChainSelector = ({ hideArrow }: { hideArrow?: boolean }) => { const { chainId, setSelectedChainId, multichainUXEnabled } = useSwapAndLimitContext() // multichainFlagEnabled is different from multichainUXEnabled, multichainUXEnabled applies to swap // flag can be true but multichainUXEnabled can be false (TDP page) @@ -85,7 +57,6 @@ export const ChainSelector = ({ leftAlign }: { leftAlign?: boolean }) => { const walletSupportsChain = useWalletSupportedChains() const isSupportedChain = useIsSupportedChainIdCallback() const showTestnets = useAtomValue(showTestnetsAtom) - const navRefreshEnabled = useFeatureFlag(FeatureFlags.NavRefresh) const [isOpen, setIsOpen] = useState(false) const selectChain = useSelectChain() const [searchParams, setSearchParams] = useSearchParams() @@ -131,11 +102,6 @@ export const ChainSelector = ({ leftAlign }: { leftAlign?: boolean }) => { [multichainUXEnabled, setSelectedChainId, selectChain, searchParams, setSearchParams], ) - const styledMenuCss = css` - ${leftAlign ? 'left: 0;' : 'right: 0;'} - ${styledMobileMenuCss}; - ` - const menuLabel = !chainId ? ( ) : ( @@ -144,54 +110,27 @@ export const ChainSelector = ({ leftAlign }: { leftAlign?: boolean }) => { if (multichainFlagEnabled) { return ( - - + + ) } - if (navRefreshEnabled) { - return ( - - - {menuLabel} - - - - {supportedChains.map((selectorChain) => ( - - ))} - {unsupportedChains.map((selectorChain) => ( - undefined} - targetChain={selectorChain} - key={selectorChain} - isPending={false} - /> - ))} - - - - ) - } - return ( - setIsOpen(!isOpen)} - menuLabel={menuLabel} - tooltipText={chainId ? undefined : t`wallet.networkUnsupported`} - dataTestId="chain-selector" - optionsContainerTestId="chain-selector-options" - internalMenuItems={ - + + + {menuLabel} + + + {supportedChains.map((selectorChain) => ( { isPending={false} /> ))} - - } - buttonCss={StyledDropdownButton} - menuFlyoutCss={styledMenuCss} - /> + + + ) } diff --git a/apps/web/src/components/NavBar/CompanyMenu/Content.tsx b/apps/web/src/components/NavBar/CompanyMenu/Content.tsx index 73dc9b8c91c..081416f0c62 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/Content.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/Content.tsx @@ -1,6 +1,4 @@ -import { useTranslation } from 'react-i18next' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useTranslation } from 'uniswap/src/i18n' export interface MenuItem { label: string @@ -18,42 +16,28 @@ export interface MenuSection { export const useMenuContent = (): MenuSection[] => { const { t } = useTranslation() - const isLegacyNav = !useFeatureFlag(FeatureFlags.NavRefresh) - const legacyAppLinks = { - title: t('common.app'), - key: 'App', - items: [ - { label: t('common.pool'), href: '/pool', internal: true, overflow: true }, - { label: t('common.vote'), href: 'https://vote.uniswapfoundation.org/' }, - { label: t('common.analytics'), href: '/explore', internal: true }, - ], - } - const companyLinks = { - title: t('common.company'), - key: 'Company', - items: [ - { label: t('common.careers'), href: 'https://boards.greenhouse.io/uniswaplabs' }, - { label: t('common.blog'), href: 'https://blog.uniswap.org/' }, - ], - } - const protocolLinks = { - title: t('common.protocol'), - key: 'Protocol', - items: [ - ...(!isLegacyNav ? [{ label: t('common.vote'), href: 'https://vote.uniswapfoundation.org/' }] : []), - { label: t('common.governance'), href: 'https://uniswap.org/governance' }, - { label: t('common.developers'), href: 'https://uniswap.org/developers' }, - ], - } - const helpLinks = { - title: t('common.needHelp'), - key: 'Help', - items: [ - { label: t('common.helpCenter'), href: 'https://support.uniswap.org/hc/en-us' }, - { label: t('common.contactUs.button'), href: 'https://support.uniswap.org/hc/en-us/requests/new' }, - ], - } - - return [...(isLegacyNav ? [legacyAppLinks] : []), companyLinks, protocolLinks, helpLinks] + return [ + { + title: t('common.company'), + items: [ + { label: t('common.careers'), href: 'https://boards.greenhouse.io/uniswaplabs' }, + { label: t('common.blog'), href: 'https://blog.uniswap.org/' }, + ], + }, + { + title: t('common.protocol'), + items: [ + { label: t('common.governance'), href: 'https://uniswap.org/governance' }, + { label: t('common.developers'), href: 'https://uniswap.org/developers' }, + ], + }, + { + title: t('common.needHelp'), + items: [ + { label: t('common.helpCenter'), href: 'https://support.uniswap.org/hc/en-us' }, + { label: t('common.contactUs.button'), href: 'https://support.uniswap.org/hc/en-us/requests/new' }, + ], + }, + ] } diff --git a/apps/web/src/components/NavBar/CompanyMenu/DownloadAppCTA.tsx b/apps/web/src/components/NavBar/CompanyMenu/DownloadAppCTA.tsx index 3030475bc03..ac46b5f56c9 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/DownloadAppCTA.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/DownloadAppCTA.tsx @@ -1,15 +1,15 @@ import { useIsTouchDevice } from '@tamagui/core' import { InterfaceElementName } from '@uniswap/analytics-events' -import Column from 'components/Column' import { MobileAppLogo } from 'components/Icons/MobileAppLogo' import { NAV_BREAKPOINT, useIsMobileDrawer } from 'components/NavBar/ScreenSizes' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { Text } from 'rebass' import { useOpenModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ThemedText } from 'theme/components' +import { Flex } from 'ui/src' +import { Trans } from 'uniswap/src/i18n/Trans' import { isWebAndroid, isWebIOS } from 'utilities/src/platform' import { APP_DOWNLOAD_LINKS, openDownloadApp } from 'utils/openDownloadApp' @@ -54,16 +54,17 @@ export function DownloadApp({ onClick }: { onClick?: () => void }) { openGetTheAppModal() } }} + data-testid="nav-dropdown-download-app" > - + - +
) } diff --git a/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx b/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx index 734a612d75c..92b4de2e702 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/MenuDropdown.tsx @@ -1,15 +1,15 @@ -import Column from 'components/Column' import { MenuItem, MenuSection, useMenuContent } from 'components/NavBar/CompanyMenu/Content' import { DownloadApp } from 'components/NavBar/CompanyMenu/DownloadAppCTA' import { NavDropdown } from 'components/NavBar/NavDropdown' import { useTabsVisible } from 'components/NavBar/ScreenSizes' import { useTabsContent } from 'components/NavBar/Tabs/TabsContent' -import { t } from 'i18next' import styled, { css } from 'lib/styled-components' import { Socials } from 'pages/Landing/sections/Footer' import { useMemo } from 'react' import { Link } from 'react-router-dom' import { ExternalLink, Separator, ThemedText } from 'theme/components' +import { Flex } from 'ui/src' +import { t } from 'uniswap/src/i18n' const Container = styled.div` width: 295px; @@ -20,39 +20,40 @@ const Container = styled.div` height: unset; border-radius: 12px; ` -const LinkStyles = css` +const LinkStyles = css<{ $hoverColor?: string }>` font-size: 16px; text-decoration: none; color: ${({ theme }) => theme.neutral2}; + transition: color ${({ theme }) => theme.transition.duration.fast}; padding: 4px 0; &:hover { - color: ${({ theme }) => theme.accent1}; + color: ${({ theme, $hoverColor }) => $hoverColor || theme.accent1}; opacity: 1; } ` -const StyledInternalLink = styled(Link)` +const StyledInternalLink = styled(Link)<{ $hoverColor?: string }>` ${LinkStyles} padding: 0; ` -const StyledExternalLink = styled(ExternalLink)` +const StyledExternalLink = styled(ExternalLink)<{ $hoverColor?: string }>` ${LinkStyles} padding: 0; ` -export function MenuLink({ label, href, internal, closeMenu }: MenuItem) { +export function MenuLink({ label, href, internal, $hoverColor, closeMenu }: MenuItem & { $hoverColor?: string }) { return internal ? ( - + {label} ) : ( - + {label} ) } function Section({ title, items, closeMenu }: MenuSection) { return ( - + {title} {items.map((item, index) => ( ))} - +
) } export function MenuDropdown({ close }: { close?: () => void }) { @@ -83,9 +84,9 @@ export function MenuDropdown({ close }: { close?: () => void }) { }, [tabs]) return ( - - - + + + {!areTabsVisible &&
} {menuContent.map((sectionContent, index) => (
void }) { - + ) diff --git a/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx b/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx index a0ed6ca7f34..a57896cf0a7 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/MobileMenuDrawer.tsx @@ -1,5 +1,4 @@ import { AnimatedSlider } from 'components/AnimatedSlider' -import Column from 'components/Column' import { useMenuContent } from 'components/NavBar/CompanyMenu/Content' import { DownloadApp } from 'components/NavBar/CompanyMenu/DownloadAppCTA' import { MenuLink } from 'components/NavBar/CompanyMenu/MenuDropdown' @@ -14,8 +13,8 @@ import styled, { useTheme } from 'lib/styled-components' import { Socials } from 'pages/Landing/sections/Footer' import { useCallback, useEffect, useRef, useState } from 'react' import { ChevronDown } from 'react-feather' -import { useTranslation } from 'react-i18next' -import { Accordion, Square, Text } from 'ui/src' +import { Accordion, Flex, Square, Text } from 'ui/src' +import { useTranslation } from 'uniswap/src/i18n' const StyledMenuLink = styled(MenuLink)` color: ${({ theme }) => theme.neutral2} !important; @@ -23,9 +22,6 @@ const StyledMenuLink = styled(MenuLink)` color: ${({ theme }) => theme.neutral2} !important; } ` -const MobileDrawer = styled(Column)` - padding: 12px 24px 32px 24px; -` function MenuSection({ title, @@ -40,7 +36,7 @@ function MenuSection({ return ( - + {({ open }: { open: boolean }) => ( <> @@ -56,9 +52,9 @@ function MenuSection({ )} - {children} + {children} - + ) } @@ -80,7 +76,7 @@ export function MobileMenuDrawer({ isOpen, closeMenu }: { isOpen: boolean; close ) const onExitPreferencesMenu = useCallback(() => changeView(PreferencesView.SETTINGS), [changeView]) const { t } = useTranslation() - const tabsContent = useTabsContent() + const tabsContent = useTabsContent({ includeNftsLink: true }) const menuContent = useMenuContent() // Collapse sections on close @@ -91,8 +87,8 @@ export function MobileMenuDrawer({ isOpen, closeMenu }: { isOpen: boolean; close }, [isOpen]) return ( - - + + - + {tabsContent.map((tab, index) => ( ))} - + - + - + ) } diff --git a/apps/web/src/components/NavBar/CompanyMenu/index.tsx b/apps/web/src/components/NavBar/CompanyMenu/index.tsx index f903940fc18..2829749077f 100644 --- a/apps/web/src/components/NavBar/CompanyMenu/index.tsx +++ b/apps/web/src/components/NavBar/CompanyMenu/index.tsx @@ -1,4 +1,3 @@ -import { useIsTouchDevice } from '@tamagui/core' import { ArrowChangeDown } from 'components/Icons/ArrowChangeDown' import { NavIcon } from 'components/Logo/NavIcon' import { MenuDropdown } from 'components/NavBar/CompanyMenu/MenuDropdown' @@ -8,7 +7,7 @@ import { useScreenSize } from 'hooks/screenSize' import styled from 'lib/styled-components' import { useCallback, useEffect, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { Popover, Text } from 'ui/src' +import { Popover, Text, useIsTouchDevice } from 'ui/src' import { Hamburger } from 'ui/src/components/icons' const ArrowDown = styled(ArrowChangeDown)<{ $isActive: boolean }>` @@ -58,10 +57,10 @@ export function CompanyMenu() { return ( - + - - + + {isLargeScreen && ( Uniswap diff --git a/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.test.tsx b/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.test.tsx index 8c346d46e5f..63ad1c4f8ac 100644 --- a/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.test.tsx +++ b/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.test.tsx @@ -15,7 +15,5 @@ describe('GetTheAppButton', () => { expect(container).toMatchSnapshot() expect(screen.getByText('Get the app')).toBeVisible() - expect(screen.getByTestId('apple-logo')).toBeVisible() - expect(screen.getByTestId('google-play-store-logo')).toBeVisible() }) }) diff --git a/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.tsx b/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.tsx index cb3f6cd68eb..e6a3184f892 100644 --- a/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.tsx +++ b/apps/web/src/components/NavBar/DownloadApp/GetTheAppButton.tsx @@ -1,62 +1,35 @@ -import { AppleLogo } from 'components/Icons/AppleLogo' -import { GooglePlayStoreLogo } from 'components/Icons/GooglePlayStoreLogo' -import Row from 'components/Row' -import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' -import { Wiggle } from 'pages/Landing/components/animations' import { useOpenModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' -import { Text } from 'ui/src' +import { Button, Flex, Text, styled } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' -const StyledButton = styled.button` - height: 40px; - background: ${({ theme }) => theme.surface1}; - border-radius: 20px; - border: 1px solid ${({ theme }) => theme.surface3}; - padding: 8px 16px 8px 12px; - cursor: pointer; - &:hover { - background: ${({ theme }) => theme.surface2}; - } -` +const StyledButton = styled(Button, { + height: '40px', + backgroundColor: '$surface1', + borderRadius: '$rounded20', + borderWidth: '$spacing1', + borderStyle: 'solid', + borderColor: '$surface3', + cursor: 'pointer', + alignItems: 'center', + hoverStyle: { + backgroundColor: '$surface2', + }, + pressStyle: { + backgroundColor: '$surface2', + }, +}) -export const WiggleIcon = styled(Wiggle)` - flex: 0; - height: auto; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; -` -export function GetTheAppButton({ showIcons = true }: { showIcons?: boolean }) { - const theme = useTheme() +export function GetTheAppButton() { const openModal = useOpenModal(ApplicationModal.GET_THE_APP) return ( - - - + + + - {showIcons && ( - - - - )} - {showIcons && ( - - - - )} - + ) } diff --git a/apps/web/src/components/NavBar/DownloadApp/Modal/Content.tsx b/apps/web/src/components/NavBar/DownloadApp/Modal/Content.tsx index 160792b3fb8..67690c76023 100644 --- a/apps/web/src/components/NavBar/DownloadApp/Modal/Content.tsx +++ b/apps/web/src/components/NavBar/DownloadApp/Modal/Content.tsx @@ -1,21 +1,22 @@ -import { ColumnCenter } from 'components/Column' -import { MobileAppLogo } from 'components/Icons/MobileAppLogo' import { PropsWithChildren } from 'react' import { ThemedText } from 'theme/components' +import { Flex, Image } from 'ui/src' +import { UNISWAP_LOGO } from 'ui/src/assets' +import { iconSizes } from 'ui/src/theme' export function ModalContent({ title, subtext, children }: PropsWithChildren<{ title: string; subtext: string }>) { return ( - - - - + + + + {title} {subtext} - - + + {children} - + ) } diff --git a/apps/web/src/components/NavBar/DownloadApp/Modal/GetStarted.tsx b/apps/web/src/components/NavBar/DownloadApp/Modal/GetStarted.tsx index b814b811ee3..4b945afdfc4 100644 --- a/apps/web/src/components/NavBar/DownloadApp/Modal/GetStarted.tsx +++ b/apps/web/src/components/NavBar/DownloadApp/Modal/GetStarted.tsx @@ -3,19 +3,25 @@ import ExtensionIllustration from 'assets/images/extensionIllustration.png' import WalletIllustration from 'assets/images/walletIllustration.png' import Column from 'components/Column' import { AppleLogo } from 'components/Icons/AppleLogo' -import { BraveBrowserLogo } from 'components/Icons/BraveBrowserLogo' import { GoogleChromeLogo } from 'components/Icons/GoogleChromeLogo' import { GooglePlayStoreLogo } from 'components/Icons/GooglePlayStoreLogo' -import { WiggleIcon } from 'components/NavBar/DownloadApp/GetTheAppButton' import { ModalContent } from 'components/NavBar/DownloadApp/Modal/Content' -import Row from 'components/Row' import styled, { useTheme } from 'lib/styled-components' +import { Wiggle } from 'pages/Landing/components/animations' import { PropsWithChildren } from 'react' -import { useTranslation } from 'react-i18next' -import { Text } from 'ui/src' +import { Flex, Text } from 'ui/src' import { uniswapUrls } from 'uniswap/src/constants/urls' import Trace from 'uniswap/src/features/telemetry/Trace' +import { useTranslation } from 'uniswap/src/i18n' +const WiggleIcon = styled(Wiggle)` + flex: 0; + height: auto; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; +` const IllustrationContainer = styled.div` display: flex; max-width: 100%; @@ -41,17 +47,17 @@ const RightContent = styled.div` function CardInfo({ title, details, children }: PropsWithChildren<{ title: string; details: string }>) { return ( - - + + {title} {details} - + {children} - + ) } @@ -60,20 +66,20 @@ export function GetStarted({ toAppDownload }: { toAppDownload: () => void }) { const { t } = useTranslation() return ( - + - - + + - + @@ -81,19 +87,16 @@ export function GetStarted({ toAppDownload }: { toAppDownload: () => void }) { - - - - - + + - + - + ) } diff --git a/apps/web/src/components/NavBar/DownloadApp/Modal/GetTheApp.tsx b/apps/web/src/components/NavBar/DownloadApp/Modal/GetTheApp.tsx index 5fd2d0f33e3..5bbdda624ed 100644 --- a/apps/web/src/components/NavBar/DownloadApp/Modal/GetTheApp.tsx +++ b/apps/web/src/components/NavBar/DownloadApp/Modal/GetTheApp.tsx @@ -1,11 +1,11 @@ import { ReactComponent as AppStoreBadge } from 'assets/svg/app-store-badge.svg' import { ReactComponent as PlayStoreBadge } from 'assets/svg/play-store-badge.svg' import { ModalContent } from 'components/NavBar/DownloadApp/Modal/Content' -import Row from 'components/Row' import { WalletOneLinkQR } from 'components/WalletOneLinkQR' import styled from 'lib/styled-components' -import { useTranslation } from 'react-i18next' import { ExternalLink } from 'theme/components' +import { Flex } from 'ui/src' +import { useTranslation } from 'uniswap/src/i18n' const BadgeLink = styled(ExternalLink)` stroke: none; @@ -20,14 +20,14 @@ export function GetTheApp() { - + - + ) } diff --git a/apps/web/src/components/NavBar/DownloadApp/Modal/index.tsx b/apps/web/src/components/NavBar/DownloadApp/Modal/index.tsx index 40865279929..a6f1815eb66 100644 --- a/apps/web/src/components/NavBar/DownloadApp/Modal/index.tsx +++ b/apps/web/src/components/NavBar/DownloadApp/Modal/index.tsx @@ -3,16 +3,14 @@ import { AnimatedSlider } from 'components/AnimatedSlider' import Modal from 'components/Modal' import { GetStarted } from 'components/NavBar/DownloadApp/Modal/GetStarted' import { GetTheApp } from 'components/NavBar/DownloadApp/Modal/GetTheApp' -import Row from 'components/Row' import styled, { css } from 'lib/styled-components' import { useCallback, useState } from 'react' import { ArrowLeft, X } from 'react-feather' import { useCloseModal, useModalIsOpen } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ClickableStyle } from 'theme/components' +import { Flex } from 'ui/src' import { iconSizes } from 'ui/src/theme' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' const StyledModal = styled(Modal)` @@ -44,33 +42,28 @@ enum Page { export function GetTheAppModal() { const [page, setPage] = useState(Page.GetStarted) const isOpen = useModalIsOpen(ApplicationModal.GET_THE_APP) - const isLegacyNav = !useFeatureFlag(FeatureFlags.NavRefresh) const closeModal = useCloseModal() const close = useCallback(() => { closeModal() setTimeout(() => setPage(Page.GetStarted), 500) }, [closeModal, setPage]) - const showBackButton = !isLegacyNav && page !== Page.GetStarted + const showBackButton = page !== Page.GetStarted return ( - - + + {showBackButton && setPage(Page.GetStarted)} size={iconSizes.icon24} />} - - {isLegacyNav ? ( + + + setPage(Page.GetApp)} /> - ) : ( - - setPage(Page.GetApp)} /> - - - )} + diff --git a/apps/web/src/components/NavBar/DownloadApp/__snapshots__/GetTheAppButton.test.tsx.snap b/apps/web/src/components/NavBar/DownloadApp/__snapshots__/GetTheAppButton.test.tsx.snap index d607b48ef98..64ac58def31 100644 --- a/apps/web/src/components/NavBar/DownloadApp/__snapshots__/GetTheAppButton.test.tsx.snap +++ b/apps/web/src/components/NavBar/DownloadApp/__snapshots__/GetTheAppButton.test.tsx.snap @@ -1,68 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`GetTheAppButton displays a button with call to action text and icons 1`] = ` -.c1 { - box-sizing: border-box; - margin: 0; - min-width: 0; - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; -} - -.c2 { - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; - gap: 12px; -} - -.c0 { - height: 40px; - background: #FFFFFF; - border-radius: 20px; - border: 1px solid #22222212; - padding: 8px 16px 8px 12px; - cursor: pointer; -} - -.c0:hover { - background: #F9F9F9; -} - -.c3 { - -webkit-flex: 0; - -ms-flex: 0; - flex: 0; - height: auto; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} -
- + +
diff --git a/apps/web/src/components/NavBar/LEGACY/Blur.tsx b/apps/web/src/components/NavBar/LEGACY/Blur.tsx deleted file mode 100644 index 214f7c5de6d..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/Blur.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import styled from 'lib/styled-components' - -const MAX_STRENGTH = 5 -const BLUR_STEPS = 20 -const BLUR_FADE = '#fff' - -const NAV_HEIGHT = 72 - -const BlurGroup = styled.div` - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-image: linear-gradient(${BLUR_FADE}, rgba(${BLUR_FADE}, 0)); -` - -const BlurLayer = styled.div<{ index: number }>` - position: absolute; - top: 0; - left: 0; - right: 0; - height: ${({ index }) => (NAV_HEIGHT / BLUR_STEPS) * index}px; - backdrop-filter: blur(${({ index }) => (MAX_STRENGTH / BLUR_STEPS) * (BLUR_STEPS - index)}px); -` - -export default function Blur() { - return ( - - {Array.from(Array(BLUR_STEPS), (_, index) => ( - - ))} - - ) -} diff --git a/apps/web/src/components/NavBar/LEGACY/Menu.tsx b/apps/web/src/components/NavBar/LEGACY/Menu.tsx deleted file mode 100644 index d69a0c0b24e..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/Menu.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ColumnCenter } from 'components/Column' -import { MenuContent } from 'components/NavBar/LEGACY/MenuContent' -import { useOnClickOutside } from 'hooks/useOnClickOutside' -import styled from 'lib/styled-components' -import { vars } from 'nft/css/sprinkles.css' -import { useRef, useState } from 'react' -import { ChevronDown } from 'react-feather' -import { BREAKPOINTS } from 'theme' - -const Wrapper = styled.div` - width: 100%; - display: flex; - flex: grow; - justify-content: center; - align-items: center; - position: relative; - margin: 4px 0px; -` -const IconContainer = styled(ColumnCenter)<{ isActive: boolean }>` - min-height: 100%; - justify-content: center; - border-radius: 14px; - padding: 9px 14px; - cursor: pointer; - color: ${({ isActive, theme }) => (isActive ? theme.neutral1 : theme.neutral2)}; - :hover { - background: ${vars.color.lightGrayOverlay}; - } -` -const ChevronIcon = styled(ChevronDown)<{ $rotated: boolean }>` - @media screen and (max-width: ${BREAKPOINTS.md}px) { - rotate: 180deg; - } - transition: transform 0.3s ease; - transform: ${({ $rotated }) => ($rotated ? 'rotate(180deg)' : 'none')}; -` -export function More() { - const [isOpen, setIsOpen] = useState(false) - const ref = useRef(null) - useOnClickOutside(ref, () => setIsOpen(false)) - - return ( - - setIsOpen(!isOpen)} data-testid="nav-more-button"> - - - {isOpen && setIsOpen(false)} />} - - ) -} diff --git a/apps/web/src/components/NavBar/LEGACY/MenuContent.tsx b/apps/web/src/components/NavBar/LEGACY/MenuContent.tsx deleted file mode 100644 index 77df1b7accb..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/MenuContent.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import Column from 'components/Column' -import { ScrollBarStyles } from 'components/Common' -import { MobileAppLogo } from 'components/Icons/MobileAppLogo' -import { MenuItem, MenuSection, useMenuContent } from 'components/NavBar/CompanyMenu/Content' -import Row from 'components/Row' -import { Trans } from 'i18n' -import styled, { css } from 'lib/styled-components' -import { Socials } from 'pages/Landing/sections/Footer' -import { Link } from 'react-router-dom' -import { Text } from 'rebass' -import { useOpenModal } from 'state/application/hooks' -import { ApplicationModal } from 'state/application/reducer' -import { BREAKPOINTS } from 'theme' -import { ExternalLink, ThemedText } from 'theme/components' - -const Container = styled.div` - width: 295px; - max-height: 85vh; - padding: 24px; - margin-top: 12px; - margin-bottom: 8px; - background: ${({ theme }) => theme.surface1}; - user-select: none; - overflow: auto; - ${ScrollBarStyles} - height: unset; - - border-radius: 12px; - border: 1px solid ${({ theme }) => theme.surface3}; - box-shadow: 0px 0px 10px 0px rgba(34, 34, 34, 0.04); - - position: absolute; - right: 0px; - top: 30px; - bottom: unset; - @media screen and (max-width: ${BREAKPOINTS.md}px) { - top: unset; - bottom: 50px; - } -` -const LinkStyles = css` - font-size: 16px; - text-decoration: none; - color: ${({ theme }) => theme.neutral2}; - &:hover { - color: ${({ theme }) => theme.accent1}; - opacity: 1; - } -` -const StyledInternalLink = styled(Link)<{ canHide?: boolean }>` - ${LinkStyles} - @media screen and (max-width: ${BREAKPOINTS.md}px), (min-width: ${BREAKPOINTS.xl}px) { - display: ${({ canHide }) => (canHide ? 'none' : 'block')}; - } -` -const StyledExternalLink = styled(ExternalLink)` - ${LinkStyles} -` -const Separator = styled.div` - width: 100%; - height: 1px; - background: ${({ theme }) => theme.surface3}; -` -const StyledRow = styled(Row)` - cursor: pointer; - :hover { - color: ${({ theme }) => theme.accent1}; - } -` -const StyledSocials = styled(Socials)` - height: 20px; -` -function Item({ label, href, internal, overflow, closeMenu }: MenuItem) { - return internal ? ( - - {label} - - ) : ( - {label} - ) -} -function Section({ title, items, closeMenu }: MenuSection) { - return ( - - {title} - {items.map((item, index) => ( - - ))} - - ) -} -export function MenuContent({ close }: { close: () => void }) { - const openGetTheAppModal = useOpenModal(ApplicationModal.GET_THE_APP) - const menuContent = useMenuContent() - - return ( - - - {menuContent.map((sectionContent, index) => ( -
- ))} - - { - close() - openGetTheAppModal() - }} - > - - - - - - - - - - - - - - ) -} diff --git a/apps/web/src/components/NavBar/LEGACY/SearchBar/RecentlySearchedAssets.ts b/apps/web/src/components/NavBar/LEGACY/SearchBar/RecentlySearchedAssets.ts deleted file mode 100644 index 0873a248dca..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/SearchBar/RecentlySearchedAssets.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens' -import { SearchToken } from 'graphql/data/SearchTokens' -import { supportedChainIdFromGQLChain } from 'graphql/data/util' -import { useAtom } from 'jotai' -import { atomWithStorage, useAtomValue } from 'jotai/utils' -import { GenieCollection } from 'nft/types' -import { useCallback, useMemo } from 'react' -import { - Chain, - NftCollection, - useRecentlySearchedAssetsQuery, -} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { logger } from 'utilities/src/logger/logger' -import { getNativeTokenDBAddress } from 'utils/nativeTokens' - -type RecentlySearchedAsset = { - isNft?: boolean - address: string - chain: Chain -} - -// Temporary measure used until backend supports addressing by "NATIVE" -const NATIVE_QUERY_ADDRESS_INPUT = null as unknown as string -function getQueryAddress(chain: Chain) { - return getNativeTokenDBAddress(chain) ?? NATIVE_QUERY_ADDRESS_INPUT -} - -const recentlySearchedAssetsAtom = atomWithStorage('recentlySearchedAssets', []) - -export function useAddRecentlySearchedAsset() { - const [searchHistory, updateSearchHistory] = useAtom(recentlySearchedAssetsAtom) - - return useCallback( - (asset: RecentlySearchedAsset) => { - // Removes the new asset if it was already in the array - const newHistory = searchHistory.filter( - (oldAsset) => !(oldAsset.address === asset.address && oldAsset.chain === asset.chain), - ) - newHistory.unshift(asset) - updateSearchHistory(newHistory) - }, - [searchHistory, updateSearchHistory], - ) -} - -export function useRecentlySearchedAssets() { - const history = useAtomValue(recentlySearchedAssetsAtom) - const shortenedHistory = useMemo(() => history.slice(0, 4), [history]) - - const { data: queryData, loading } = useRecentlySearchedAssetsQuery({ - variables: { - collectionAddresses: shortenedHistory.filter((asset) => asset.isNft).map((asset) => asset.address), - contracts: shortenedHistory - .filter((asset) => !asset.isNft) - .map((token) => ({ - address: token.address === NATIVE_CHAIN_ID ? getQueryAddress(token.chain) : token.address, - chain: token.chain, - })), - }, - }) - - const data = useMemo(() => { - if (shortenedHistory.length === 0) { - return [] - } else if (!queryData) { - return undefined - } - // Collects both tokens and collections in a map, so they can later be returned in original order - const resultsMap: { [key: string]: GenieCollection | SearchToken } = {} - - const queryCollections = queryData?.nftCollections?.edges.map((edge) => edge.node as NonNullable) - const collections = queryCollections?.map( - (queryCollection): GenieCollection => { - return { - address: queryCollection.nftContracts?.[0]?.address ?? '', - isVerified: queryCollection?.isVerified, - name: queryCollection?.name, - stats: { - floor_price: queryCollection?.markets?.[0]?.floorPrice?.value, - total_supply: queryCollection?.numAssets, - }, - imageUrl: queryCollection?.image?.url ?? '', - } - }, - [queryCollections], - ) - collections?.forEach((collection) => (resultsMap[collection.address] = collection)) - queryData.tokens?.filter(Boolean).forEach((token) => { - if (token) { - resultsMap[token.address ?? `NATIVE-${token.chain}`] = token - } - }) - - const data: (SearchToken | GenieCollection)[] = [] - shortenedHistory.forEach((asset) => { - if (asset.address === NATIVE_CHAIN_ID) { - // Handles special case where wMATIC data needs to be used for MATIC - const chain = supportedChainIdFromGQLChain(asset.chain) - if (!chain) { - logger.error(new Error('Invalid chain retrieved from Search Token/Collection Query'), { - tags: { - file: 'RecentlySearchedAssets', - function: 'useRecentlySearchedAssets', - }, - extra: { asset }, - }) - return - } - const native = nativeOnChain(chain) - const queryAddress = getQueryAddress(asset.chain)?.toLowerCase() ?? `NATIVE-${asset.chain}` - const result = resultsMap[queryAddress] - if (result) { - data.push({ ...result, address: NATIVE_CHAIN_ID, ...native }) - } - } else { - const result = resultsMap[asset.address] - if (result) { - data.push(result) - } - } - }) - return data - }, [queryData, shortenedHistory]) - - return { data, loading } -} diff --git a/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBar.css.ts b/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBar.css.ts deleted file mode 100644 index f614c2d4dbb..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBar.css.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { style } from '@vanilla-extract/css' -import { subheadSmall } from 'nft/css/common.css' -import { breakpoints, sprinkles, vars } from 'nft/css/sprinkles.css' - -const DESKTOP_NAVBAR_WIDTH = 330 -const DESKTOP_NAVBAR_WIDTH_MD = 360 -const DESKTOP_NAVBAR_WIDTH_L = 480 -const DESKTOP_NAVBAR_WIDTH_XL = 520 -const DESKTOP_NAVBAR_WIDTH_XXL = 640 - -const baseSearchStyle = style([ - sprinkles({ - paddingY: '8', - width: { sm: 'viewWidth' }, - borderStyle: 'solid', - borderWidth: '1px', - borderColor: 'surface3', - }), - { - backdropFilter: 'blur(60px)', - '@media': { - [`screen and (min-width: ${breakpoints.sm}px)`]: { - width: `${DESKTOP_NAVBAR_WIDTH_MD}px`, - }, - }, - }, -]) - -const baseSearchNftStyle = style([ - baseSearchStyle, - { - '@media': { - [`screen and (min-width: ${breakpoints.md}px)`]: { - width: `${DESKTOP_NAVBAR_WIDTH}px`, - }, - [`screen and (min-width: ${breakpoints.lg}px)`]: { - width: `${DESKTOP_NAVBAR_WIDTH_MD}px`, - }, - [`screen and (min-width: ${breakpoints.xl}px)`]: { - width: `${DESKTOP_NAVBAR_WIDTH_L}px`, - }, - [`screen and (min-width: ${breakpoints.xxl}px)`]: { - width: `${DESKTOP_NAVBAR_WIDTH_XL}px`, - }, - [`screen and (min-width: ${breakpoints.xxxl}px)`]: { - width: `${DESKTOP_NAVBAR_WIDTH_XXL}px`, - }, - }, - }, -]) - -export const searchBarContainerNft = style([ - sprinkles({ - right: '0', - top: '0', - zIndex: '3', - display: 'flex', - maxHeight: 'searchResultsMaxHeight', - overflow: 'hidden', - }), - { - backdropFilter: 'blur(60px)', - borderRadius: '16px', - }, -]) - -export const searchBarContainerDisableBlur = style({ - backdropFilter: 'none', -}) - -export const searchBar = style([ - baseSearchStyle, - sprinkles({ - color: 'neutral2', - paddingX: '12', - }), -]) - -export const nftSearchBar = style([ - baseSearchNftStyle, - sprinkles({ - color: 'neutral2', - paddingX: '12', - }), - { - backdropFilter: 'blur(60px)', - }, -]) - -export const searchBarInput = style([ - sprinkles({ - padding: '0', - fontSize: '16', - fontWeight: 'book', - color: { default: 'neutral1', placeholder: 'neutral2' }, - border: 'none', - background: 'none', - lineHeight: '24', - height: 'full', - }), -]) - -export const searchBarDropdownNft = style([ - baseSearchNftStyle, - sprinkles({ - borderBottomLeftRadius: '16', - borderBottomRightRadius: '16', - height: { sm: 'viewHeight', md: 'auto' }, - backgroundColor: 'surface1', - }), - { - backdropFilter: 'blur(60px)', - borderTop: 'none', - }, -]) - -export const searchBarScrollable = sprinkles({ - overflowY: 'auto', -}) - -export const sectionHeader = style([ - subheadSmall, - sprinkles({ - color: 'neutral2', - }), - { - lineHeight: '20px', - }, -]) - -export const notFoundContainer = style([ - sectionHeader, - sprinkles({ - paddingY: '4', - paddingLeft: '16', - }), -]) - -export const hidden = style([ - sprinkles({ - visibility: 'hidden', - opacity: '0', - padding: '0', - height: '0', - }), -]) -export const visible = style([ - sprinkles({ - visibility: 'visible', - opacity: '1', - height: 'full', - }), -]) - -export const searchContentLeftAlign = style({ - '@media': { - [`screen and (min-width: ${breakpoints.lg}px)`]: { - transform: 'translateX(0)', - transition: `transform ${vars.time[125]}`, - transitionTimingFunction: 'ease-in', - }, - }, -}) diff --git a/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBar.tsx b/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBar.tsx deleted file mode 100644 index 508b84ef76f..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBar.tsx +++ /dev/null @@ -1,225 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { InterfaceElementName, InterfaceEventName, InterfaceSectionName } from '@uniswap/analytics-events' -import clsx from 'clsx' -import { Search } from 'components/Icons/Search' -import * as styles from 'components/NavBar/LEGACY/SearchBar/SearchBar.css' -import { SearchBarDropdown } from 'components/NavBar/LEGACY/SearchBar/SearchBarDropdown' -import { NavIcon } from 'components/NavBar/NavIcon' -import { useSearchTokens } from 'graphql/data/SearchTokens' -import { useCollectionSearch } from 'graphql/data/nft/CollectionSearch' -import { useIsMobile, useIsTablet } from 'hooks/screenSize' -import { useAccount } from 'hooks/useAccount' -import useDebounce from 'hooks/useDebounce' -import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' -import { useIsNftPage } from 'hooks/useIsNftPage' -import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { useTranslation } from 'i18n/useTranslation' -import styled from 'lib/styled-components' -import { organizeSearchResults } from 'lib/utils/searchBar' -import { Box } from 'nft/components/Box' -import { Column, Row } from 'nft/components/Flex' -import { ChevronLeftIcon, NavMagnifyingGlassIcon } from 'nft/components/icons' -import { magicalGradientOnHover } from 'nft/css/common.css' -import { useIsNavSearchInputVisible } from 'nft/hooks/useIsNavSearchInputVisible' -import { ChangeEvent, useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { useLocation } from 'react-router-dom' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' - -const KeyShortcut = styled.div` - background-color: ${({ theme }) => theme.surface3}; - color: ${({ theme }) => theme.neutral2}; - padding: 0px 8px; - width: 20px; - height: 20px; - border-radius: 4px; - font-size: 12px; - font-weight: 535; - line-height: 18px; - display: flex; - align-items: center; - opacity: 0.6; - backdrop-filter: blur(60px); -` - -export function SearchBar() { - const [isOpen, toggleOpen] = useReducer((state: boolean) => !state, false) - const [searchValue, setSearchValue] = useState('') - const debouncedSearchValue = useDebounce(searchValue, 300) - const searchRef = useRef(null) - const inputRef = useRef(null) - const { pathname } = useLocation() - const isMobile = useIsMobile() - const isTablet = useIsTablet() - const isNavSearchInputVisible = useIsNavSearchInputVisible() - const shouldDisableNFTRoutes = useDisableNFTRoutes() - - useOnClickOutside(searchRef, () => { - isOpen && toggleOpen() - }) - - const { data: collections, loading: collectionsAreLoading } = useCollectionSearch(debouncedSearchValue) - - const account = useAccount() - const { data: tokens, loading: tokensAreLoading } = useSearchTokens(debouncedSearchValue, account.chainId ?? 1) - - const isNFTPage = useIsNftPage() - - const [reducedTokens, reducedCollections] = organizeSearchResults(isNFTPage, tokens ?? [], collections ?? []) - - // close dropdown on escape - useEffect(() => { - const escapeKeyDownHandler = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen) { - event.preventDefault() - toggleOpen() - } - } - - document.addEventListener('keydown', escapeKeyDownHandler) - - return () => { - document.removeEventListener('keydown', escapeKeyDownHandler) - } - }, [isOpen, toggleOpen, collections]) - - // clear searchbar when changing pages - useEffect(() => { - setSearchValue('') - }, [pathname]) - - // auto set cursor when searchbar is opened - useEffect(() => { - if (isOpen) { - inputRef.current?.focus() - } - }, [isOpen]) - - const isMobileOrTablet = isMobile || isTablet || !isNavSearchInputVisible - - const trace = useTrace({ section: InterfaceSectionName.NAVBAR_SEARCH }) - - const navbarSearchEventProperties = { - navbar_search_input_text: debouncedSearchValue, - hasInput: debouncedSearchValue.length > 0, - ...trace, - } - - const { t } = useTranslation() // subscribe to locale changes - const placeholderText = isMobileOrTablet - ? t('common.search.label') - : shouldDisableNFTRoutes - ? t('common.searchTokens') - : t('common.searchTokensNFT') - - const handleKeyPress = useCallback( - (event: KeyboardEvent) => { - const isInputEvent = event.target && (event.target as HTMLInputElement).tagName === 'INPUT' - if (event.key === '/' && !isInputEvent) { - event.preventDefault() - !isOpen && toggleOpen() - } - }, - [isOpen], - ) - - useEffect(() => { - const innerRef = inputRef.current - - if (innerRef !== null) { - //only mount the listener when input available as ref - document.addEventListener('keydown', handleKeyPress) - } - - return () => { - if (innerRef !== null) { - document.removeEventListener('keydown', handleKeyPress) - } - } - }, [handleKeyPress, inputRef]) - - return ( - - - !isOpen && toggleOpen()} - gap="12" - > - - - - - - - - - - ) => { - !isOpen && toggleOpen() - setSearchValue(event.target.value) - }} - onBlur={() => sendAnalyticsEvent(InterfaceEventName.NAVBAR_SEARCH_EXITED, navbarSearchEventProperties)} - className={`${styles.searchBarInput} ${styles.searchContentLeftAlign}`} - value={searchValue} - ref={inputRef} - width="full" - /> - - {!isOpen && /} - - - {isOpen && ( - 0} - isLoading={tokensAreLoading || collectionsAreLoading} - /> - )} - - - {isMobileOrTablet && ( - - - - )} - - ) -} diff --git a/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBarDropdown.tsx b/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBarDropdown.tsx deleted file mode 100644 index e768bc42423..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/SearchBar/SearchBarDropdown.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import { InterfaceSectionName, NavBarSearchTypes } from '@uniswap/analytics-events' -import clsx from 'clsx' -import Badge from 'components/Badge' -import { ChainLogo } from 'components/Logo/ChainLogo' -import { useRecentlySearchedAssets } from 'components/NavBar/LEGACY/SearchBar/RecentlySearchedAssets' -import * as styles from 'components/NavBar/LEGACY/SearchBar/SearchBar.css' -import { SkeletonRow, SuggestionRow } from 'components/NavBar/LEGACY/SearchBar/SuggestionRow' -import { SuspendConditionally } from 'components/Suspense/SuspendConditionally' -import { SuspenseWithPreviousRenderAsFallback } from 'components/Suspense/SuspenseWithPreviousRenderAsFallback' -import { BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS } from 'constants/chains' -import { SearchToken } from 'graphql/data/SearchTokens' -import useTrendingTokens from 'graphql/data/TrendingTokens' -import { useTrendingCollections } from 'graphql/data/nft/TrendingCollections' -import { useAccount } from 'hooks/useAccount' -import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' -import { useIsNftPage } from 'hooks/useIsNftPage' -import { Trans } from 'i18n' -import styled from 'lib/styled-components' -import { Box } from 'nft/components/Box' -import { Column, Row } from 'nft/components/Flex' -import { ClockIcon, TrendingArrow } from 'nft/components/icons' -import { subheadSmall } from 'nft/css/common.css' -import { GenieCollection } from 'nft/types' -import { useEffect, useMemo, useState } from 'react' -import { useLocation } from 'react-router-dom' -import { ThemedText } from 'theme/components' -import { HistoryDuration, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { InterfaceSearchResultSelectionProperties } from 'uniswap/src/features/telemetry/types' -import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' -import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' - -interface SearchBarDropdownSectionProps { - toggleOpen: () => void - suggestions: (GenieCollection | SearchToken | undefined)[] - header: JSX.Element - headerIcon?: JSX.Element - hoveredIndex?: number - startingIndex: number - setHoveredIndex: (index: number | undefined) => void - isLoading?: boolean - eventProperties: InterfaceSearchResultSelectionProperties -} - -const SearchBarDropdownSection = ({ - toggleOpen, - suggestions, - header, - headerIcon = undefined, - hoveredIndex, - startingIndex, - setHoveredIndex, - isLoading, - eventProperties, -}: SearchBarDropdownSectionProps) => { - return ( - - - {headerIcon ? headerIcon : null} - {header} - - - {suggestions.map((suggestion, index) => - isLoading || !suggestion ? ( - - ) : ( - - ), - )} - - - ) -} - -function isKnownToken(token: SearchToken) { - return token.project?.safetyLevel == SafetyLevel.Verified || token.project?.safetyLevel == SafetyLevel.MediumWarning -} - -const ChainComingSoonBadge = styled(Badge)` - align-items: center; - background-color: ${({ theme }) => theme.surface2}; - color: ${({ theme }) => theme.neutral2}; - display: flex; - flex-direction: row; - justify-content: flex-start; - opacity: 1; - padding: 8px; - margin: 16px 16px 4px; - width: calc(100% - 32px); - gap: 8px; -` - -interface SearchBarDropdownProps { - toggleOpen: () => void - tokens: SearchToken[] - collections: GenieCollection[] - queryText: string - hasInput: boolean - isLoading: boolean -} - -export const SearchBarDropdown = (props: SearchBarDropdownProps) => { - const { isLoading } = props - const account = useAccount() - const showChainComingSoonBadge = - account.chainId && BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS.includes(account.chainId) && !isLoading - - return ( - - - - - - - - {showChainComingSoonBadge && account.chainId && ( - - - - - - - )} - - - ) -} - -function SearchBarDropdownContents({ - toggleOpen, - tokens, - collections, - queryText, - hasInput, -}: SearchBarDropdownProps): JSX.Element { - const [hoveredIndex, setHoveredIndex] = useState(0) - const { data: searchHistory } = useRecentlySearchedAssets() - const shortenedHistory = useMemo(() => searchHistory?.slice(0, 2) ?? [...Array(2)], [searchHistory]) - const { pathname } = useLocation() - const isNFTPage = useIsNftPage() - const isTokenPage = pathname.includes('/explore') - const shouldDisableNFTRoutes = useDisableNFTRoutes() - - const { data: trendingCollections, loading: trendingCollectionsAreLoading } = useTrendingCollections( - 3, - HistoryDuration.Day, - ) - - const formattedTrendingCollections = useMemo(() => { - return !trendingCollectionsAreLoading - ? trendingCollections - ?.map((collection) => ({ - ...collection, - collectionAddress: collection.address, - floorPrice: collection.floor, - stats: { - total_supply: collection.totalSupply, - one_day_change: collection.floorChange, - floor_price: collection.floor, - }, - })) - .slice(0, isNFTPage ? 3 : 2) ?? [] - : [...Array(isNFTPage ? 3 : 2)] - }, [trendingCollections, isNFTPage, trendingCollectionsAreLoading]) - - const account = useAccount() - const { data: trendingTokenData } = useTrendingTokens(account.chainId) - - const trendingTokensLength = !isNFTPage ? 3 : 2 - const trendingTokens = useMemo( - () => trendingTokenData?.slice(0, trendingTokensLength) ?? [...Array(trendingTokensLength)], - [trendingTokenData, trendingTokensLength], - ) - - const totalSuggestions = hasInput - ? tokens.length + collections.length - : Math.min(shortenedHistory.length, 2) + - (isNFTPage ? formattedTrendingCollections?.length ?? 0 : 0) + - (!isNFTPage ? trendingTokens?.length ?? 0 : 0) - - // Navigate search results via arrow keys - useEffect(() => { - const keyDownHandler = (event: KeyboardEvent) => { - if (event.key === 'ArrowUp') { - event.preventDefault() - if (!hoveredIndex) { - setHoveredIndex(totalSuggestions - 1) - } else { - setHoveredIndex(hoveredIndex - 1) - } - } else if (event.key === 'ArrowDown') { - event.preventDefault() - if (hoveredIndex && hoveredIndex === totalSuggestions - 1) { - setHoveredIndex(0) - } else { - setHoveredIndex((hoveredIndex ?? -1) + 1) - } - } - } - - document.addEventListener('keydown', keyDownHandler) - - return () => { - document.removeEventListener('keydown', keyDownHandler) - } - }, [toggleOpen, hoveredIndex, totalSuggestions]) - - const hasVerifiedCollection = collections.some((collection) => collection.isVerified) - const hasKnownToken = tokens.some(isKnownToken) - const showCollectionsFirst = isNFTPage && (hasVerifiedCollection || !hasKnownToken) - - const trace = useTrace({ section: InterfaceSectionName.NAVBAR_SEARCH }) - - const eventProperties = { - total_suggestions: totalSuggestions, - query_text: queryText, - ...trace, - } - - const tokenSearchResults = - tokens.length > 0 ? ( - } - /> - ) : ( - - - - ) - - const collectionSearchResults = - collections.length > 0 ? ( - } - /> - ) : ( - No NFT collections found. - ) - - return hasInput ? ( - // Empty or Up to 8 combined tokens and nfts - - {showCollectionsFirst ? ( - <> - {collectionSearchResults} - {tokenSearchResults} - - ) : ( - <> - {tokenSearchResults} - {collectionSearchResults} - - )} - - ) : ( - // Recent Searches, Trending Tokens, Trending Collections - - {shortenedHistory.length > 0 && ( - } - headerIcon={} - isLoading={!searchHistory} - /> - )} - {!isNFTPage && ( - } - headerIcon={} - isLoading={!trendingTokenData} - /> - )} - {Boolean(!isTokenPage && !shouldDisableNFTRoutes) && ( - } - headerIcon={} - isLoading={trendingCollectionsAreLoading} - /> - )} - - ) -} - -function ComingSoonText({ chainId }: { chainId: InterfaceChainId }) { - switch (chainId) { - case UniverseChainId.Avalanche: - return - default: - return null - } -} diff --git a/apps/web/src/components/NavBar/LEGACY/SearchBar/SuggestionRow.tsx b/apps/web/src/components/NavBar/LEGACY/SearchBar/SuggestionRow.tsx deleted file mode 100644 index f7fa4890eec..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/SearchBar/SuggestionRow.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { InterfaceEventName } from '@uniswap/analytics-events' -import Column from 'components/Column' -import QueryTokenLogo from 'components/Logo/QueryTokenLogo' -import { useAddRecentlySearchedAsset } from 'components/NavBar/LEGACY/SearchBar/RecentlySearchedAssets' -import Row from 'components/Row' -import TokenSafetyIcon from 'components/TokenSafety/TokenSafetyIcon' -import { DeltaArrow, DeltaText } from 'components/Tokens/TokenDetails/Delta' -import { LoadingBubble } from 'components/Tokens/loading' -import { useTokenWarning } from 'constants/tokenSafety' -import { NATIVE_CHAIN_ID } from 'constants/tokens' -import { SearchToken } from 'graphql/data/SearchTokens' -import { getTokenDetailsURL, supportedChainIdFromGQLChain } from 'graphql/data/util' -import { Trans } from 'i18n' -import styled, { css } from 'lib/styled-components' -import { VerifiedIcon } from 'nft/components/icons' -import { GenieCollection } from 'nft/types' -import { useCallback, useEffect, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { EllipsisStyle, ThemedText } from 'theme/components' -import { Chain, TokenStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { InterfaceSearchResultSelectionProperties } from 'uniswap/src/features/telemetry/types' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { NumberType, useFormatter } from 'utils/formatNumbers' - -const PriceChangeContainer = styled.div` - display: flex; - align-items: center; - padding-top: 4px; - gap: 2px; -` -const SuggestionRowStyles = css<{ $isFocused: boolean }>` - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - text-decoration: none; - padding: 8px 16px; - :hover { - background: ${({ theme }) => theme.surface2}; - } - ${({ $isFocused, theme }) => - $isFocused && - ` - background: ${theme.surface2}; -`} -` -const StyledLink = styled(Link)` - ${SuggestionRowStyles} -` -const SkeletonSuggestionRow = styled.div` - ${SuggestionRowStyles} -` -const PrimaryContainer = styled(Column)` - align-items: flex-start; - width: 90%; -` -const CollectionImageStyles = css` - width: 36px; - height: 36px; - border-radius: 9999px; - background: ${({ theme }) => theme.surface3}; - flex-shrink: 0; -` -const CollectionImage = styled.img` - ${CollectionImageStyles} -` -const BrokenCollectionImage = styled.div` - ${CollectionImageStyles} -` -const PrimaryText = styled(ThemedText.SubHeader)` - ${EllipsisStyle} -` -const SecondaryContainer = styled(Column)` - text-align: right; - align-items: flex-end; -` - -interface SuggestionRowProps { - suggestion: GenieCollection | SearchToken - isHovered: boolean - setHoveredIndex: (index: number | undefined) => void - toggleOpen: () => void - index: number - eventProperties: InterfaceSearchResultSelectionProperties -} - -function suggestionIsToken(suggestion: GenieCollection | SearchToken): suggestion is SearchToken { - return (suggestion as SearchToken).decimals !== undefined -} - -export const SuggestionRow = ({ - suggestion, - isHovered, - setHoveredIndex, - toggleOpen, - index, - eventProperties, -}: SuggestionRowProps) => { - const isToken = suggestionIsToken(suggestion) - const addRecentlySearchedAsset = useAddRecentlySearchedAsset() - const navigate = useNavigate() - const { formatFiatPrice, formatDelta, formatNumberOrString } = useFormatter() - const [brokenCollectionImage, setBrokenCollectionImage] = useState(false) - const warning = useTokenWarning( - isToken ? suggestion.address : undefined, - isToken ? supportedChainIdFromGQLChain(suggestion.chain) : UniverseChainId.Mainnet, - ) - - const handleClick = useCallback(() => { - const address = - !suggestion.address && suggestion.standard === TokenStandard.Native ? NATIVE_CHAIN_ID : suggestion.address - const asset = isToken - ? address && { address, chain: suggestion.chain } - : { ...suggestion, isNft: true, chain: Chain.Ethereum } - asset && addRecentlySearchedAsset(asset) - - toggleOpen() - sendAnalyticsEvent(InterfaceEventName.NAVBAR_RESULT_SELECTED, { ...eventProperties }) - }, [suggestion, isToken, addRecentlySearchedAsset, toggleOpen, eventProperties]) - - const path = isToken ? getTokenDetailsURL({ ...suggestion }) : `/nfts/collection/${suggestion.address}` - // Close the modal on escape - useEffect(() => { - const keyDownHandler = (event: KeyboardEvent) => { - if (event.key === 'Enter' && isHovered) { - event.preventDefault() - navigate(path) - handleClick() - } - } - document.addEventListener('keydown', keyDownHandler) - return () => { - document.removeEventListener('keydown', keyDownHandler) - } - }, [toggleOpen, isHovered, suggestion, navigate, handleClick, path]) - - return ( - !isHovered && setHoveredIndex(index)} - onMouseLeave={() => isHovered && setHoveredIndex(undefined)} - data-testid={isToken ? `searchbar-token-row-${suggestion.chain}-${suggestion.address ?? NATIVE_CHAIN_ID}` : ''} - > - - {isToken ? ( - - ) : brokenCollectionImage ? ( - - ) : ( - setBrokenCollectionImage(true)} - /> - )} - - - {suggestion.name} - {isToken ? : suggestion.isVerified && } - - - {isToken ? ( - suggestion.symbol - ) : ( - <> - {formatNumberOrString({ input: suggestion?.stats?.total_supply, type: NumberType.WholeNumber })}  - - - )} - - - - - - - - {isToken - ? formatFiatPrice({ price: suggestion.market?.price?.value }) - : `${formatNumberOrString({ input: suggestion.stats?.floor_price, type: NumberType.NFTToken })} ETH`} - - - - - {isToken ? ( - <> - - - - {formatDelta(Math.abs(suggestion.market?.pricePercentChange?.value ?? 0))} - - - - ) : ( - - - - )} - - - - ) -} - -const SkeletonContent = styled(Column)` - width: 100%; -` - -export const SkeletonRow = () => { - return ( - - - - - - - - - - - - - - - - - ) -} diff --git a/apps/web/src/components/NavBar/LEGACY/index.tsx b/apps/web/src/components/NavBar/LEGACY/index.tsx deleted file mode 100644 index 83c56f2d009..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' -import { UniIcon } from 'components/Logo/UniIcon' -import { Bag } from 'components/NavBar/Bag' -import { ChainSelector } from 'components/NavBar/ChainSelector' -import { GetTheAppButton } from 'components/NavBar/DownloadApp/GetTheAppButton' -import Blur from 'components/NavBar/LEGACY/Blur' -import { More } from 'components/NavBar/LEGACY/Menu' -import { SearchBar } from 'components/NavBar/LEGACY/SearchBar/SearchBar' -import * as styles from 'components/NavBar/LEGACY/style.css' -import Web3Status from 'components/Web3Status' -import { chainIdToBackendChain } from 'constants/chains' -import { useAccount } from 'hooks/useAccount' -import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' -import { useIsLandingPage } from 'hooks/useIsLandingPage' -import { useIsLimitPage } from 'hooks/useIsLimitPage' -import { useIsNftPage } from 'hooks/useIsNftPage' -import { useIsPoolsPage } from 'hooks/useIsPoolsPage' -import { useIsSendPage } from 'hooks/useIsSendPage' -import { useIsSwapPage } from 'hooks/useIsSwapPage' -import { Trans } from 'i18n' -import styled from 'lib/styled-components' -import { Box } from 'nft/components/Box' -import { Row } from 'nft/components/Flex' -import { useProfilePageState } from 'nft/hooks' -import { useIsNavSearchInputVisible } from 'nft/hooks/useIsNavSearchInputVisible' -import { ProfilePageStateType } from 'nft/types' -import { ReactNode, useCallback } from 'react' -import { NavLink, NavLinkProps, useLocation, useNavigate } from 'react-router-dom' -import { Z_INDEX } from 'theme/zIndex' -import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' - -const Nav = styled.nav` - position: relative; - padding: ${({ theme }) => `${theme.navVerticalPad}px 12px`}; - width: 100%; - height: ${({ theme }) => theme.navHeight}px; - z-index: ${Z_INDEX.sticky}; -` - -interface MenuItemProps { - href: string - id?: NavLinkProps['id'] - isActive?: boolean - children: ReactNode - dataTestId?: string -} - -const MenuItem = ({ href, dataTestId, id, isActive, children }: MenuItemProps) => { - return ( - - {children} - - ) -} - -export const PageTabs = () => { - const { pathname } = useLocation() - const account = useAccount() - const chainName = chainIdToBackendChain({ chainId: account.chainId, withFallback: true }) - - const isPoolActive = useIsPoolsPage() - const isNftPage = useIsNftPage() - - const shouldDisableNFTRoutes = useDisableNFTRoutes() - - return ( - <> - - - - - - - {!shouldDisableNFTRoutes && ( - - - - )} - - - - - - - - ) -} - -const LegacyNavbar = ({ blur }: { blur: boolean }) => { - const isNftPage = useIsNftPage() - const isSwapPage = useIsSwapPage() - const isSendPage = useIsSendPage() - const isLimitPage = useIsLimitPage() - const isLandingPage = useIsLandingPage() - const sellPageState = useProfilePageState((state) => state.state) - const navigate = useNavigate() - const isNavSearchInputVisible = useIsNavSearchInputVisible() - const multichainUXEnabled = useFeatureFlag(FeatureFlags.MultichainUX) - - const account = useAccount() - const accountDrawer = useAccountDrawer() - - const hideChainSelector = multichainUXEnabled ? isSendPage || isSwapPage || isLimitPage || isNftPage : isNftPage - - const handleUniIconClick = useCallback(() => { - if (account.isConnected) { - return - } - accountDrawer.close() - navigate({ - pathname: '/', - search: '?intro=true', - }) - }, [account.isConnected, accountDrawer, navigate]) - - return ( - <> - {blur && } - - - ) -} - -export default LegacyNavbar diff --git a/apps/web/src/components/NavBar/LEGACY/style.css.ts b/apps/web/src/components/NavBar/LEGACY/style.css.ts deleted file mode 100644 index aeeba92ba63..00000000000 --- a/apps/web/src/components/NavBar/LEGACY/style.css.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { style } from '@vanilla-extract/css' -import { subhead } from 'nft/css/common.css' -import { sprinkles, vars } from 'nft/css/sprinkles.css' - -export const logoContainer = style([ - sprinkles({ - display: 'flex', - marginRight: '12', - alignItems: 'center', - cursor: 'pointer', - }), -]) - -export const logo = style([ - sprinkles({ - display: 'block', - color: 'accent1', - }), -]) - -export const baseSideContainer = style([ - sprinkles({ - display: 'flex', - width: 'full', - flex: '1', - flexShrink: '2', - }), -]) - -export const leftSideContainer = style([ - baseSideContainer, - sprinkles({ - alignItems: 'center', - justifyContent: 'flex-start', - }), -]) - -export const searchContainer = style([ - sprinkles({ - flex: '1', - flexShrink: '1', - justifyContent: { lg: 'flex-end', xl: 'center' }, - display: { sm: 'none' }, - alignSelf: 'center', - - alignItems: 'flex-start', - }), - { height: '42px' }, -]) - -export const rightSideContainer = style([ - baseSideContainer, - sprinkles({ - alignItems: 'center', - justifyContent: 'flex-end', - }), -]) - -const baseMenuItem = style([ - subhead, - sprinkles({ - paddingY: '8', - paddingX: { sm: '6', md: '14' }, - marginY: '4', - borderRadius: '14', - marginX: { sm: '4', md: '0' }, - transition: '250', - height: 'min', - width: 'full', - textAlign: 'center', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '4', - }), - { - lineHeight: '22px', - textDecoration: 'none', - ':hover': { - background: vars.color.lightGrayOverlay, - }, - }, -]) - -export const menuItem = style([ - baseMenuItem, - sprinkles({ - color: 'neutral2', - }), -]) - -export const activeMenuItem = style([ - baseMenuItem, - sprinkles({ - color: 'neutral1', - background: 'none', - }), -]) diff --git a/apps/web/src/components/NavBar/MobileBottomBar/MobileBottomBar.tsx b/apps/web/src/components/NavBar/MobileBottomBar/MobileBottomBar.tsx index 16ed9411bd0..06682f0c602 100644 --- a/apps/web/src/components/NavBar/MobileBottomBar/MobileBottomBar.tsx +++ b/apps/web/src/components/NavBar/MobileBottomBar/MobileBottomBar.tsx @@ -1,10 +1,10 @@ import { NAV_BREAKPOINT } from 'components/NavBar/ScreenSizes' -import styled, { css } from 'lib/styled-components' +import styled from 'lib/styled-components' import { Z_INDEX } from 'theme/zIndex' const MOBILE_BAR_MAX_HEIGHT = 100 // ensure that it's translated out of view on scroll -const MobileBottomBarBase = css` +export const MobileBottomBar = styled.div<{ $hide: boolean }>` z-index: ${Z_INDEX.dropdown}; position: fixed; display: flex; @@ -13,24 +13,6 @@ const MobileBottomBarBase = css` left: 0; justify-content: space-between; gap: 8px; - @media screen and (min-width: ${NAV_BREAKPOINT.showMobileBar}px) { - display: none; - } -` - -export const MobileBottomBarLegacy = styled.div` - ${MobileBottomBarBase} - width: calc(100vw - 16px); - height: 48px; - margin: 8px; - padding: 0px 4px; - background: ${({ theme }) => theme.surface1}; - border: 1px solid ${({ theme }) => theme.surface3}; - border-radius: 20px; -` - -export const MobileBottomBar = styled.div<{ $hide: boolean }>` - ${MobileBottomBarBase} width: 100%; max-height: ${MOBILE_BAR_MAX_HEIGHT}px; backdrop-filter: blur(4px); diff --git a/apps/web/src/components/NavBar/MobileBottomBar/TDPActionTabs.tsx b/apps/web/src/components/NavBar/MobileBottomBar/TDPActionTabs.tsx index 01f431489e6..0b2ad66deac 100644 --- a/apps/web/src/components/NavBar/MobileBottomBar/TDPActionTabs.tsx +++ b/apps/web/src/components/NavBar/MobileBottomBar/TDPActionTabs.tsx @@ -1,16 +1,16 @@ import { CreditCardIcon } from 'components/Icons/CreditCard' import { Sell } from 'components/Icons/Sell' import { Send } from 'components/Icons/Send' -import Row from 'components/Row' import { NATIVE_CHAIN_ID } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' import { useSwitchChain } from 'hooks/useSwitchChain' -import { t } from 'i18n' import styled from 'lib/styled-components' import { useTDPContext } from 'pages/TokenDetails/TDPContext' import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { ClickableStyle } from 'theme/components' +import { Flex } from 'ui/src' +import { t } from 'uniswap/src/i18n' const TDPActionPill = styled.button<{ $color?: string }>` display: flex; @@ -79,13 +79,13 @@ export function TDPActionTabs() { }, ] return ( - + {tabs.map((tab) => ( toActionLink(tab.href)} $color={tokenColor}> {tab.icon} {tab.label} ))} - + ) } diff --git a/apps/web/src/components/NavBar/MobileBottomBar/index.ts b/apps/web/src/components/NavBar/MobileBottomBar/index.ts index afc2981a6b7..6ba3590ae86 100644 --- a/apps/web/src/components/NavBar/MobileBottomBar/index.ts +++ b/apps/web/src/components/NavBar/MobileBottomBar/index.ts @@ -1,2 +1,2 @@ -export { MobileBottomBar, MobileBottomBarLegacy } from './MobileBottomBar' +export { MobileBottomBar } from './MobileBottomBar' export { TDPActionTabs } from './TDPActionTabs' diff --git a/apps/web/src/components/NavBar/NavBar.tsx b/apps/web/src/components/NavBar/NavBar.tsx deleted file mode 100644 index 7759532461a..00000000000 --- a/apps/web/src/components/NavBar/NavBar.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Bag } from 'components/NavBar/Bag' -import { ChainSelector } from 'components/NavBar/ChainSelector' -import { CompanyMenu } from 'components/NavBar/CompanyMenu' -import { GetTheAppButton } from 'components/NavBar/DownloadApp/GetTheAppButton' -import { PreferenceMenu } from 'components/NavBar/PreferencesMenu' -import { useTabsVisible } from 'components/NavBar/ScreenSizes' -import { SearchBar } from 'components/NavBar/SearchBar' -import { Tabs } from 'components/NavBar/Tabs/Tabs' -import Row from 'components/Row' -import Web3Status from 'components/Web3Status' -import { useScreenSize } from 'hooks/screenSize' -import { useAccount } from 'hooks/useAccount' -import { useIsLandingPage } from 'hooks/useIsLandingPage' -import { useIsLimitPage } from 'hooks/useIsLimitPage' -import { useIsNftPage } from 'hooks/useIsNftPage' -import { useIsSendPage } from 'hooks/useIsSendPage' -import { useIsSwapPage } from 'hooks/useIsSwapPage' -import styled, { css } from 'lib/styled-components' -import { useProfilePageState } from 'nft/hooks' -import { ProfilePageStateType } from 'nft/types' -import { BREAKPOINTS } from 'theme' -import { Z_INDEX } from 'theme/zIndex' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' - -const Nav = styled.nav` - padding: 0px 12px; - width: 100%; - height: ${({ theme }) => theme.navHeight}px; - z-index: ${Z_INDEX.sticky}; - display: flex; - align-items: center; - justify-content: center; -` -const NavContents = styled.div` - max-width: ${({ theme }) => `${theme.breakpoint.xxxl}px`}; - width: 100%; - display: flex; - flex-direction: row; - justify-content: center; - flex: 1 auto 1; -` -const NavItems = css` - gap: 12px; - @media screen and (max-width: ${BREAKPOINTS.sm}px) { - gap: 4px; - } -` -const Left = styled(Row)` - display: flex; - align-items: center; - wrap: nowrap; - ${NavItems} -` -const Right = styled(Row)` - justify-content: flex-end; - align-self: flex-end; - ${NavItems} -` -const SearchContainer = styled.div` - display: flex; - flex: 1; - flex-shrink: 1; - justify-content: center; - align-self: center; - align-items: flex-start; - height: 42px; -` - -export const RefreshedNavbar = () => { - const isNftPage = useIsNftPage() - const isLandingPage = useIsLandingPage() - const isSendPage = useIsSendPage() - const isSwapPage = useIsSwapPage() - const isLimitPage = useIsLimitPage() - - const sellPageState = useProfilePageState((state) => state.state) - const isSmallScreen = !useScreenSize()['sm'] - const areTabsVisible = useTabsVisible() - const collapseSearchBar = !useScreenSize()['lg'] - const account = useAccount() - const NAV_SEARCH_MAX_HEIGHT = 'calc(100vh - 30px)' - - const multichainUXEnabled = useFeatureFlag(FeatureFlags.MultichainUX) - const hideChainSelector = multichainUXEnabled ? isSendPage || isSwapPage || isLimitPage || isNftPage : isNftPage - - return ( - - ) -} diff --git a/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx b/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx index 5f0836fbf41..e6104cbc8fc 100644 --- a/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx +++ b/apps/web/src/components/NavBar/NavDropdown/NavDropdown.tsx @@ -1,10 +1,9 @@ import { RemoveScroll } from '@tamagui/remove-scroll' import { ScrollBarStyles } from 'components/Common' import { NAV_BREAKPOINT, useIsMobileDrawer } from 'components/NavBar/ScreenSizes' -import Row from 'components/Row' import styled from 'lib/styled-components' import { ReactNode, RefObject } from 'react' -import { Popover } from 'ui/src' +import { Flex, Popover } from 'ui/src' const NavDropdownContent = styled.div<{ $width?: number }>` border-radius: 16px; @@ -35,9 +34,10 @@ interface NavDropdownProps { isOpen: boolean width?: number dropdownRef?: RefObject + dataTestId?: string } -export function NavDropdown({ children, width, dropdownRef, isOpen }: NavDropdownProps) { +export function NavDropdown({ children, width, dropdownRef, isOpen, dataTestId }: NavDropdownProps) { const isMobileDrawer = useIsMobileDrawer() return ( @@ -56,9 +56,10 @@ export function NavDropdown({ children, width, dropdownRef, isOpen }: NavDropdow }, }, ]} + data-testid={dataTestId} > - + {children} @@ -75,9 +76,9 @@ export function NavDropdown({ children, width, dropdownRef, isOpen }: NavDropdow /> - + - + diff --git a/apps/web/src/components/NavBar/NavIcon.tsx b/apps/web/src/components/NavBar/NavIcon.tsx index 853d786c016..1bafee8dc7f 100644 --- a/apps/web/src/components/NavBar/NavIcon.tsx +++ b/apps/web/src/components/NavBar/NavIcon.tsx @@ -1,13 +1,11 @@ -import { t } from 'i18n' -import styled, { css } from 'lib/styled-components' +import styled from 'lib/styled-components' import { ReactNode } from 'react' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { t } from 'uniswap/src/i18n' -const ContainerBase = css<{ $size: number }>` +const Container = styled.div<{ $size: number; $isActive: boolean }>` + position: relative; width: ${({ $size }) => $size}px; height: ${({ $size }) => $size}px; - position: relative; display: flex; flex-direction: column; justify-content: center; @@ -18,15 +16,6 @@ const ContainerBase = css<{ $size: number }>` cursor: pointer; transition: all 250ms; z-index: 1; -` -const LegacyContainer = styled.div<{ $size: number; $isActive: boolean }>` - ${ContainerBase} - color: ${({ $isActive, theme }) => ($isActive ? theme.neutral1 : theme.neutral2)}; - border-radius: 16px; - transform: translateY(-1px) translateX(4px); -` -const Container = styled.div<{ $size: number; $isActive: boolean }>` - ${ContainerBase} background-color: ${({ $isActive, theme }) => ($isActive ? theme.surface1Hovered : 'transparent')}; color: ${({ theme }) => theme.neutral2}; border-radius: 50%; @@ -50,14 +39,9 @@ export const NavIcon = ({ label = t('common.navigationButton'), onClick, }: NavIconProps) => { - const isNavRefresh = useFeatureFlag(FeatureFlags.NavRefresh) - return isNavRefresh ? ( + return ( {children} - ) : ( - - {children} - ) } diff --git a/apps/web/src/components/NavBar/PreferencesMenu/Currency.tsx b/apps/web/src/components/NavBar/PreferencesMenu/Currency.tsx index 46e641d5f28..bf2278bea68 100644 --- a/apps/web/src/components/NavBar/PreferencesMenu/Currency.tsx +++ b/apps/web/src/components/NavBar/PreferencesMenu/Currency.tsx @@ -1,7 +1,7 @@ import { LocalCurrencyMenuItems } from 'components/AccountDrawer/LocalCurrencyMenu' import { PreferencesHeader } from 'components/NavBar/PreferencesMenu/Header' import { SettingsColumn } from 'components/NavBar/PreferencesMenu/shared' -import { Trans } from 'i18n' +import { Trans } from 'uniswap/src/i18n' export function CurrencySettings({ onExitMenu }: { onExitMenu: () => void }) { return ( diff --git a/apps/web/src/components/NavBar/PreferencesMenu/Language.tsx b/apps/web/src/components/NavBar/PreferencesMenu/Language.tsx index 4430c98a4a5..ddeb810375b 100644 --- a/apps/web/src/components/NavBar/PreferencesMenu/Language.tsx +++ b/apps/web/src/components/NavBar/PreferencesMenu/Language.tsx @@ -1,7 +1,7 @@ import { LanguageMenuItems } from 'components/AccountDrawer/LanguageMenu' import { PreferencesHeader } from 'components/NavBar/PreferencesMenu/Header' import { SettingsColumn } from 'components/NavBar/PreferencesMenu/shared' -import { Trans } from 'i18n' +import { Trans } from 'uniswap/src/i18n' export function LanguageSettings({ onExitMenu }: { onExitMenu: () => void }) { return ( diff --git a/apps/web/src/components/NavBar/PreferencesMenu/Preferences.tsx b/apps/web/src/components/NavBar/PreferencesMenu/Preferences.tsx index b8a8573c36e..9da6ba2eb74 100644 --- a/apps/web/src/components/NavBar/PreferencesMenu/Preferences.tsx +++ b/apps/web/src/components/NavBar/PreferencesMenu/Preferences.tsx @@ -3,11 +3,11 @@ import { PreferencesView } from 'components/NavBar/PreferencesMenu/shared' import { LOCALE_LABEL } from 'constants/locales' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useActiveLocale } from 'hooks/useActiveLocale' -import { Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ChevronRight } from 'react-feather' import { ThemeSelector } from 'theme/components/ThemeToggle' import { Text } from 'ui/src' +import { Trans, t } from 'uniswap/src/i18n' const Pref = styled.div` display: flex; diff --git a/apps/web/src/components/NavBar/PreferencesMenu/shared.tsx b/apps/web/src/components/NavBar/PreferencesMenu/shared.tsx index 14df50ec4f7..16bc4a3e1b1 100644 --- a/apps/web/src/components/NavBar/PreferencesMenu/shared.tsx +++ b/apps/web/src/components/NavBar/PreferencesMenu/shared.tsx @@ -1,5 +1,4 @@ -import Column from 'components/Column' -import styled from 'lib/styled-components' +import { Flex, styled } from 'ui/src' export enum PreferencesView { SETTINGS = 'Settings', @@ -7,7 +6,6 @@ export enum PreferencesView { CURRENCY = 'Currency', } -export const SettingsColumn = styled(Column)` - width: 100%; - overflow: auto; -` +export const SettingsColumn = styled(Flex, { + width: '100%', +}) diff --git a/apps/web/src/components/NavBar/SearchBar/RecentlySearchedAssets.ts b/apps/web/src/components/NavBar/SearchBar/RecentlySearchedAssets.ts index 4c3bd36a564..8a924564885 100644 --- a/apps/web/src/components/NavBar/SearchBar/RecentlySearchedAssets.ts +++ b/apps/web/src/components/NavBar/SearchBar/RecentlySearchedAssets.ts @@ -1,24 +1,23 @@ import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens' -import { SearchToken } from 'graphql/data/SearchTokens' +import { SearchToken, TokenSearchResultWeb } from 'graphql/data/SearchTokens' import { supportedChainIdFromGQLChain } from 'graphql/data/util' import { useAtom } from 'jotai' import { atomWithStorage, useAtomValue } from 'jotai/utils' import { GenieCollection } from 'nft/types' import { useCallback, useMemo } from 'react' +import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { Chain, NftCollection, useRecentlySearchedAssetsQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { toGraphQLChain } from 'uniswap/src/features/chains/utils' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { SearchResultType } from 'uniswap/src/features/search/SearchResult' +import { UniverseChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { getNativeTokenDBAddress } from 'utils/nativeTokens' -type RecentlySearchedAsset = { - isNft?: boolean - address: string - chain: Chain -} - // Temporary measure used until backend supports addressing by "NATIVE" const NATIVE_QUERY_ADDRESS_INPUT = null as unknown as string function getQueryAddress(chain: Chain) { @@ -28,18 +27,66 @@ function getNativeQueryAddress(chain: Chain) { return `NATIVE-${chain}` } -const recentlySearchedAssetsAtom = atomWithStorage('recentlySearchedAssets', []) +export const recentlySearchedAssetsAtom = atomWithStorage('recentlySearchedAssetsV2', []) + +// Used by TokenSelector +export function useAddRecentlySearchedCurrency() { + const [searchHistory, updateSearchHistory] = useAtom(recentlySearchedAssetsAtom) + + return useCallback( + (currencyInfo: CurrencyInfo) => { + // Removes the new currency if it was already in the array + const newHistory = searchHistory.filter((oldCurrency) => { + // Don't filter out NFTs of the same chainId when adding a native token to the search history + if (oldCurrency.isNft) { + return true + } + // Filter out tokens of the same address and chainId + if (currencyInfo.currency.isToken) { + return !( + oldCurrency.address === currencyInfo.currency.address && + oldCurrency.chainId === currencyInfo.currency.chainId + ) + // Filter out native tokens of the same chainId + } else { + return oldCurrency.chainId !== currencyInfo.currency.chainId + } + }) + newHistory.unshift({ + type: SearchResultType.Token, + chain: toGraphQLChain(currencyInfo.currency.chainId) ?? Chain.Ethereum, + chainId: currencyInfo.currency.chainId, + address: currencyInfo.currency.isToken + ? currencyInfo.currency.address + : UNIVERSE_CHAIN_INFO[currencyInfo.currency.chainId as UniverseChainId].nativeCurrency.address, + name: currencyInfo.currency.name ?? null, + symbol: currencyInfo.currency.symbol ?? '', + logoUrl: currencyInfo.logoUrl ?? null, + safetyLevel: currencyInfo.safetyLevel ?? null, + isToken: currencyInfo.currency.isToken, + isNative: currencyInfo.currency.isNative, + }) + updateSearchHistory(newHistory) + }, + [searchHistory, updateSearchHistory], + ) +} +// Used by NavBar export function useAddRecentlySearchedAsset() { const [searchHistory, updateSearchHistory] = useAtom(recentlySearchedAssetsAtom) return useCallback( - (asset: RecentlySearchedAsset) => { + (asset: TokenSearchResultWeb) => { // Removes the new asset if it was already in the array + const address = asset.isNative ? UNIVERSE_CHAIN_INFO[asset.chainId].nativeCurrency.address : asset.address const newHistory = searchHistory.filter( - (oldAsset) => !(oldAsset.address === asset.address && oldAsset.chain === asset.chain), + (oldAsset) => !(oldAsset.address === address && oldAsset.chain === asset.chain), ) - newHistory.unshift(asset) + newHistory.unshift({ + ...asset, + address, + }) updateSearchHistory(newHistory) }, [searchHistory, updateSearchHistory], @@ -56,7 +103,7 @@ export function useRecentlySearchedAssets() { contracts: shortenedHistory .filter((asset) => !asset.isNft) .map((token) => ({ - address: token.address === NATIVE_CHAIN_ID ? getQueryAddress(token.chain) : token.address, + address: token.isNative ? getQueryAddress(token.chain) : token.address, chain: token.chain, })), }, @@ -96,7 +143,7 @@ export function useRecentlySearchedAssets() { const data: (SearchToken | GenieCollection)[] = [] shortenedHistory.forEach((asset) => { - if (asset.address === NATIVE_CHAIN_ID) { + if (asset.isNative) { // Handles special case where wMATIC data needs to be used for MATIC const chain = supportedChainIdFromGQLChain(asset.chain) if (!chain) { diff --git a/apps/web/src/components/NavBar/SearchBar/SearchBar.test.tsx b/apps/web/src/components/NavBar/SearchBar/SearchBar.test.tsx index e4717fa9b0e..1f5f5a537ee 100644 --- a/apps/web/src/components/NavBar/SearchBar/SearchBar.test.tsx +++ b/apps/web/src/components/NavBar/SearchBar/SearchBar.test.tsx @@ -1,10 +1,10 @@ import { SearchBar } from 'components/NavBar/SearchBar' import { useScreenSize } from 'hooks/screenSize/useScreenSize' import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' -import { useTranslation } from 'i18n/useTranslation' import { useIsNavSearchInputVisible } from 'nft/hooks/useIsNavSearchInputVisible' import { mocked } from 'test-utils/mocked' import { render, screen } from 'test-utils/render' +import { useTranslation } from 'uniswap/src/i18n' jest.mock('hooks/useDisableNFTRoutes') jest.mock('hooks/screenSize/useScreenSize') @@ -38,6 +38,6 @@ describe('disable nft on searchbar', () => { const { container } = render() expect(container).toMatchSnapshot() const { t } = useTranslation() - expect(screen.queryByPlaceholderText(t('common.searchTokens'))).toBeVisible() + expect(screen.queryByPlaceholderText(t('tokens.selector.search.placeholder'))).toBeVisible() }) }) diff --git a/apps/web/src/components/NavBar/SearchBar/SearchBarDropdown.tsx b/apps/web/src/components/NavBar/SearchBar/SearchBarDropdown.tsx index 3d1fa3e87cf..d06b1162adc 100644 --- a/apps/web/src/components/NavBar/SearchBar/SearchBarDropdown.tsx +++ b/apps/web/src/components/NavBar/SearchBar/SearchBarDropdown.tsx @@ -14,15 +14,16 @@ import { useTrendingCollections } from 'graphql/data/nft/TrendingCollections' import { useAccount } from 'hooks/useAccount' import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' import { useIsNftPage } from 'hooks/useIsNftPage' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { GenieCollection } from 'nft/types' import { useEffect, useMemo, useState } from 'react' import { Clock, TrendingUp } from 'react-feather' import { useLocation } from 'react-router-dom' import { ThemedText } from 'theme/components' +import { Flex } from 'ui/src' import { HistoryDuration, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { InterfaceSearchResultSelectionProperties } from 'uniswap/src/features/telemetry/types' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' @@ -72,12 +73,12 @@ function SearchBarDropdownSection({ eventProperties, }: SearchBarDropdownSectionProps) { return ( - + {headerIcon ? headerIcon : null} {header} - + {suggestions.map((suggestion, index) => isLoading || !suggestion ? ( @@ -98,8 +99,8 @@ function SearchBarDropdownSection({ /> ), )} - - + + ) } @@ -164,7 +165,7 @@ function SearchBarDropdownContents({ }: SearchBarDropdownProps): JSX.Element { const [hoveredIndex, setHoveredIndex] = useState(0) const { data: searchHistory } = useRecentlySearchedAssets() - const shortenedHistory = useMemo(() => searchHistory?.slice(0, 2) ?? [...Array(2)], [searchHistory]) + const shortenedHistory = useMemo(() => searchHistory ?? [...Array(2)], [searchHistory]) const { pathname } = useLocation() const isNFTPage = useIsNftPage() const isTokenPage = pathname.includes('/explore') @@ -288,7 +289,7 @@ function SearchBarDropdownContents({ return hasInput ? ( // Empty or Up to 8 combined tokens and nfts - + {showCollectionsFirst ? ( <> {collectionSearchResults} @@ -300,10 +301,10 @@ function SearchBarDropdownContents({ {collectionSearchResults} )} - + ) : ( // Recent Searches, Trending Tokens, Trending Collections - + {shortenedHistory.length > 0 && ( } + header={} headerIcon={} isLoading={!searchHistory} /> @@ -331,7 +332,7 @@ function SearchBarDropdownContents({ suggestion_type: NavBarSearchTypes.TOKEN_TRENDING, ...eventProperties, }} - header={} + header={} headerIcon={} isLoading={!trendingTokenData} /> @@ -352,7 +353,7 @@ function SearchBarDropdownContents({ isLoading={trendingCollectionsAreLoading} /> )} - + ) } diff --git a/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx b/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx index 50bff51c4aa..3fe16a2c8e9 100644 --- a/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx +++ b/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx @@ -2,7 +2,6 @@ import { InterfaceEventName } from '@uniswap/analytics-events' import Column from 'components/Column' import QueryTokenLogo from 'components/Logo/QueryTokenLogo' import { useAddRecentlySearchedAsset } from 'components/NavBar/SearchBar/RecentlySearchedAssets' -import Row from 'components/Row' import TokenSafetyIcon from 'components/TokenSafety/TokenSafetyIcon' import { DeltaArrow, DeltaText } from 'components/Tokens/TokenDetails/Delta' import { LoadingBubble } from 'components/Tokens/loading' @@ -10,16 +9,18 @@ import { useTokenWarning } from 'constants/tokenSafety' import { NATIVE_CHAIN_ID } from 'constants/tokens' import { SearchToken } from 'graphql/data/SearchTokens' import { getTokenDetailsURL, supportedChainIdFromGQLChain } from 'graphql/data/util' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' +import { searchGenieCollectionToTokenSearchResult, searchTokenToTokenSearchResult } from 'lib/utils/searchBar' import { GenieCollection } from 'nft/types' import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { EllipsisStyle, ThemedText } from 'theme/components' +import { Flex } from 'ui/src' import { Verified } from 'ui/src/components/icons' -import { Chain, TokenStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { TokenStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { InterfaceSearchResultSelectionProperties } from 'uniswap/src/features/telemetry/types' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -99,6 +100,7 @@ export function SuggestionRow({ index, eventProperties, }: SuggestionRowProps) { + const { t } = useTranslation() const isToken = suggestionIsToken(suggestion) const addRecentlySearchedAsset = useAddRecentlySearchedAsset() const navigate = useNavigate() @@ -112,9 +114,16 @@ export function SuggestionRow({ const handleClick = useCallback(() => { const address = !suggestion.address && suggestion.standard === TokenStandard.Native ? NATIVE_CHAIN_ID : suggestion.address - const asset = isToken - ? address && { address, chain: suggestion.chain } - : { ...suggestion, isNft: true, chain: Chain.Ethereum } + const asset = + isToken && address + ? searchTokenToTokenSearchResult({ + ...suggestion, + address, + chainId: supportedChainIdFromGQLChain(suggestion.chain) as UniverseChainId, + isNative: suggestion.address === NATIVE_CHAIN_ID, + isToken: suggestion.standard === TokenStandard.Erc20, + }) + : searchGenieCollectionToTokenSearchResult(suggestion as GenieCollection) asset && addRecentlySearchedAsset(asset) toggleOpen() @@ -146,7 +155,7 @@ export function SuggestionRow({ onMouseLeave={() => isHovered && setHoveredIndex(undefined)} data-testid={isToken ? `searchbar-token-row-${suggestion.chain}-${suggestion.address ?? NATIVE_CHAIN_ID}` : ''} > - + {isToken ? ( )} - + {suggestion.name} {isToken ? : suggestion.isVerified && } - + - {isToken ? ( - suggestion.symbol - ) : ( - <> - {formatNumberOrString({ input: suggestion?.stats?.total_supply, type: NumberType.WholeNumber })}  - - - )} + {isToken + ? suggestion.symbol + : t('search.results.count', { + count: suggestion?.stats?.total_supply ?? 0, + })} - + - + {isToken ? formatFiatPrice({ price: suggestion.market?.price?.value }) : `${formatNumberOrString({ input: suggestion.stats?.floor_price, type: NumberType.NFTToken })} ETH`} - + {isToken ? ( @@ -218,20 +224,20 @@ const SkeletonContent = styled(Column)` export function SkeletonRow() { return ( - + - + - + - + - + - + ) } diff --git a/apps/web/src/components/NavBar/SearchBar/__snapshots__/SearchBar.test.tsx.snap b/apps/web/src/components/NavBar/SearchBar/__snapshots__/SearchBar.test.tsx.snap index ac65aadc55a..1718e4c4cfb 100644 --- a/apps/web/src/components/NavBar/SearchBar/__snapshots__/SearchBar.test.tsx.snap +++ b/apps/web/src/components/NavBar/SearchBar/__snapshots__/SearchBar.test.tsx.snap @@ -109,6 +109,7 @@ exports[`disable nft on searchbar should render text with nfts 1`] = ` >
@@ -339,41 +260,41 @@ exports[`disable nft on searchbar dropdown should not render popular nft collect
@@ -382,41 +303,41 @@ exports[`disable nft on searchbar dropdown should not render popular nft collect
@@ -448,52 +369,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections justify-content: flex-start; } -.c2 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 20; -} - -.c3 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 4px; -} - .c7 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 4; -} - -.c11 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -508,13 +384,13 @@ exports[`disable nft on searchbar dropdown should render popular nft collections gap: 8px; } -.c4 { +.c2 { box-sizing: border-box; margin: 0; min-width: 0; } -.c5 { +.c3 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -533,40 +409,6 @@ exports[`disable nft on searchbar dropdown should render popular nft collections } .c9 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c13 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c14 { border-radius: 12px; border-radius: 12px; height: 20px; @@ -581,7 +423,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections background-size: 400%; } -.c15 { +.c10 { border-radius: 12px; border-radius: 12px; height: 20px; @@ -596,7 +438,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections background-size: 400%; } -.c16 { +.c11 { border-radius: 12px; border-radius: 12px; height: 16px; @@ -611,7 +453,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections background-size: 400%; } -.c17 { +.c12 { border-radius: 12px; border-radius: 12px; height: 16px; @@ -626,7 +468,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections background-size: 400%; } -.c8 { +.c5 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -649,11 +491,11 @@ exports[`disable nft on searchbar dropdown should render popular nft collections transition: background 250ms ease; } -.c8:hover { +.c5:hover { background: #22222212; } -.c10 { +.c6 { width: 36px; height: 36px; border-radius: 9999px; @@ -663,7 +505,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections flex-shrink: 0; } -.c12 { +.c8 { width: 100%; } @@ -677,7 +519,7 @@ exports[`disable nft on searchbar dropdown should render popular nft collections opacity: 1; } -.c6 { +.c4 { color: #7D7D7D; line-height: 20px; padding: 4px 16px; @@ -696,14 +538,14 @@ exports[`disable nft on searchbar dropdown should render popular nft collections class="c0 c1" >
@@ -772,41 +614,41 @@ exports[`disable nft on searchbar dropdown should render popular nft collections
@@ -815,41 +657,41 @@ exports[`disable nft on searchbar dropdown should render popular nft collections
@@ -860,11 +702,11 @@ exports[`disable nft on searchbar dropdown should render popular nft collections
@@ -933,41 +775,41 @@ exports[`disable nft on searchbar dropdown should render popular nft collections
diff --git a/apps/web/src/components/NavBar/SearchBar/index.tsx b/apps/web/src/components/NavBar/SearchBar/index.tsx index e7531f4a26a..18b97a82095 100644 --- a/apps/web/src/components/NavBar/SearchBar/index.tsx +++ b/apps/web/src/components/NavBar/SearchBar/index.tsx @@ -13,7 +13,6 @@ import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' import { useIsNftPage } from 'hooks/useIsNftPage' import { KeyAction, useKeyPress } from 'hooks/useKeyPress' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { useTranslation } from 'i18n/useTranslation' import styled, { css, useTheme } from 'lib/styled-components' import { organizeSearchResults } from 'lib/utils/searchBar' import { useCallback, useEffect, useRef, useState } from 'react' @@ -24,6 +23,7 @@ import { Z_INDEX } from 'theme/zIndex' import { Input } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useTranslation } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' const NAV_SEARCH_MAX_WIDTH = '400px' @@ -227,11 +227,11 @@ export const SearchBar = ({ ...trace, } - const placeholderText = shouldDisableNFTRoutes ? t('common.searchTokens') : t('common.searchTokensNFT') + const placeholderText = shouldDisableNFTRoutes ? t('tokens.selector.search.placeholder') : t('common.searchTokensNFT') return ( - + {(!!isNavSearchInputVisible || isOpen) && ( - + {!isNavSearchInputVisible && ( - + diff --git a/apps/web/src/components/NavBar/Tabs/QuickKey.test.tsx b/apps/web/src/components/NavBar/Tabs/QuickKey.test.tsx index b84c47df354..2019019e49e 100644 --- a/apps/web/src/components/NavBar/Tabs/QuickKey.test.tsx +++ b/apps/web/src/components/NavBar/Tabs/QuickKey.test.tsx @@ -1,4 +1,4 @@ -import sourceTranslations from 'i18n/locales/source/en-US.json' +import sourceTranslations from 'uniswap/src/i18n/locales/web-source/en-US.json' describe('navigation quick keys', () => { const keys = Object.keys(sourceTranslations).filter((key) => key.startsWith('quickKey')) diff --git a/apps/web/src/components/NavBar/Tabs/Tabs.tsx b/apps/web/src/components/NavBar/Tabs/Tabs.tsx index 36320904875..de11b6b4690 100644 --- a/apps/web/src/components/NavBar/Tabs/Tabs.tsx +++ b/apps/web/src/components/NavBar/Tabs/Tabs.tsx @@ -140,8 +140,8 @@ const Tab = ({ return ( - {Label} - + {Label} + {items.map((item, index) => ( { +export const useTabsContent = (props?: { includeNftsLink?: boolean }): TabsSection[] => { const { t } = useTranslation() - const isLegacyNav = !useFeatureFlag(FeatureFlags.NavRefresh) - const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregatorWeb) + const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregator) const { pathname } = useLocation() const theme = useTheme() const areTabsVisible = useTabsVisible() - return isLegacyNav - ? [ + return [ + { + title: t('common.trade'), + href: '/swap', + isActive: pathname.startsWith('/swap') || pathname.startsWith('/limit') || pathname.startsWith('/send'), + items: [ { - title: t('common.swap'), + label: t('common.swap'), + icon: , + quickKey: 'U', href: '/swap', + internal: true, }, { - title: t('common.explore'), - href: '/explore', + label: t('swap.limit'), + icon: , + quickKey: 'L', + href: '/limit', + internal: true, }, { - title: t('common.nfts'), - href: '/nfts', + label: t('common.send.button'), + icon: , + quickKey: 'E', + href: '/send', + internal: true, }, - ] - : [ - { - title: t('common.trade'), - href: '/swap', - isActive: pathname.startsWith('/swap') || pathname.startsWith('/limit') || pathname.startsWith('/send'), - items: [ - { - label: t('common.swap'), - icon: , - quickKey: t(`quickKey.swap`), - href: '/swap', - internal: true, - }, - { - label: t('swap.limit'), - icon: , - quickKey: t(`quickKey.limit`), - href: '/limit', - internal: true, - }, - { - label: t('common.send.button'), - icon: , - quickKey: t(`quickKey.send`), - href: '/send', - internal: true, - }, - ...(forAggregatorEnabled - ? [ - { - label: t('common.buy.label'), - icon: , - quickKey: t(`quickKey.buy`), - href: '/buy', - internal: true, - }, - ] - : []), - ], - }, - { - title: t('common.explore'), - href: '/explore', - isActive: pathname.startsWith('/explore') || pathname.startsWith('/nfts'), - items: [ - { label: t('common.tokens'), quickKey: t(`quickKey.tokens`), href: '/explore/tokens', internal: true }, - { label: t('common.pools'), quickKey: t(`quickKey.pools`), href: '/explore/pools', internal: true }, - { - label: t('common.transactions'), - quickKey: t(`quickKey.transactions`), - href: '/explore/transactions', - internal: true, - }, - { label: t('common.nfts'), quickKey: t(`quickKey.nfts`), href: '/nfts', internal: true }, - ], - }, - { - title: t('common.pool'), - href: '/pool', - isActive: pathname.startsWith('/pool'), - }, - ...(!areTabsVisible + ...(forAggregatorEnabled ? [ { - title: t('common.nfts'), - href: '/nfts', + label: t('common.buy.label'), + icon: , + quickKey: 'B', + href: '/buy', + internal: true, }, ] : []), - ] + ], + }, + { + title: t('common.explore'), + href: '/explore', + isActive: pathname.startsWith('/explore') || pathname.startsWith('/nfts'), + items: [ + { label: t('common.tokens'), quickKey: 'T', href: '/explore/tokens', internal: true }, + { label: t('common.pools'), quickKey: 'P', href: '/explore/pools', internal: true }, + { + label: t('common.transactions'), + quickKey: 'X', + href: '/explore/transactions', + internal: true, + }, + { label: t('common.nfts'), quickKey: 'N', href: '/nfts', internal: true }, + ], + }, + { + title: t('common.pool'), + href: '/pool', + isActive: pathname.startsWith('/pool'), + items: [ + { label: t('nav.tabs.viewPosition'), quickKey: 'V', href: '/pool', internal: true }, + { + label: t('nav.tabs.createPosition'), + quickKey: 'V', + href: '/add', + internal: true, + }, + ], + }, + ...(!areTabsVisible || props?.includeNftsLink + ? [ + { + title: t('common.nfts'), + href: '/nfts', + }, + ] + : []), + ] } diff --git a/apps/web/src/components/NavBar/index.tsx b/apps/web/src/components/NavBar/index.tsx index 70ed8d5a9a5..bd46aaa1591 100644 --- a/apps/web/src/components/NavBar/index.tsx +++ b/apps/web/src/components/NavBar/index.tsx @@ -1,10 +1,114 @@ -import LegacyNavbar from 'components/NavBar/LEGACY/index' -import { RefreshedNavbar } from 'components/NavBar/NavBar' +import { Bag } from 'components/NavBar/Bag' +import { ChainSelector } from 'components/NavBar/ChainSelector' +import { CompanyMenu } from 'components/NavBar/CompanyMenu' +import { GetTheAppButton } from 'components/NavBar/DownloadApp/GetTheAppButton' +import { PreferenceMenu } from 'components/NavBar/PreferencesMenu' +import { useTabsVisible } from 'components/NavBar/ScreenSizes' +import { SearchBar } from 'components/NavBar/SearchBar' +import { Tabs } from 'components/NavBar/Tabs/Tabs' +import Row from 'components/Row' +import Web3Status from 'components/Web3Status' +import { useScreenSize } from 'hooks/screenSize' +import { useAccount } from 'hooks/useAccount' +import { useIsLandingPage } from 'hooks/useIsLandingPage' +import { useIsLimitPage } from 'hooks/useIsLimitPage' +import { useIsNftPage } from 'hooks/useIsNftPage' +import { useIsSendPage } from 'hooks/useIsSendPage' +import { useIsSwapPage } from 'hooks/useIsSwapPage' +import styled, { css } from 'lib/styled-components' +import { useProfilePageState } from 'nft/hooks' +import { ProfilePageStateType } from 'nft/types' +import { BREAKPOINTS } from 'theme' +import { Z_INDEX } from 'theme/zIndex' import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' -const Navbar = ({ blur }: { blur: boolean }) => { - return useFeatureFlag(FeatureFlags.NavRefresh) ? : -} +const Nav = styled.nav` + padding: 0px 12px; + width: 100%; + height: ${({ theme }) => theme.navHeight}px; + z-index: ${Z_INDEX.sticky}; + display: flex; + align-items: center; + justify-content: center; +` +const NavContents = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex: 1 auto 1; +` +const NavItems = css` + gap: 12px; + @media screen and (max-width: ${BREAKPOINTS.sm}px) { + gap: 4px; + } +` +const Left = styled(Row)` + display: flex; + align-items: center; + wrap: nowrap; + ${NavItems} +` +const Right = styled(Row)` + justify-content: flex-end; + ${NavItems} +` +const SearchContainer = styled.div` + display: flex; + flex: 1; + flex-shrink: 1; + justify-content: center; + align-self: center; + align-items: flex-start; + height: 42px; +` + +export default function Navbar() { + const isNftPage = useIsNftPage() + const isLandingPage = useIsLandingPage() + const isSendPage = useIsSendPage() + const isSwapPage = useIsSwapPage() + const isLimitPage = useIsLimitPage() + + const sellPageState = useProfilePageState((state) => state.state) + const isSmallScreen = !useScreenSize()['sm'] + const areTabsVisible = useTabsVisible() + const collapseSearchBar = !useScreenSize()['lg'] + const account = useAccount() + const NAV_SEARCH_MAX_HEIGHT = 'calc(100vh - 30px)' + + const { value: multichainFlagEnabled, isLoading: isMultichainFlagLoading } = useFeatureFlagWithLoading( + FeatureFlags.MultichainUX, + ) + const hideChainSelector = + multichainFlagEnabled || isMultichainFlagLoading + ? isLandingPage || isSendPage || isSwapPage || isLimitPage || isNftPage + : isNftPage -export default Navbar + return ( + + ) +} diff --git a/apps/web/src/components/NavigationTabs/index.tsx b/apps/web/src/components/NavigationTabs/index.tsx index 66571ad6540..7ab71db8461 100644 --- a/apps/web/src/components/NavigationTabs/index.tsx +++ b/apps/web/src/components/NavigationTabs/index.tsx @@ -2,7 +2,6 @@ import { Percent } from '@uniswap/sdk-core' import { RowBetween } from 'components/Row' import SettingsTab from 'components/Settings' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ReactNode } from 'react' import { ArrowLeft } from 'react-feather' @@ -13,6 +12,7 @@ import { resetMintState } from 'state/mint/actions' import { resetMintState as resetMintV3State } from 'state/mint/v3/actions' import { ThemedText } from 'theme/components' import { flexRowNoWrap } from 'theme/styles' +import { Trans } from 'uniswap/src/i18n' const Tabs = styled.div` ${flexRowNoWrap}; diff --git a/apps/web/src/components/NetworkAlert/NetworkAlert.tsx b/apps/web/src/components/NetworkAlert/NetworkAlert.tsx index 2361f42d32c..e13412f3610 100644 --- a/apps/web/src/components/NetworkAlert/NetworkAlert.tsx +++ b/apps/web/src/components/NetworkAlert/NetworkAlert.tsx @@ -4,12 +4,12 @@ import { RowBetween } from 'components/Row' import { getChain, useIsSupportedChainId } from 'constants/chains' import { useIsSendPage } from 'hooks/useIsSendPage' import { useIsSwapPage } from 'hooks/useIsSwapPage' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ArrowUpRight } from 'react-feather' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { ExternalLink, HideSmall, ThemedText } from 'theme/components' import { useIsDarkMode } from 'theme/components/ThemeToggle' +import { Trans } from 'uniswap/src/i18n' const BridgeLink = styled(ExternalLink)<{ bgColor: string }>` color: ${({ color }) => color}; diff --git a/apps/web/src/components/NumericalInput/index.tsx b/apps/web/src/components/NumericalInput/index.tsx index e13685d2bcf..273ed145a7d 100644 --- a/apps/web/src/components/NumericalInput/index.tsx +++ b/apps/web/src/components/NumericalInput/index.tsx @@ -58,6 +58,7 @@ export interface InputProps extends Omit, 'ref align?: 'right' | 'left' prependSymbol?: string maxDecimals?: number + testId?: string } export function isInputGreaterThanDecimals(value: string, maxDecimals?: number): boolean { @@ -66,7 +67,7 @@ export function isInputGreaterThanDecimals(value: string, maxDecimals?: number): } const Input = forwardRef( - ({ value, onUserInput, placeholder, prependSymbol, maxDecimals, ...rest }: InputProps, ref) => { + ({ value, onUserInput, placeholder, prependSymbol, maxDecimals, testId, ...rest }: InputProps, ref) => { const { formatterLocale } = useFormatterLocales() const enforcer = (nextUserInput: string) => { @@ -91,6 +92,7 @@ const Input = forwardRef( {...rest} ref={ref} value={prependSymbol && value ? prependSymbol + valueFormattedWithLocale : valueFormattedWithLocale} + data-testid={testId} onChange={(event) => { if (prependSymbol) { const value = event.target.value diff --git a/apps/web/src/components/Polling/index.tsx b/apps/web/src/components/Polling/index.tsx deleted file mode 100644 index 9b6c3c3969f..00000000000 --- a/apps/web/src/components/Polling/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { styled as tamaguiStyled } from '@tamagui/core' -import { ChainConnectivityWarning } from 'components/Polling/ChainConnectivityWarning' -import { RowFixed } from 'components/Row' -import { MouseoverTooltip } from 'components/Tooltip' -import { AVERAGE_L1_BLOCK_TIME, useIsSupportedChainId } from 'constants/chains' -import { useAccount } from 'hooks/useAccount' -import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' -import { useIsLandingPage } from 'hooks/useIsLandingPage' -import { useIsNftPage } from 'hooks/useIsNftPage' -import useMachineTimeMs from 'hooks/useMachineTime' -import { Trans } from 'i18n' -import useBlockNumber from 'lib/hooks/useBlockNumber' -import styled, { keyframes } from 'lib/styled-components' -import { useEffect, useMemo, useState } from 'react' -import { ExternalLink } from 'theme/components' -import { Text } from 'ui/src' -import { DEFAULT_MS_BEFORE_WARNING, UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' -import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' - -const StyledPolling = styled.div` - align-items: center; - bottom: 0; - color: ${({ theme }) => theme.neutral3}; - display: none; - padding: 1rem; - position: fixed; - right: 0; - transition: 250ms ease color; - - a { - color: unset; - } - a:hover { - color: unset; - text-decoration: none; - } - - @media screen and (min-width: ${({ theme }) => theme.breakpoint.md}px) { - display: flex; - } -` - -const StyledPollingBlockNumber = tamaguiStyled(Text, { - fontSize: 11, - color: '$statusSuccess', - opacity: 0.5, - hoverStyle: { - opacity: 1, - }, - - '$platform-web': { - transition: 'opacity 0.25s ease', - }, - - variants: { - warning: { - true: { - color: '$yellow600', - }, - }, - - breathe: { - true: { - opacity: 1, - }, - }, - - hovering: { - true: { - opacity: 0.7, - }, - }, - } as const, -}) - -const StyledPollingDot = styled.div<{ warning: boolean }>` - width: 8px; - height: 8px; - min-height: 8px; - min-width: 8px; - border-radius: 50%; - position: relative; - background-color: ${({ theme, warning }) => (warning ? theme.deprecated_yellow3 : theme.success)}; - transition: 250ms ease background-color; -` - -const rotate360 = keyframes` - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -` - -const Spinner = styled.div<{ warning: boolean }>` - animation: ${rotate360} 1s cubic-bezier(0.83, 0, 0.17, 1) infinite; - transform: translateZ(0); - - border-top: 1px solid transparent; - border-right: 1px solid transparent; - border-bottom: 1px solid transparent; - border-left: 2px solid ${({ theme, warning }) => (warning ? theme.deprecated_yellow3 : theme.success)}; - background: transparent; - width: 14px; - height: 14px; - border-radius: 50%; - position: relative; - transition: 250ms ease border-color; - - left: -3px; - top: -3px; -` - -export default function Polling() { - const { chainId } = useAccount() - const isSupportedChain = useIsSupportedChainId(chainId) - const blockNumber = useBlockNumber() - const [isMounting, setIsMounting] = useState(false) - const [isHover, setIsHover] = useState(false) - const isNftPage = useIsNftPage() - const isLandingPage = useIsLandingPage() - - const waitMsBeforeWarning = useMemo( - () => - (isSupportedChain ? UNIVERSE_CHAIN_INFO[chainId]?.blockWaitMsBeforeWarning : undefined) ?? - DEFAULT_MS_BEFORE_WARNING, - [chainId, isSupportedChain], - ) - const machineTime = useMachineTimeMs(AVERAGE_L1_BLOCK_TIME) - const blockTime = useCurrentBlockTimestamp( - useMemo( - () => ({ - blocksPerFetch: - /* 5m / 12s = */ 25 * (isSupportedChain ? UNIVERSE_CHAIN_INFO[chainId].blockPerMainnetEpochForChainId : 1), - }), - [chainId, isSupportedChain], - ), - ) - const warning = Boolean(!!blockTime && machineTime - blockTime.mul(1000).toNumber() > waitMsBeforeWarning) - - useEffect( - () => { - if (!blockNumber) { - return - } - - setIsMounting(true) - const mountingTimer = setTimeout(() => setIsMounting(false), 1000) - - // this will clear Timeout when component unmount like in willComponentUnmount - return () => { - clearTimeout(mountingTimer) - } - }, - [blockNumber], //useEffect will run only one time - //if you pass a value to array, like this [data] than clearTimeout will run every time this value changes (useEffect re-run) - ) - - //TODO - chainlink gas oracle is really slow. Can we get a better data source? - - const blockExternalLinkHref = useMemo(() => { - if (!chainId || !blockNumber) { - return '' - } - return getExplorerLink(chainId, blockNumber.toString(), ExplorerDataType.BLOCK) - }, [blockNumber, chainId]) - - if (isNftPage || isLandingPage) { - return null - } - - return ( - - setIsHover(true)} onMouseLeave={() => setIsHover(false)}> - - - }>{blockNumber}  - - - {isMounting && }{' '} - - {warning && } - - ) -} diff --git a/apps/web/src/components/Pools/PoolDetails/ChartSection/index.tsx b/apps/web/src/components/Pools/PoolDetails/ChartSection/index.tsx index 25142e2286f..501c9af6d93 100644 --- a/apps/web/src/components/Pools/PoolDetails/ChartSection/index.tsx +++ b/apps/web/src/components/Pools/PoolDetails/ChartSection/index.tsx @@ -20,13 +20,13 @@ import { DISPLAYS, TimePeriodDisplay, getTimePeriodFromDisplay } from 'component import { PoolData } from 'graphql/data/pools/usePoolData' import { TimePeriod, gqlToCurrency, supportedChainIdFromGQLChain, toHistoryDuration } from 'graphql/data/util' import useStablecoinPrice from 'hooks/useStablecoinPrice' -import { Trans, t } from 'i18n' import { useAtomValue } from 'jotai/utils' import styled, { useTheme } from 'lib/styled-components' import { useMemo, useState } from 'react' import { EllipsisStyle, ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { Chain, ProtocolVersion } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans, t } from 'uniswap/src/i18n' import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -349,10 +349,10 @@ function LiquidityTooltipDisplay({ return ( <> - {t(`{{token}} liquidity: {{name}}`, { token: tokenADescriptor, name: displayValue0 })} + {t('liquidityPool.chart.tooltip.amount', { token: tokenADescriptor, amount: displayValue0 })} - {t(`{{token}} liquidity: {{name}}`, { token: tokenBDescriptor, name: displayValue1 })} + {t('liquidityPool.chart.tooltip.amount', { token: tokenBDescriptor, amount: displayValue1 })} ) diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx index 40391e35b02..fefa6b35700 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx @@ -19,7 +19,6 @@ import { NATIVE_CHAIN_ID } from 'constants/tokens' import { getTokenDetailsURL, gqlToCurrency } from 'graphql/data/util' import { useScreenSize } from 'hooks/screenSize' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Trans, t } from 'i18n' import styled, { css, useTheme } from 'lib/styled-components' import React, { useMemo, useReducer, useRef } from 'react' import { ChevronRight, ExternalLink as ExternalLinkIcon } from 'react-feather' @@ -27,6 +26,7 @@ import { Link } from 'react-router-dom' import { ClickableStyle, EllipsisStyle, ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { ProtocolVersion, Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { shortenAddress } from 'utilities/src/addresses' import { useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsLink.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsLink.tsx index ec0d3e0190b..8aaceafed6f 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsLink.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsLink.tsx @@ -9,7 +9,6 @@ import { SupportedInterfaceChainId, chainIdToBackendChain } from 'constants/chai import { NATIVE_CHAIN_ID } from 'constants/tokens' import { getTokenDetailsURL, gqlToCurrency } from 'graphql/data/util' import useCopyClipboard from 'hooks/useCopyClipboard' -import { Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useCallback, useState } from 'react' import { ChevronRight, Copy } from 'react-feather' @@ -17,6 +16,7 @@ import { useNavigate } from 'react-router-dom' import { BREAKPOINTS } from 'theme' import { ClickableStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { isAddress, shortenAddress } from 'utilities/src/addresses' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsPositionsTable.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsPositionsTable.tsx index 9d699f0ad74..c7c81aa2641 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsPositionsTable.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsPositionsTable.tsx @@ -7,7 +7,6 @@ import { BIPS_BASE } from 'constants/misc' import { useCurrency } from 'hooks/Tokens' import { useAccount } from 'hooks/useAccount' import { useSwitchChain } from 'hooks/useSwitchChain' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useCallback } from 'react' import { AlertTriangle } from 'react-feather' @@ -15,6 +14,7 @@ import { useNavigate } from 'react-router-dom' import { Bound } from 'state/mint/v3/actions' import { BREAKPOINTS } from 'theme' import { ClickableStyle, ThemedText } from 'theme/components' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' const PositionTableWrapper = styled(Column)` @@ -85,6 +85,7 @@ enum PositionStatus { } function PositionRow({ positionInfo }: { positionInfo: PositionInfo }) { + const { t } = useTranslation() const tokens = [ useCurrency(positionInfo.details.token0, positionInfo.chainId), useCurrency(positionInfo.details.token1, positionInfo.chainId), @@ -135,31 +136,27 @@ function PositionRow({ positionInfo }: { positionInfo: PositionInfo }) {   - {formatTickPrice({ - price: priceLower, - atLimit: ticksAtLimit, - direction: Bound.LOWER, + {t('liquidityPool.positions.price', { + amountWithSymbol: `${formatTickPrice({ + price: priceLower, + atLimit: ticksAtLimit, + direction: Bound.LOWER, + })} ${positionInfo.pool.token0.symbol}`, + outputToken: positionInfo.pool.token1.symbol, })} -   - {positionInfo.pool.token0.symbol}  - -   - {positionInfo.pool.token1.symbol}   - {formatTickPrice({ - price: priceUpper, - atLimit: ticksAtLimit, - direction: Bound.UPPER, + {t('liquidityPool.positions.price', { + amountWithSymbol: `${formatTickPrice({ + price: priceUpper, + atLimit: ticksAtLimit, + direction: Bound.UPPER, + })} ${positionInfo.pool.token0.symbol}`, + outputToken: positionInfo.pool.token1.symbol, })} -   - {positionInfo.pool.token0.symbol}  - -   - {positionInfo.pool.token1.symbol} diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsStats.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsStats.tsx index 60448142bd4..825f98223d5 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsStats.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsStats.tsx @@ -11,7 +11,6 @@ import { PoolData } from 'graphql/data/pools/usePoolData' import { getTokenDetailsURL, unwrapToken } from 'graphql/data/util' import { useCurrency } from 'hooks/Tokens' import { useScreenSize } from 'hooks/screenSize' -import { Trans } from 'i18n' import styled, { css, useTheme } from 'lib/styled-components' import { ReactNode, useMemo } from 'react' import { Link } from 'react-router-dom' @@ -19,6 +18,7 @@ import { Text } from 'rebass' import { BREAKPOINTS } from 'theme' import { ClickableStyle, ThemedText } from 'theme/components' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const HeaderText = styled(Text)` diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx index e70af1d4a8f..b374b9ac17b 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx @@ -15,7 +15,6 @@ import { gqlToCurrency } from 'graphql/data/util' import { useScreenSize } from 'hooks/screenSize' import { useAccount } from 'hooks/useAccount' import { useSwitchChain } from 'hooks/useSwitchChain' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Swap } from 'pages/Swap' import { useMemo, useReducer } from 'react' @@ -26,6 +25,7 @@ import { ClickableStyle, ThemedText } from 'theme/components' import { opacify } from 'theme/utils' import { Z_INDEX } from 'theme/zIndex' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { currencyId } from 'utils/currencyId' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsTable.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsTable.tsx index f4bcbefb02a..667e6687a8c 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsTable.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsTable.tsx @@ -7,11 +7,11 @@ import Row from 'components/Row' import { useChainFromUrlParam } from 'constants/chains' import { getSupportedGraphQlChain } from 'graphql/data/util' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useMemo, useState } from 'react' import { ClickableStyle, ThemedText } from 'theme/components' import { ProtocolVersion, Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' enum PoolDetailsTableTabs { TRANSACTIONS = 'transactions', diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsTransactionsTable.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsTransactionsTable.tsx index 197f1cd73a0..31de388c257 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsTransactionsTable.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsTransactionsTable.tsx @@ -13,11 +13,11 @@ import { } from 'graphql/data/pools/usePoolTransactions' import { OrderDirection, getSupportedGraphQlChain, supportedChainIdFromGQLChain } from 'graphql/data/util' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useMemo, useReducer, useState } from 'react' import { ExternalLink, ThemedText } from 'theme/components' import { ProtocolVersion, Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' import { NumberType, useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsLink.test.tsx.snap b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsLink.test.tsx.snap index 9692f71fade..e53b845bc2d 100644 --- a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsLink.test.tsx.snap +++ b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsLink.test.tsx.snap @@ -608,15 +608,19 @@ exports[`PoolDetailsHeader renders link for token address 1`] = ` class="c5" >
- - + > + + +
diff --git a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsPositionTable.test.tsx.snap b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsPositionTable.test.tsx.snap index 3130c3ec9b4..c8cb80c43bd 100644 --- a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsPositionTable.test.tsx.snap +++ b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsPositionTable.test.tsx.snap @@ -277,7 +277,7 @@ exports[`PoolDetailsPositionsTable renders with PositionStatus Closed 1`] = ` class="c7 c14 css-obwv3p" data-testid="position-min-0" > - Min: 0 WETH per USDC + Min: 0 WETH per USDC
- Max: 1.00 WETH per USDC + Max: 1.00 WETH per USDC
@@ -558,7 +558,7 @@ exports[`PoolDetailsPositionsTable renders with PositionStatus In Range 1`] = ` class="c7 c15 css-obwv3p" data-testid="position-min-0" > - Min: 0 WETH per USDC + Min: 0 WETH per USDC
- Max: 1.00 WETH per USDC + Max: 1.00 WETH per USDC
@@ -856,7 +856,7 @@ exports[`PoolDetailsPositionsTable renders with PositionStatus Out Of Range 1`] class="c7 c14 css-obwv3p" data-testid="position-min-0" > - Min: 0 WETH per USDC + Min: 0 WETH per USDC
- Max: 1.00 WETH per USDC + Max: 1.00 WETH per USDC
diff --git a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStats.test.tsx.snap b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStats.test.tsx.snap index edcee7783a7..f9961ab3c42 100644 --- a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStats.test.tsx.snap +++ b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStats.test.tsx.snap @@ -309,15 +309,19 @@ exports[`PoolDetailsStats pool balance chart not visible on mobile 1`] = ` class="c10" >
- - + > + + +
@@ -340,15 +344,19 @@ exports[`PoolDetailsStats pool balance chart not visible on mobile 1`] = ` class="c10" >
- - + > + + +
@@ -802,15 +810,19 @@ exports[`PoolDetailsStats renders stats text correctly 1`] = ` class="c11" >
- - + > + + +
@@ -833,15 +845,19 @@ exports[`PoolDetailsStats renders stats text correctly 1`] = ` class="c11" >
- - + > + + +
diff --git a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStatsButtons.test.tsx.snap b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStatsButtons.test.tsx.snap index 1347d4e8a07..346c6ddd163 100644 --- a/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStatsButtons.test.tsx.snap +++ b/apps/web/src/components/Pools/PoolDetails/__snapshots__/PoolDetailsStatsButtons.test.tsx.snap @@ -210,6 +210,7 @@ exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = ` } .c19 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -262,10 +263,6 @@ exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = ` transition-property: opacity,color,background-color; } -.c22:hover { - opacity: 0.6; -} - .c22:focus { -webkit-text-decoration: underline; text-decoration: underline; @@ -978,6 +975,12 @@ exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = ` display: none; } +@media (hover:hover) and (pointer:fine) { + .c22:hover { + opacity: 0.6; + } +} + @media only screen and (max-width:1024px) { .c56 { opacity: 0; @@ -1226,7 +1229,7 @@ exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = ` class="c32" > Sell @@ -1266,30 +1269,39 @@ exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = ` class="c1 c2 c19" >
- - +
+
+ + +
+
+ + USDC +
- - USDC -
Buy @@ -1415,30 +1427,39 @@ exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = ` class="c1 c2 c19" >
- - +
+
+ + +
+
+ + WETH +
- - WETH -
void; onClose: () => void } function ActivityPopupContent({ activity, onClick, onClose }: ActivityPopupContentProps) { const success = activity.status === TransactionStatus.Confirmed && !activity.cancelled - const { ENSName } = useENSName(activity?.otherAccount) return ( @@ -124,12 +122,7 @@ function ActivityPopupContent({ activity, onClick, onClose }: ActivityPopupConte ) } title={{activity.title}} - descriptor={ - - {activity.descriptor} - {ENSName ?? activity.otherAccount} - - } + descriptor={{activity.descriptor}} onClick={onClick} /> diff --git a/apps/web/src/components/Popups/PopupItem.tsx b/apps/web/src/components/Popups/PopupItem.tsx index fe2eac38db5..c9a9b68056f 100644 --- a/apps/web/src/components/Popups/PopupItem.tsx +++ b/apps/web/src/components/Popups/PopupItem.tsx @@ -8,6 +8,12 @@ import { useAccount } from 'hooks/useAccount' import { useEffect } from 'react' import { useRemovePopup } from 'state/application/hooks' import { PopupContent, PopupType } from 'state/application/reducer' +import { ToastSimple } from 'ui/src' +import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' +import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { t } from 'uniswap/src/i18n' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { SwapTab } from 'uniswap/src/types/screens/interface' export default function PopupItem({ removeAfterMs, @@ -50,5 +56,26 @@ export default function PopupItem({ case PopupType.FailedSwitchNetwork: { return } + case PopupType.SwitchNetwork: { + return ( + + {' '} + {getSwitchNetworkTitle(content.action, content.chainId as UniverseChainId)} + + ) + } + } +} + +function getSwitchNetworkTitle(action: SwapTab, chainId: UniverseChainId) { + const { label } = UNIVERSE_CHAIN_INFO[chainId] + + switch (action) { + case SwapTab.Swap: + return t('notification.swap.network', { network: label }) + case SwapTab.Send: + return t('notification.send.network', { network: label }) + default: + return null } } diff --git a/apps/web/src/components/Popups/index.tsx b/apps/web/src/components/Popups/index.tsx index 562a3f07bc5..2056c9bc31c 100644 --- a/apps/web/src/components/Popups/index.tsx +++ b/apps/web/src/components/Popups/index.tsx @@ -5,6 +5,7 @@ import PopupItem from 'components/Popups/PopupItem' import styled from 'lib/styled-components' import { useActivePopups } from 'state/application/hooks' import { Z_INDEX } from 'theme/zIndex' +import { AnimatePresence } from 'ui/src' const StickyContainer = styled.div` position: absolute; @@ -60,9 +61,11 @@ export default function Popups() { - {activePopups.map((item) => ( - - ))} + + {activePopups.map((item) => ( + + ))} + {hasPopups && ( diff --git a/apps/web/src/components/PositionCard/Sushi.tsx b/apps/web/src/components/PositionCard/Sushi.tsx index 453831979cf..70b41011ec0 100644 --- a/apps/web/src/components/PositionCard/Sushi.tsx +++ b/apps/web/src/components/PositionCard/Sushi.tsx @@ -4,17 +4,17 @@ import { ButtonEmpty } from 'components/Button' import { LightCard } from 'components/Card' import { AutoColumn } from 'components/Column' import { DoubleCurrencyLogo } from 'components/DoubleLogo' +import { FixedHeightRow } from 'components/PositionCard' import { AutoRow, RowFixed } from 'components/Row' import { CardNoise } from 'components/earn/styled' import { Dots } from 'components/swap/styled' import { useColor } from 'hooks/useColor' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { transparentize } from 'polished' import { Link } from 'react-router-dom' import { Text } from 'rebass' +import { Trans } from 'uniswap/src/i18n' import { unwrappedToken } from 'utils/unwrappedToken' -import { FixedHeightRow } from '.' const StyledPositionCard = styled(LightCard)<{ bgColor: any }>` border: none; diff --git a/apps/web/src/components/PositionCard/V2.tsx b/apps/web/src/components/PositionCard/V2.tsx index 857192c5636..33d6701683a 100644 --- a/apps/web/src/components/PositionCard/V2.tsx +++ b/apps/web/src/components/PositionCard/V2.tsx @@ -12,7 +12,6 @@ import { BIG_INT_ZERO } from 'constants/misc' import { useAccount } from 'hooks/useAccount' import { useColor } from 'hooks/useColor' import { useTotalSupply } from 'hooks/useTotalSupply' -import { Trans } from 'i18n' import JSBI from 'jsbi' import styled from 'lib/styled-components' import { transparentize } from 'polished' @@ -21,6 +20,7 @@ import { ChevronDown, ChevronUp } from 'react-feather' import { Link } from 'react-router-dom' import { Text } from 'rebass' import { useTokenBalance } from 'state/connection/hooks' +import { Trans } from 'uniswap/src/i18n' import { currencyId } from 'utils/currencyId' import { unwrappedToken } from 'utils/unwrappedToken' import { FixedHeightRow } from '.' diff --git a/apps/web/src/components/PositionCard/index.tsx b/apps/web/src/components/PositionCard/index.tsx index aa970b3e39a..c5898e39f38 100644 --- a/apps/web/src/components/PositionCard/index.tsx +++ b/apps/web/src/components/PositionCard/index.tsx @@ -8,11 +8,11 @@ import CurrencyLogo from 'components/Logo/CurrencyLogo' import { AutoRow, RowBetween, RowFixed } from 'components/Row' import { CardNoise } from 'components/earn/styled' import { Dots } from 'components/swap/styled' +import { chainIdToBackendChain } from 'constants/chains' import { BIG_INT_ZERO } from 'constants/misc' import { useAccount } from 'hooks/useAccount' import { useColor } from 'hooks/useColor' import { useTotalSupply } from 'hooks/useTotalSupply' -import { Trans } from 'i18n' import JSBI from 'jsbi' import styled from 'lib/styled-components' import { transparentize } from 'polished' @@ -21,7 +21,8 @@ import { ChevronDown, ChevronUp } from 'react-feather' import { Link } from 'react-router-dom' import { Text } from 'rebass' import { useTokenBalance } from 'state/connection/hooks' -import { ExternalLink, ThemedText } from 'theme/components' +import { StyledInternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { currencyId } from 'utils/currencyId' import { unwrappedToken } from 'utils/unwrappedToken' @@ -283,27 +284,20 @@ export default function FullPositionCard({ pair, border, stakedBalance }: Positi - {poolTokenPercentage ? ( - - ) : ( - '-' - )} + {poolTokenPercentage + ? `${poolTokenPercentage.toFixed(2) === '0.00' ? '<0.01' : poolTokenPercentage.toFixed(2)}%` + : '-'} - - + {userDefaultPoolBalance && JSBI.greaterThan(userDefaultPoolBalance.quotient, BIG_INT_ZERO) && ( diff --git a/apps/web/src/components/PositionList/index.tsx b/apps/web/src/components/PositionList/index.tsx index 46d1a7bd61f..2aa4f790458 100644 --- a/apps/web/src/components/PositionList/index.tsx +++ b/apps/web/src/components/PositionList/index.tsx @@ -1,9 +1,9 @@ import PositionListItem from 'components/PositionListItem' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import React from 'react' import { MEDIA_WIDTHS } from 'theme' import { PositionDetails } from 'types/position' +import { Trans } from 'uniswap/src/i18n' const DesktopHeader = styled.div` display: none; diff --git a/apps/web/src/components/PositionListItem/index.tsx b/apps/web/src/components/PositionListItem/index.tsx index f53f2ea601d..cf9cb773fd2 100644 --- a/apps/web/src/components/PositionListItem/index.tsx +++ b/apps/web/src/components/PositionListItem/index.tsx @@ -10,13 +10,13 @@ import { DAI, USDC_MAINNET, USDT, WBTC, WRAPPED_NATIVE_CURRENCY } from 'constant import { useToken } from 'hooks/Tokens' import useIsTickAtLimit from 'hooks/useIsTickAtLimit' import { usePool } from 'hooks/usePools' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useMemo } from 'react' import { Link } from 'react-router-dom' import { Bound } from 'state/mint/v3/actions' import { MEDIA_WIDTHS } from 'theme' import { HideSmall, SmallOnly, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' import { unwrappedToken } from 'utils/unwrappedToken' @@ -227,18 +227,22 @@ export default function PositionListItem({   - - {formatTickPrice({ - price: priceLower, - atLimit: tickAtLimit, - direction: Bound.LOWER, - })}{' '} - , - y: , + amountWithSymbol: ( + <> + + {formatTickPrice({ + price: priceLower, + atLimit: tickAtLimit, + direction: Bound.LOWER, + })}{' '} + + + + ), + outputToken: , }} /> {' '} @@ -252,18 +256,22 @@ export default function PositionListItem({ - - {formatTickPrice({ - price: priceUpper, - atLimit: tickAtLimit, - direction: Bound.UPPER, - })}{' '} - , - y: , + amountWithSymbol: ( + <> + + {formatTickPrice({ + price: priceUpper, + atLimit: tickAtLimit, + direction: Bound.UPPER, + })}{' '} + + + + ), + outputToken: , }} /> diff --git a/apps/web/src/components/PositionPreview/index.tsx b/apps/web/src/components/PositionPreview/index.tsx index d25793ea962..51403c1c048 100644 --- a/apps/web/src/components/PositionPreview/index.tsx +++ b/apps/web/src/components/PositionPreview/index.tsx @@ -9,13 +9,13 @@ import RateToggle from 'components/RateToggle' import { RowBetween, RowFixed } from 'components/Row' import { Break } from 'components/earn/styled' import { BIPS_BASE } from 'constants/misc' -import { Trans } from 'i18n' import JSBI from 'jsbi' import { BlastRebasingAlert } from 'pages/AddLiquidity/blastAlerts' import { ReactNode, useCallback, useState } from 'react' import { Bound } from 'state/mint/v3/actions' import { ThemedText } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' import { unwrappedToken } from 'utils/unwrappedToken' diff --git a/apps/web/src/components/PrivacyPolicy/index.tsx b/apps/web/src/components/PrivacyPolicy/index.tsx index 2000b6747bb..5e82e74f6eb 100644 --- a/apps/web/src/components/PrivacyPolicy/index.tsx +++ b/apps/web/src/components/PrivacyPolicy/index.tsx @@ -3,15 +3,15 @@ import Card, { DarkGrayCard } from 'components/Card' import { AutoColumn } from 'components/Column' import Modal from 'components/Modal' import Row, { AutoRow, RowBetween } from 'components/Row' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useEffect, useRef } from 'react' import { ArrowDown, Info, X } from 'react-feather' -import { useModalIsOpen, useTogglePrivacyPolicy } from 'state/application/hooks' +import { useCloseModal, useModalIsOpen } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ExternalLink, ThemedText } from 'theme/components' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { isMobile } from 'utilities/src/platform' const Wrapper = styled.div` @@ -62,7 +62,7 @@ const EXTERNAL_APIS = [ <> {' '} - + ), @@ -76,7 +76,7 @@ const EXTERNAL_APIS = [ export function PrivacyPolicyModal() { const node = useRef() const open = useModalIsOpen(ApplicationModal.PRIVACY_POLICY) - const toggle = useTogglePrivacyPolicy() + const closeModal = useCloseModal() useEffect(() => { if (!open) { @@ -89,13 +89,13 @@ export function PrivacyPolicyModal() { }, [open]) return ( - toggle()}> + closeModal(ApplicationModal.PRIVACY_POLICY)}> - toggle()}> + closeModal(ApplicationModal.PRIVACY_POLICY)}> @@ -165,7 +165,7 @@ function PrivacyPolicy() { - + diff --git a/apps/web/src/components/RangeSelector/PresetsButtons.tsx b/apps/web/src/components/RangeSelector/PresetsButtons.tsx index 7658bd87ce2..12d603992ca 100644 --- a/apps/web/src/components/RangeSelector/PresetsButtons.tsx +++ b/apps/web/src/components/RangeSelector/PresetsButtons.tsx @@ -1,8 +1,8 @@ import { ButtonOutlined } from 'components/Button' import { AutoRow } from 'components/Row' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Button = styled(ButtonOutlined).attrs(() => ({ padding: '6px', diff --git a/apps/web/src/components/RangeSelector/index.tsx b/apps/web/src/components/RangeSelector/index.tsx index ea867a3d345..f900e0d9129 100644 --- a/apps/web/src/components/RangeSelector/index.tsx +++ b/apps/web/src/components/RangeSelector/index.tsx @@ -1,8 +1,8 @@ import { Currency, Price, Token } from '@uniswap/sdk-core' import StepCounter from 'components/InputStepCounter/InputStepCounter' import { AutoRow } from 'components/Row' -import { Trans } from 'i18n' import { Bound } from 'state/mint/v3/actions' +import { Trans } from 'uniswap/src/i18n' // currencyA is the base token export default function RangeSelector({ diff --git a/apps/web/src/components/ReceiveCryptoModal/ChooseProvider.tsx b/apps/web/src/components/ReceiveCryptoModal/ChooseProvider.tsx index be77f7809ab..bc7be4fc0c2 100644 --- a/apps/web/src/components/ReceiveCryptoModal/ChooseProvider.tsx +++ b/apps/web/src/components/ReceiveCryptoModal/ChooseProvider.tsx @@ -4,7 +4,6 @@ import { ProviderOption } from 'components/ReceiveCryptoModal/ProviderOption' import { useAccount } from 'hooks/useAccount' import useENSName from 'hooks/useENSName' import { useTheme } from 'lib/styled-components' -import { useTranslation } from 'react-i18next' import { useOpenModal, useToggleModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { CopyToClipboard } from 'theme/components' @@ -15,6 +14,7 @@ import { uniswapUrls } from 'uniswap/src/constants/urls' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' +import { useTranslation } from 'uniswap/src/i18n' const ICON_SIZE = 32 const ICON_BORDER_RADIUS = 100 @@ -89,7 +89,7 @@ export function ChooseProvider({ setConnectedProvider, setErrorProvider }: Choos const { t } = useTranslation() const account = useAccount() const toggleModal = useToggleModal(ApplicationModal.RECEIVE_CRYPTO) - const providers = useCexTransferProviders(true) + const providers = useCexTransferProviders() return ( diff --git a/apps/web/src/components/RoutingDiagram/RoutingDiagram.tsx b/apps/web/src/components/RoutingDiagram/RoutingDiagram.tsx index 4fc8cdda263..0d21d73942a 100644 --- a/apps/web/src/components/RoutingDiagram/RoutingDiagram.tsx +++ b/apps/web/src/components/RoutingDiagram/RoutingDiagram.tsx @@ -8,11 +8,11 @@ import CurrencyLogo from 'components/Logo/CurrencyLogo' import Row, { AutoRow } from 'components/Row' import { MouseoverTooltip, TooltipSize } from 'components/Tooltip' import { BIPS_BASE } from 'constants/misc' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Box } from 'rebass' import { ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' +import { Trans } from 'uniswap/src/i18n' import { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries' const Wrapper = styled(Box)` diff --git a/apps/web/src/components/Row/index.tsx b/apps/web/src/components/Row/index.tsx index b8204ef8176..21dea78cd9d 100644 --- a/apps/web/src/components/Row/index.tsx +++ b/apps/web/src/components/Row/index.tsx @@ -51,6 +51,7 @@ export const AutoRow = styled(Row)<{ gap?: string; justify?: string }>` /** @deprecated Please use `Flex` from `ui/src` going forward */ export const RowFixed = styled(Row)<{ gap?: string; justify?: string }>` + position: relative; width: fit-content; margin: ${({ gap }) => gap && `-${gap}`}; ` diff --git a/apps/web/src/components/SearchModal/CurrencySearch.tsx b/apps/web/src/components/SearchModal/CurrencySearch.tsx index 3e23250d8bc..88cf076a56b 100644 --- a/apps/web/src/components/SearchModal/CurrencySearch.tsx +++ b/apps/web/src/components/SearchModal/CurrencySearch.tsx @@ -1,259 +1,168 @@ -import { InterfaceEventName, InterfaceModalName } from '@uniswap/analytics-events' -import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import Column from 'components/Column' -import { ChainSelector } from 'components/NavBar/ChainSelector' -import Row, { RowBetween } from 'components/Row' -import CommonBases from 'components/SearchModal/CommonBases' -import CurrencyList, { CurrencyRow, formatAnalyticsEventProperties } from 'components/SearchModal/CurrencyList' -import { PaddedColumn, SearchInput, Separator } from 'components/SearchModal/styled' -import { useCurrencySearchResults } from 'components/SearchModal/useCurrencySearchResults' -import useDebounce from 'hooks/useDebounce' -import { useOnClickOutside } from 'hooks/useOnClickOutside' -import useSelectChain from 'hooks/useSelectChain' -import useToggle from 'hooks/useToggle' -import { useTokenBalances } from 'hooks/useTokenBalances' -import { Trans, t } from 'i18n' -import useNativeCurrency from 'lib/hooks/useNativeCurrency' -import styled, { useTheme } from 'lib/styled-components' -import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' -import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useRef, useState } from 'react' -import AutoSizer from 'react-virtualized-auto-sizer' -import { FixedSizeList } from 'react-window' -import { Text } from 'rebass' -import { useSwapAndLimitContext } from 'state/swap/hooks' -import { CloseIcon, ThemedText } from 'theme/components' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { isAddress } from 'utilities/src/addresses' -import { currencyKey } from 'utils/currencyKey' - -const ContentWrapper = styled(Column)` - background-color: ${({ theme }) => theme.surface1}; - width: 100%; - overflow: hidden; - flex: 1 1; - position: relative; - border-radius: 20px; -` - -const ChainSelectorWrapper = styled.div` - background-color: ${({ theme }) => theme.surface2}; - border-radius: 12px; -` - -export interface CurrencySearchFilters { - showCommonBases?: boolean - disableNonToken?: boolean - onlyShowCurrenciesWithBalance?: boolean -} - -const DEFAULT_CURRENCY_SEARCH_FILTERS: CurrencySearchFilters = { - showCommonBases: true, - disableNonToken: false, - onlyShowCurrenciesWithBalance: false, -} +import { Currency } from '@uniswap/sdk-core' +import { hideSmallBalancesAtom } from 'components/AccountDrawer/SmallBalanceToggle' +import { hideSpamAtom } from 'components/AccountDrawer/SpamToggle' +import { + recentlySearchedAssetsAtom, + useAddRecentlySearchedCurrency, +} from 'components/NavBar/SearchBar/RecentlySearchedAssets' +import { useAccount } from 'hooks/useAccount' +import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' +import { useActiveLocale } from 'hooks/useActiveLocale' +import usePrevious from 'hooks/usePrevious' +import { useAtomValue } from 'jotai/utils' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useAddPopup, useRemovePopup } from 'state/application/hooks' +import { PopupType } from 'state/application/reducer' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' +import { Flex } from 'ui/src' +import { TokenSelector, TokenSelectorVariation } from 'uniswap/src/components/TokenSelector/TokenSelector' +import { + useCommonTokensOptions, + useFilterCallbacks, + usePopularTokensOptions, + usePortfolioTokenOptions, + useTokenSectionsForSearchResults, +} from 'uniswap/src/components/TokenSelector/hooks' +import { TokenSearchResult } from 'uniswap/src/features/search/SearchResult' +import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { CurrencyField } from 'uniswap/src/types/currency' +import { SwapTab } from 'uniswap/src/types/screens/interface' +import { formatNumberOrString } from 'utilities/src/format/localeBased' +import { NumberType as UtilitiesNumberType } from 'utilities/src/format/types' +import { NumberType, useFormatter } from 'utils/formatNumbers' interface CurrencySearchProps { - isOpen: boolean + currencyField: CurrencyField + onCurrencySelect: (currency: Currency) => void onDismiss: () => void - selectedCurrency?: Currency | null - onCurrencySelect: (currency: Currency, hasWarning?: boolean) => void - otherSelectedCurrency?: Currency | null - showCurrencyAmount?: boolean - filters?: CurrencySearchFilters } -export function CurrencySearch({ - selectedCurrency, - onCurrencySelect, - otherSelectedCurrency, - showCurrencyAmount, - onDismiss, - isOpen, - filters, -}: CurrencySearchProps) { - const { showCommonBases } = { - ...DEFAULT_CURRENCY_SEARCH_FILTERS, - ...filters, - } - const { chainId } = useSwapAndLimitContext() - - const theme = useTheme() - - // refs for fixed size lists - const fixedList = useRef() - - const [searchQuery, setSearchQuery] = useState('') - const debouncedQuery = useDebounce(searchQuery, 200) - const isAddressSearch = isAddress(debouncedQuery) - - const { - searchCurrency, - allCurrencyRows, - loading: currencySearchResultsLoading, - } = useCurrencySearchResults({ - searchQuery: debouncedQuery, - filters, - selectedCurrency, - otherSelectedCurrency, - }) - - const { balanceMap } = useTokenBalances() - - const native = useNativeCurrency(chainId) - - const selectChain = useSelectChain() - const handleCurrencySelect = useCallback( - async (currency: Currency, hasWarning?: boolean) => { - if (currency.chainId !== chainId) { - const result = await selectChain(currency.chainId) - if (!result) { - // failed to switch chains, don't select the currency - return - } - } - onCurrencySelect(currency, hasWarning) - if (!hasWarning) { - onDismiss() - } +export function CurrencySearch({ currencyField, onCurrencySelect, onDismiss }: CurrencySearchProps) { + const account = useAccount() + const { chainId, setSelectedChainId, isUserSelectedChainId, setIsUserSelectedChainId, currentTab } = + useSwapAndLimitContext() + const [filteredChainId, setFilteredChainId] = useState( + isUserSelectedChainId ? chainId : undefined, + ) + const prevChainId = usePrevious(chainId) + const { formatNumber } = useFormatter() + const activeCurrencyCode = useActiveLocalCurrency() + const activeLocale = useActiveLocale() + const recentlySearchedAssets = useAtomValue(recentlySearchedAssetsAtom) + const addPopup = useAddPopup() + const removePopup = useRemovePopup() + const hideSmallBalances = useAtomValue(hideSmallBalancesAtom) + const hideSpamBalances = useAtomValue(hideSpamAtom) + const searchHistory = useMemo( + () => + recentlySearchedAssets + .slice(0, 4) + .filter((value) => (filteredChainId ? value.chainId === filteredChainId : true)), + [recentlySearchedAssets, filteredChainId], + ) + const addRecentlySearchedCurrency = useAddRecentlySearchedCurrency() + + const handleCurrencySelectTokenSelectorCallback = useCallback( + (currency: Currency) => { + onCurrencySelect(currency) + setSelectedChainId(currency.chainId) + setFilteredChainId(currency.chainId) + setIsUserSelectedChainId(true) + onDismiss() }, - [chainId, onCurrencySelect, onDismiss, selectChain], + [onCurrencySelect, onDismiss, setSelectedChainId, setIsUserSelectedChainId], ) - // clear the input on open useEffect(() => { - if (isOpen) { - setSearchQuery('') + if (currentTab !== SwapTab.Swap && currentTab !== SwapTab.Send) { + return } - }, [isOpen]) - - // manage focus on modal show - const inputRef = useRef() - const handleInput = useCallback((event: ChangeEvent) => { - const input = event.target.value - const checksummedInput = isAddress(input) - setSearchQuery(checksummedInput || input) - fixedList.current?.scrollTo(0) - }, []) + if (chainId && prevChainId && chainId !== prevChainId) { + removePopup(PopupType.SwitchNetwork) + addPopup( + { + type: PopupType.SwitchNetwork, + chainId, + action: currentTab, + }, + PopupType.SwitchNetwork, + 3000, + ) + } + }, [currentTab, chainId, prevChainId, addPopup, removePopup]) - // Allows the user to select a currency by pressing Enter if it's the only currency in the list. - const handleEnter = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter') { - const currencyResults = allCurrencyRows.filter((currencyRow) => !!currencyRow.currency) - const s = debouncedQuery.toLowerCase().trim() - if (s === native?.symbol?.toLowerCase()) { - handleCurrencySelect(native) - } else if (currencyResults.length > 0) { - if ( - currencyResults[0]?.currency && - (currencyResults[0].currency.symbol?.toLowerCase() === debouncedQuery.trim().toLowerCase() || - currencyResults.length === 1) - ) { - handleCurrencySelect(currencyResults[0].currency) + return ( + + + formatNumber({ + input: fromAmount as number, + type: NumberType.FiatTokenPrice, + }) + } + currencyField={currencyField} + flow={TokenSelectorFlow.Swap} + formatNumberOrStringCallback={(input) => + formatNumberOrString({ + price: input.value, + currencyCode: activeCurrencyCode, + locale: activeLocale, + type: UtilitiesNumberType.TokenNonTx, + }) + } + isSurfaceReady={true} + navigateToBuyOrReceiveWithEmptyWalletCallback={() => null} + useCommonTokensOptionsHook={useCommonTokensOptions} + useFavoriteTokensOptionsHook={() => { + return { + data: [], + loading: false, } + }} + useFilterCallbacksHook={useFilterCallbacks} + usePopularTokensOptionsHook={usePopularTokensOptions} + usePortfolioTokenOptionsHook={usePortfolioTokenOptions} + useTokenSectionsForEmptySearchHook={() => { + return { + data: [], + loading: false, + } + }} + useTokenSectionsForSearchResultsHook={useTokenSectionsForSearchResults} + useTokenWarningDismissedHook={() => { + return { + tokenWarningDismissed: false, + dismissWarningCallback: () => null, + } + }} + variation={ + currencyField === CurrencyField.INPUT + ? TokenSelectorVariation.BalancesAndPopular + : TokenSelectorVariation.SuggestedAndFavoritesAndPopular } - } - }, - [allCurrencyRows, debouncedQuery, native, handleCurrencySelect], - ) - - // menu ui - const [open, toggle] = useToggle(false) - const node = useRef() - useOnClickOutside(node, open ? toggle : undefined) - - return ( - - - - - - - - - - - } - onChange={handleInput} - onKeyDown={handleEnter} - /> - - - - - {showCommonBases && ( - - )} - - - {searchCurrency ? ( - - searchCurrency && handleCurrencySelect(searchCurrency, hasWarning)} - otherSelected={Boolean( - searchCurrency && otherSelectedCurrency && otherSelectedCurrency.equals(searchCurrency), - )} - showCurrencyAmount={showCurrencyAmount} - eventProperties={formatAnalyticsEventProperties( - searchCurrency, - 0, - [searchCurrency], - searchQuery, - isAddressSearch, - )} - balance={ - tryParseCurrencyAmount(String(balanceMap[currencyKey(searchCurrency)]?.balance ?? 0), searchCurrency) ?? - CurrencyAmount.fromRawAmount(searchCurrency, 0) - } - /> - - ) : allCurrencyRows.some((currencyRow) => !!currencyRow.currency) || currencySearchResultsLoading ? ( -
- - {({ height }: { height: number }) => ( - - )} - -
- ) : ( - - - - - - )} -
-
+ onClose={() => { + setFilteredChainId(null) + onDismiss() + }} + onDismiss={() => null} + onPressAnimation={() => null} + onSelectChain={(chainId) => { + setFilteredChainId(chainId) + }} + onSelectCurrency={handleCurrencySelectTokenSelectorCallback} + /> +
) } diff --git a/apps/web/src/components/SearchModal/CurrencySearchModal.tsx b/apps/web/src/components/SearchModal/CurrencySearchModal.tsx index 0264d2d4102..4940aed7a56 100644 --- a/apps/web/src/components/SearchModal/CurrencySearchModal.tsx +++ b/apps/web/src/components/SearchModal/CurrencySearchModal.tsx @@ -1,11 +1,14 @@ import { Currency, Token } from '@uniswap/sdk-core' import Modal from 'components/Modal' -import { CurrencySearch, CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' +import { CurrencySearch } from 'components/SearchModal/CurrencySearch' +import { CurrencySearchFilters, DeprecatedCurrencySearch } from 'components/SearchModal/DeprecatedCurrencySearch' import TokenSafety from 'components/TokenSafety' import useLast from 'hooks/useLast' import { memo, useCallback, useEffect, useState } from 'react' - import { useUserAddedTokens } from 'state/user/userAddedTokens' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { CurrencyField } from 'uniswap/src/types/currency' interface CurrencySearchModalProps { isOpen: boolean @@ -15,6 +18,7 @@ interface CurrencySearchModalProps { otherSelectedCurrency?: Currency | null showCurrencyAmount?: boolean currencySearchFilters?: CurrencySearchFilters + currencyField?: CurrencyField } enum CurrencyModalView { @@ -31,10 +35,12 @@ export default memo(function CurrencySearchModal({ otherSelectedCurrency, showCurrencyAmount = true, currencySearchFilters, + currencyField = CurrencyField.INPUT, }: CurrencySearchModalProps) { const [modalView, setModalView] = useState(CurrencyModalView.search) const lastOpen = useLast(isOpen) const userAddedTokens = useUserAddedTokens() + const multichainFlagEnabled = useFeatureFlag(FeatureFlags.MultichainUX) useEffect(() => { if (isOpen && !lastOpen) { @@ -64,8 +70,10 @@ export default memo(function CurrencySearchModal({ let content = null switch (modalView) { case CurrencyModalView.search: - content = ( - + ) : ( + theme.surface1}; + width: 100%; + overflow: hidden; + flex: 1 1; + position: relative; + border-radius: 20px; +` + +const ChainSelectorWrapper = styled.div` + background-color: ${({ theme }) => theme.surface2}; + border-radius: 12px; +` + +export interface CurrencySearchFilters { + showCommonBases?: boolean + disableNonToken?: boolean + onlyShowCurrenciesWithBalance?: boolean +} + +const DEFAULT_CURRENCY_SEARCH_FILTERS: CurrencySearchFilters = { + showCommonBases: true, + disableNonToken: false, + onlyShowCurrenciesWithBalance: false, +} + +interface CurrencySearchProps { + isOpen: boolean + onDismiss: () => void + selectedCurrency?: Currency | null + onCurrencySelect: (currency: Currency, hasWarning?: boolean) => void + otherSelectedCurrency?: Currency | null + showCurrencyAmount?: boolean + filters?: CurrencySearchFilters +} + +export function DeprecatedCurrencySearch({ + selectedCurrency, + onCurrencySelect, + otherSelectedCurrency, + showCurrencyAmount, + onDismiss, + isOpen, + filters, +}: CurrencySearchProps) { + const { showCommonBases } = { + ...DEFAULT_CURRENCY_SEARCH_FILTERS, + ...filters, + } + const { chainId } = useSwapAndLimitContext() + + const theme = useTheme() + + // refs for fixed size lists + const fixedList = useRef() + + const [searchQuery, setSearchQuery] = useState('') + const debouncedQuery = useDebounce(searchQuery, 200) + const isAddressSearch = isAddress(debouncedQuery) + + const { + searchCurrency, + allCurrencyRows, + loading: currencySearchResultsLoading, + } = useCurrencySearchResults({ + searchQuery: debouncedQuery, + filters, + selectedCurrency, + otherSelectedCurrency, + }) + + const { balanceMap } = useTokenBalances() + + const native = useNativeCurrency(chainId) + + const selectChain = useSelectChain() + const handleCurrencySelect = useCallback( + async (currency: Currency, hasWarning?: boolean) => { + if (currency.chainId !== chainId) { + const result = await selectChain(currency.chainId) + if (!result) { + // failed to switch chains, don't select the currency + return + } + } + onCurrencySelect(currency, hasWarning) + if (!hasWarning) { + onDismiss() + } + }, + [chainId, onCurrencySelect, onDismiss, selectChain], + ) + + // clear the input on open + useEffect(() => { + if (isOpen) { + setSearchQuery('') + } + }, [isOpen]) + + // manage focus on modal show + const inputRef = useRef() + const handleInput = useCallback((event: ChangeEvent) => { + const input = event.target.value + const checksummedInput = isAddress(input) + setSearchQuery(checksummedInput || input) + fixedList.current?.scrollTo(0) + }, []) + + // Allows the user to select a currency by pressing Enter if it's the only currency in the list. + const handleEnter = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + const currencyResults = allCurrencyRows.filter((currencyRow) => !!currencyRow.currency) + const s = debouncedQuery.toLowerCase().trim() + if (s === native?.symbol?.toLowerCase()) { + handleCurrencySelect(native) + } else if (currencyResults.length > 0) { + if ( + currencyResults[0]?.currency && + (currencyResults[0].currency.symbol?.toLowerCase() === debouncedQuery.trim().toLowerCase() || + currencyResults.length === 1) + ) { + handleCurrencySelect(currencyResults[0].currency) + } + } + } + }, + [allCurrencyRows, debouncedQuery, native, handleCurrencySelect], + ) + + // menu ui + const [open, toggle] = useToggle(false) + const node = useRef() + useOnClickOutside(node, open ? toggle : undefined) + + return ( + + + + + + + + + + + } + onChange={handleInput} + onKeyDown={handleEnter} + /> + + + + + {showCommonBases && ( + + )} + + + {searchCurrency ? ( + + searchCurrency && handleCurrencySelect(searchCurrency, hasWarning)} + otherSelected={Boolean( + searchCurrency && otherSelectedCurrency && otherSelectedCurrency.equals(searchCurrency), + )} + showCurrencyAmount={showCurrencyAmount} + eventProperties={formatAnalyticsEventProperties( + searchCurrency, + 0, + [searchCurrency], + searchQuery, + isAddressSearch, + )} + balance={ + tryParseCurrencyAmount(String(balanceMap[currencyKey(searchCurrency)]?.balance ?? 0), searchCurrency) ?? + CurrencyAmount.fromRawAmount(searchCurrency, 0) + } + /> + + ) : allCurrencyRows.some((currencyRow) => !!currencyRow.currency) || currencySearchResultsLoading ? ( +
+ + {({ height }: { height: number }) => ( + + )} + +
+ ) : ( + + + + + + )} +
+
+ ) +} diff --git a/apps/web/src/components/SearchModal/__snapshots__/CommonBases.test.tsx.snap b/apps/web/src/components/SearchModal/__snapshots__/CommonBases.test.tsx.snap index 87b85d14d97..4371543bc80 100644 --- a/apps/web/src/components/SearchModal/__snapshots__/CommonBases.test.tsx.snap +++ b/apps/web/src/components/SearchModal/__snapshots__/CommonBases.test.tsx.snap @@ -127,15 +127,19 @@ exports[`CommonBases renders without crashing 1`] = ` class="c5" >
- - + > + + +
@@ -158,15 +162,19 @@ exports[`CommonBases renders without crashing 1`] = ` class="c5" >
- - + > + + +
@@ -189,15 +197,19 @@ exports[`CommonBases renders without crashing 1`] = ` class="c5" >
- - + > + + +
@@ -220,15 +232,19 @@ exports[`CommonBases renders without crashing 1`] = ` class="c5" >
- - + > + + +
@@ -251,15 +267,19 @@ exports[`CommonBases renders without crashing 1`] = ` class="c5" >
- - + > + + +
@@ -282,15 +302,19 @@ exports[`CommonBases renders without crashing 1`] = ` class="c5" >
- - + > + + +
diff --git a/apps/web/src/components/SearchModal/useCurrencySearchResults.ts b/apps/web/src/components/SearchModal/useCurrencySearchResults.ts index 31112fe631b..a7b6c792bc6 100644 --- a/apps/web/src/components/SearchModal/useCurrencySearchResults.ts +++ b/apps/web/src/components/SearchModal/useCurrencySearchResults.ts @@ -1,15 +1,14 @@ import { Currency } from '@uniswap/sdk-core' import { CurrencyListRow, CurrencyListSectionTitle } from 'components/SearchModal/CurrencyList' -import { CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' +import { CurrencySearchFilters } from 'components/SearchModal/DeprecatedCurrencySearch' import { chainIdToBackendChain, useSupportedChainId } from 'constants/chains' import { gqlTokenToCurrencyInfo } from 'graphql/data/types' import { useFallbackListTokens, useToken } from 'hooks/Tokens' import { useTokenBalances } from 'hooks/useTokenBalances' -import { t } from 'i18next' import { getTokenFilter } from 'lib/hooks/useTokenList/filtering' import { getSortedPortfolioTokens } from 'lib/hooks/useTokenList/sorting' import { useMemo } from 'react' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { useUserAddedTokens } from 'state/user/userAddedTokens' import { UserAddedToken } from 'types/tokens' import { @@ -19,6 +18,7 @@ import { useSearchTokensWebQuery, useTopTokensQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' import { isSameAddress } from 'utilities/src/addresses' import { currencyKey } from 'utils/currencyKey' @@ -212,16 +212,21 @@ export function useCurrencySearchResults({ const finalCurrencyList: CurrencyListRow[] = useMemo(() => { if (!isEmpty(searchQuery) || portfolioTokens.length === 0) { return [ - new CurrencyListSectionTitle(searchQuery ? t('common.searchResults') : t('common.popularTokens')), + new CurrencyListSectionTitle( + searchQuery ? t('tokens.selector.section.search') : t('tokens.selector.section.popular'), + ), ...sortedCombinedTokens.map(searchQuery ? searchResultsCurrencyListMapper : currencyListRowMapper), ] } else if (sortedTokensWithoutPortfolio.length === 0) { - return [new CurrencyListSectionTitle(t('common.yourTokens')), ...portfolioTokens.map(currencyListRowMapper)] + return [ + new CurrencyListSectionTitle(t('tokens.selector.section.yours')), + ...portfolioTokens.map(currencyListRowMapper), + ] } else { return [ - new CurrencyListSectionTitle(t('common.yourTokens')), + new CurrencyListSectionTitle(t('tokens.selector.section.yours')), ...portfolioTokens.map(currencyListRowMapper), - new CurrencyListSectionTitle(t('common.popularTokens')), + new CurrencyListSectionTitle(t('tokens.selector.section.popular')), ...sortedTokensWithoutPortfolio.map(currencyListRowMapper), ] } diff --git a/apps/web/src/components/Settings/MaxSlippageSettings/index.tsx b/apps/web/src/components/Settings/MaxSlippageSettings/index.tsx index 4b7571cf62c..53bdbd468bd 100644 --- a/apps/web/src/components/Settings/MaxSlippageSettings/index.tsx +++ b/apps/web/src/components/Settings/MaxSlippageSettings/index.tsx @@ -3,12 +3,12 @@ import Expand from 'components/Expand' import QuestionHelper from 'components/QuestionHelper' import Row, { RowBetween } from 'components/Row' import { Input, InputContainer } from 'components/Settings/Input' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useState } from 'react' import { useUserSlippageTolerance } from 'state/user/hooks' import { SlippageTolerance } from 'state/user/types' import { CautionTriangle, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' enum SlippageError { diff --git a/apps/web/src/components/Settings/MenuButton/index.tsx b/apps/web/src/components/Settings/MenuButton/index.tsx index 9958126e738..963acf4c034 100644 --- a/apps/web/src/components/Settings/MenuButton/index.tsx +++ b/apps/web/src/components/Settings/MenuButton/index.tsx @@ -1,12 +1,12 @@ import { Settings } from 'components/Icons/Settings' import Row from 'components/Row' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { InterfaceTrade } from 'state/routing/types' import { isUniswapXTrade } from 'state/routing/utils' import { useUserSlippageTolerance } from 'state/user/hooks' import { SlippageTolerance } from 'state/user/types' import { ThemedText } from 'theme/components' +import { Trans, t } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' import validateUserSlippageTolerance, { SlippageValidationResult } from 'utils/validateUserSlippageTolerance' diff --git a/apps/web/src/components/Settings/MultipleRoutingOptions.tsx b/apps/web/src/components/Settings/MultipleRoutingOptions.tsx index b255eedddad..944f73e8d59 100644 --- a/apps/web/src/components/Settings/MultipleRoutingOptions.tsx +++ b/apps/web/src/components/Settings/MultipleRoutingOptions.tsx @@ -5,13 +5,13 @@ import QuestionHelper from 'components/QuestionHelper' import Row, { RowBetween } from 'components/Row' import Toggle from 'components/Toggle' import { isUniswapXSupportedChain } from 'constants/chains' -import { Trans, t } from 'i18n' import { atom, useAtom } from 'jotai' import styled from 'lib/styled-components' import { ReactNode, useCallback } from 'react' import { RouterPreference } from 'state/routing/types' import { ExternalLink, ThemedText } from 'theme/components' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { Trans, t } from 'uniswap/src/i18n' const LabelWrapper = styled(Column)` height: 100%; @@ -67,7 +67,7 @@ function UniswapXPreferenceLabel() { <> {' '} - + } diff --git a/apps/web/src/components/Settings/RouterPreferenceSettings/index.tsx b/apps/web/src/components/Settings/RouterPreferenceSettings/index.tsx index 639804181ce..96b344de572 100644 --- a/apps/web/src/components/Settings/RouterPreferenceSettings/index.tsx +++ b/apps/web/src/components/Settings/RouterPreferenceSettings/index.tsx @@ -2,11 +2,11 @@ import Column from 'components/Column' import UniswapXBrandMark from 'components/Logo/UniswapXBrandMark' import { RowBetween, RowFixed } from 'components/Row' import Toggle from 'components/Toggle' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { RouterPreference } from 'state/routing/types' import { useRouterPreference } from 'state/user/hooks' import { ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const InlineLink = styled(ThemedText.BodySmall)` color: ${({ theme }) => theme.accent1}; diff --git a/apps/web/src/components/Settings/TransactionDeadlineSettings/index.tsx b/apps/web/src/components/Settings/TransactionDeadlineSettings/index.tsx index 3636a7c06ec..d16ff2c784c 100644 --- a/apps/web/src/components/Settings/TransactionDeadlineSettings/index.tsx +++ b/apps/web/src/components/Settings/TransactionDeadlineSettings/index.tsx @@ -3,11 +3,11 @@ import QuestionHelper from 'components/QuestionHelper' import Row from 'components/Row' import { Input, InputContainer } from 'components/Settings/Input' import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc' -import { Trans } from 'i18n' import ms from 'ms' import { useState } from 'react' import { useUserTransactionTTL } from 'state/user/hooks' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' enum DeadlineError { InvalidInput = 'InvalidInput', diff --git a/apps/web/src/components/Settings/index.tsx b/apps/web/src/components/Settings/index.tsx index 6a9a2535615..eb325c7c07d 100644 --- a/apps/web/src/components/Settings/index.tsx +++ b/apps/web/src/components/Settings/index.tsx @@ -12,7 +12,6 @@ import { isUniswapXSupportedChain, useIsSupportedChainId } from 'constants/chain import { useIsMobile } from 'hooks/screenSize' import useDisableScrolling from 'hooks/useDisableScrolling' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Portal } from 'nft/components/common/Portal' import { useCallback, useMemo, useRef } from 'react' @@ -26,6 +25,7 @@ import { Z_INDEX } from 'theme/zIndex' import { isL2ChainId } from 'uniswap/src/features/chains/utils' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { Trans } from 'uniswap/src/i18n' const CloseButton = styled.button` background: transparent; @@ -119,7 +119,7 @@ export default function SettingsTab({ compact?: boolean hideRoutingSettings?: boolean }) { - const showDeadlineSettings = isL2ChainId(chainId) + const showDeadlineSettings = !isL2ChainId(chainId) const toggleButtonNode = useRef(null) const menuNode = useRef(null) const isOpen = useModalIsOpen(ApplicationModal.SETTINGS) diff --git a/apps/web/src/components/SwitchLocaleLink/index.tsx b/apps/web/src/components/SwitchLocaleLink/index.tsx index d2943ce4cc8..6a58ebe965c 100644 --- a/apps/web/src/components/SwitchLocaleLink/index.tsx +++ b/apps/web/src/components/SwitchLocaleLink/index.tsx @@ -1,11 +1,11 @@ import { DEFAULT_LOCALE, LOCALE_LABEL, SupportedLocale } from 'constants/locales' import { navigatorLocale, useActiveLocale } from 'hooks/useActiveLocale' import { useLocationLinkProps } from 'hooks/useLocationLinkProps' -import { Trans } from 'i18n' import { useMemo } from 'react' import { useUserLocaleManager } from 'state/user/hooks' import { StyledInternalLink } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' const useTargetLocale = (activeLocale: SupportedLocale) => { const browserLocale = useMemo(() => navigatorLocale(), []) @@ -33,16 +33,22 @@ export function SwitchLocaleLink() { return ( - - { - onClick?.() - setUserLocale(targetLocale) + { + onClick?.() + setUserLocale(targetLocale) + }} + to={to} + > + {LOCALE_LABEL[targetLocale]} + + ), }} - to={to} - > - {LOCALE_LABEL[targetLocale]} - + /> ) } diff --git a/apps/web/src/components/Table/__snapshots__/styled.test.tsx.snap b/apps/web/src/components/Table/__snapshots__/styled.test.tsx.snap index 6d846a3f06e..73247b03860 100644 --- a/apps/web/src/components/Table/__snapshots__/styled.test.tsx.snap +++ b/apps/web/src/components/Table/__snapshots__/styled.test.tsx.snap @@ -137,14 +137,18 @@ exports[`TokenLinkCell renders known token on a different chain 1`] = ` class="c3" >
- + > + +
- + > + +
- + > + +
({ diff --git a/apps/web/src/components/Table/styled.tsx b/apps/web/src/components/Table/styled.tsx index 262bd10aec7..399b69a748f 100644 --- a/apps/web/src/components/Table/styled.tsx +++ b/apps/web/src/components/Table/styled.tsx @@ -9,13 +9,13 @@ import { NATIVE_CHAIN_ID } from 'constants/tokens' import { OrderDirection, getTokenDetailsURL, supportedChainIdFromGQLChain, unwrapToken } from 'graphql/data/util' import { useCurrency } from 'hooks/Tokens' import { useActiveLocale } from 'hooks/useActiveLocale' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { ArrowDown, CornerLeftUp, ExternalLink as ExternalLinkIcon } from 'react-feather' import { Link } from 'react-router-dom' import { ClickableStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { useTranslation } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' export const SHOW_RETURN_TO_TOP_OFFSET = 500 @@ -34,6 +34,11 @@ const StickyStyles = css<{ $top: number }>` position: -webkit-sticky; top: ${({ $top }) => $top}px; z-index: ${Z_INDEX.under_dropdown}; + + :before { + content: ''; + height: 12px; + } ` export const TableHead = styled.div<{ $isSticky?: boolean; $top: number }>` width: 100%; @@ -240,6 +245,7 @@ const TokenSymbolText = styled(ThemedText.BodyPrimary)` * @returns JSX.Element showing the Token's Logo, Chain logo if non-mainnet, and Token Symbol */ export const TokenLinkCell = ({ token }: { token: Token }) => { + const { t } = useTranslation() const chainId = supportedChainIdFromGQLChain(token.chain) ?? UniverseChainId.Mainnet const unwrappedToken = unwrapToken(chainId, token) const isNative = unwrappedToken.address === NATIVE_CHAIN_ID @@ -258,7 +264,7 @@ export const TokenLinkCell = ({ token }: { token: Token }) => { images={isNative ? undefined : [token.project?.logo?.url]} currencies={isNative ? [nativeCurrency] : undefined} /> - {unwrappedToken?.symbol ?? } + {unwrappedToken?.symbol ?? t('common.unknown').toUpperCase()} ) diff --git a/apps/web/src/components/Table/utils.ts b/apps/web/src/components/Table/utils.ts index 6dd2253924c..5b270357f43 100644 --- a/apps/web/src/components/Table/utils.ts +++ b/apps/web/src/components/Table/utils.ts @@ -1,4 +1,4 @@ -import { useTranslation } from 'i18n/useTranslation' +import { useTranslation } from 'uniswap/src/i18n' /** * Displays the time as a human-readable string. @@ -18,14 +18,14 @@ export function useAbbreviatedTimeString(timestamp: number) { const monthsPassed = Math.floor(daysPassed / 30) if (monthsPassed > 0) { - return t(`{{monthsPassed}}mo ago`, { monthsPassed }) + return t(`common.time.past.months`, { months: monthsPassed }) } else if (daysPassed > 0) { - return t(`{{daysPassed}}d ago`, { daysPassed }) + return t(`common.time.past.days`, { days: daysPassed }) } else if (hoursPassed > 0) { - return t(`{{hoursPassed}}h ago`, { hoursPassed }) + return t(`common.time.past.hours`, { hours: hoursPassed }) } else if (minutesPassed > 0) { - return t(`{{minutesPassed}}m ago`, { minutesPassed }) + return t(`common.time.past.minutes`, { minutes: minutesPassed }) } else { - return t(`{{secondsPassed}}s ago`, { secondsPassed }) + return t(`common.time.past.seconds`, { seconds: secondsPassed }) } } diff --git a/apps/web/src/components/Toggle/PillMultiToggle.tsx b/apps/web/src/components/Toggle/PillMultiToggle.tsx index 9c7fd4073f4..c99fbfc8041 100644 --- a/apps/web/src/components/Toggle/PillMultiToggle.tsx +++ b/apps/web/src/components/Toggle/PillMultiToggle.tsx @@ -1,4 +1,3 @@ -import { t } from 'i18n' import styled from 'lib/styled-components' import { createRef, useCallback, useEffect, useMemo, useState } from 'react' import { Z_INDEX } from 'theme/zIndex' @@ -122,7 +121,7 @@ export default function PillMultiToggle({ onSelectOption(value) }} > - {display ?? <>{t(`{{value}}`, { value })}} + {display ?? value} ) })} diff --git a/apps/web/src/components/TokenSafety/TokenSafetyMessage.tsx b/apps/web/src/components/TokenSafety/TokenSafetyMessage.tsx index 7d328f4fdf4..5cc2f50001f 100644 --- a/apps/web/src/components/TokenSafety/TokenSafetyMessage.tsx +++ b/apps/web/src/components/TokenSafety/TokenSafetyMessage.tsx @@ -1,10 +1,10 @@ import { displayWarningLabel, getWarningCopy, TOKEN_SAFETY_ARTICLE, Warning } from 'constants/tokenSafety' import { useTokenWarningColor, useTokenWarningTextColor } from 'hooks/useTokenWarningColor' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { AlertTriangle, Slash } from 'react-feather' import { Text } from 'rebass' import { ExternalLink } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Label = styled.div<{ color: string; backgroundColor: string }>` width: 100%; @@ -73,7 +73,7 @@ export default function TokenSafetyMessage({ {Boolean(description) && ' '} {tokenAddress && ( - + )} diff --git a/apps/web/src/components/TokenSafety/index.tsx b/apps/web/src/components/TokenSafety/index.tsx index a7d9d280c80..e4f95c48fc2 100644 --- a/apps/web/src/components/TokenSafety/index.tsx +++ b/apps/web/src/components/TokenSafety/index.tsx @@ -11,12 +11,12 @@ import { useTokenWarning, Warning, } from 'constants/tokenSafety' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ExternalLink as LinkIconFeather } from 'react-feather' import { Text } from 'rebass' import { useAddUserToken } from 'state/user/hooks' import { ButtonText, CopyLinkIcon, ExternalLink } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' const Wrapper = styled.div` @@ -92,9 +92,9 @@ const Buttons = ({ <> {!displayWarningLabel(warning) ? ( - + ) : ( - + )} {showCancel && Cancel} @@ -249,7 +249,7 @@ export default function TokenSafety({ token0, token1, onContinue, onCancel, onBl const { heading, description } = getWarningCopy(displayWarning, plural) const learnMoreUrl = ( - + ) diff --git a/apps/web/src/components/Tokens/TokenDetails/ActivitySection.tsx b/apps/web/src/components/Tokens/TokenDetails/ActivitySection.tsx index c3fa2e3ff1b..0eebc47f627 100644 --- a/apps/web/src/components/Tokens/TokenDetails/ActivitySection.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/ActivitySection.tsx @@ -1,11 +1,11 @@ import Row from 'components/Row' import { TokenDetailsPoolsTable } from 'components/Tokens/TokenDetails/tables/TokenDetailsPoolsTable' import { TransactionsTable } from 'components/Tokens/TokenDetails/tables/TransactionsTable' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useTDPContext } from 'pages/TokenDetails/TDPContext' import { useState } from 'react' import { ClickableStyle, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Container = styled.div` width: 100%; diff --git a/apps/web/src/components/Tokens/TokenDetails/BalanceSummary.tsx b/apps/web/src/components/Tokens/TokenDetails/BalanceSummary.tsx index 14b29f1c84b..67f9c540412 100644 --- a/apps/web/src/components/Tokens/TokenDetails/BalanceSummary.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/BalanceSummary.tsx @@ -2,7 +2,6 @@ import { Currency } from '@uniswap/sdk-core' import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo' import { getTokenDetailsURL, gqlToCurrency, supportedChainIdFromGQLChain } from 'graphql/data/util' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useTDPContext } from 'pages/TokenDetails/TDPContext' import { useMemo } from 'react' @@ -12,6 +11,7 @@ import { Chain, PortfolioTokenBalancePartsFragment, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Tokens/TokenDetails/ChartSection/AdvancedPriceChartToggle.tsx b/apps/web/src/components/Tokens/TokenDetails/ChartSection/AdvancedPriceChartToggle.tsx index d0a10456bf0..cbeed3b1624 100644 --- a/apps/web/src/components/Tokens/TokenDetails/ChartSection/AdvancedPriceChartToggle.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/ChartSection/AdvancedPriceChartToggle.tsx @@ -4,9 +4,9 @@ import { CHART_TYPE_LABELS, PriceChartType } from 'components/Charts/utils' import Row from 'components/Row' import { ChartTypeDropdown } from 'components/Tokens/TokenDetails/ChartSection/ChartTypeSelector' import { useScreenSize } from 'hooks/screenSize' -import { t } from 'i18n' import styled from 'lib/styled-components' import { EllipsisStyle } from 'theme/components' +import { t } from 'uniswap/src/i18n' const ChartTypeRow = styled(Row)` ${EllipsisStyle} diff --git a/apps/web/src/components/Tokens/TokenDetails/ChartSection/ChartTypeSelector.tsx b/apps/web/src/components/Tokens/TokenDetails/ChartSection/ChartTypeSelector.tsx index 0b3dc5a16f4..7627566af3b 100644 --- a/apps/web/src/components/Tokens/TokenDetails/ChartSection/ChartTypeSelector.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/ChartSection/ChartTypeSelector.tsx @@ -1,10 +1,10 @@ import { CHART_TYPE_LABELS, ChartType, PriceChartType } from 'components/Charts/utils' import { DropdownSelector, InternalMenuItem } from 'components/DropdownSelector' import { MouseoverTooltip } from 'components/Tooltip' -import { Trans } from 'i18n' import { css, useTheme } from 'lib/styled-components' import { useReducer } from 'react' import { Check, Info } from 'react-feather' +import { Trans } from 'uniswap/src/i18n' import { isMobile } from 'utilities/src/platform' const StyledDropdownButton = css` diff --git a/apps/web/src/components/Tokens/TokenDetails/ChartSection/index.tsx b/apps/web/src/components/Tokens/TokenDetails/ChartSection/index.tsx index 981c5053a54..281d6162589 100644 --- a/apps/web/src/components/Tokens/TokenDetails/ChartSection/index.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/ChartSection/index.tsx @@ -21,12 +21,12 @@ import { getTimePeriodFromDisplay, } from 'components/Tokens/TokenTable/TimeSelector' import { TimePeriod, toHistoryDuration } from 'graphql/data/util' -import { Trans } from 'i18n' import { useAtomValue } from 'jotai/utils' import styled from 'lib/styled-components' import { useTDPContext } from 'pages/TokenDetails/TDPContext' import { useMemo, useState } from 'react' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' export const TDP_CHART_HEIGHT_PX = 356 const TDP_CHART_SELECTOR_OPTIONS = [ChartType.PRICE, ChartType.VOLUME, ChartType.TVL] as const diff --git a/apps/web/src/components/Tokens/TokenDetails/InvalidTokenDetails.tsx b/apps/web/src/components/Tokens/TokenDetails/InvalidTokenDetails.tsx index b068c0ceda3..b8e754253d4 100644 --- a/apps/web/src/components/Tokens/TokenDetails/InvalidTokenDetails.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/InvalidTokenDetails.tsx @@ -3,11 +3,11 @@ import { ButtonPrimary } from 'components/Button' import { useIsSupportedChainId } from 'constants/chains' import { useAccount } from 'hooks/useAccount' import useSelectChain from 'hooks/useSelectChain' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useNavigate } from 'react-router-dom' import { ThemedText } from 'theme/components' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' const InvalidDetailsContainer = styled.div` diff --git a/apps/web/src/components/Tokens/TokenDetails/MobileBalanceSummaryFooter.tsx b/apps/web/src/components/Tokens/TokenDetails/MobileBalanceSummaryFooter.tsx deleted file mode 100644 index 242f37045ea..00000000000 --- a/apps/web/src/components/Tokens/TokenDetails/MobileBalanceSummaryFooter.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { chainIdToBackendChain, useSupportedChainId } from 'constants/chains' -import { NATIVE_CHAIN_ID } from 'constants/tokens' -import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' -import styled from 'lib/styled-components' -import { useTDPContext } from 'pages/TokenDetails/TDPContext' -import { StyledInternalLink, ThemedText } from 'theme/components' -import { Z_INDEX } from 'theme/zIndex' -import { NumberType, useFormatter } from 'utils/formatNumbers' - -const Wrapper = styled.div` - align-content: center; - align-items: center; - background-color: ${({ theme }) => theme.surface1}; - border: 1px solid ${({ theme }) => theme.surface3}; - color: ${({ theme }) => theme.neutral2}; - display: none; - flex-direction: row; - font-weight: 535; - font-size: 14px; - height: fit-content; - justify-content: space-between; - left: 0; - line-height: 20px; - position: fixed; - z-index: ${Z_INDEX.sticky}; - border-radius: 20px; - bottom: 56px; - margin: 8px; - padding: 12px 32px; - width: calc(100vw - 16px); - - @media screen and (min-width: ${({ theme }) => theme.breakpoint.md}px) { - bottom: 0px; - } - @media screen and (max-width: ${({ theme }) => theme.breakpoint.lg}px) { - display: flex; - } -` -const BalanceValue = styled.div` - color: ${({ theme }) => theme.neutral1}; - font-size: 20px; - line-height: 20px; - display: flex; - gap: 8px; -` -const Balance = styled.div` - align-items: flex-end; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 8px; -` -const BalanceInfo = styled.div` - display: flex; - flex: 10 1 auto; - flex-direction: column; - justify-content: flex-start; - gap: 6px; -` -const FiatValue = styled(ThemedText.Caption)` - font-size: 12px; - line-height: 16px; - - @media screen and (min-width: ${({ theme }) => theme.breakpoint.sm}px) { - line-height: 24px; - } -` -const SwapButton = styled(StyledInternalLink)` - background-color: ${({ theme }) => theme.accent1}; - border: none; - border-radius: 22px; - color: ${({ theme }) => theme.neutralContrast}; - display: flex; - flex: 1 1 auto; - padding: 12px 16px; - font-size: 16px; - font-weight: 535; - height: 44px; - justify-content: center; - margin: auto; - max-width: 100vw; -` - -export default function MobileBalanceSummaryFooter() { - const { currency, multiChainMap, currencyChain } = useTDPContext() - const supportedChain = useSupportedChainId(currency.chainId) - const pageChainBalance = multiChainMap[currencyChain]?.balance - - const account = useAccount() - const { formatNumber } = useFormatter() - - const formattedGqlBalance = formatNumber({ - input: pageChainBalance?.quantity, - type: NumberType.TokenNonTx, - }) - const formattedUsdGqlValue = formatNumber({ - input: pageChainBalance?.denominatedValue?.value, - type: NumberType.PortfolioBalance, - }) - const chain = chainIdToBackendChain({ chainId: supportedChain }) ?? '' - - return ( - - {Boolean(account.isConnected && pageChainBalance) && ( - - - - - {formattedGqlBalance} {currency.symbol} - - {formattedUsdGqlValue} - - - )} - - - - - ) -} diff --git a/apps/web/src/components/Tokens/TokenDetails/ShareButton.tsx b/apps/web/src/components/Tokens/TokenDetails/ShareButton.tsx index 8bd53eee42f..3e74f993ed3 100644 --- a/apps/web/src/components/Tokens/TokenDetails/ShareButton.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/ShareButton.tsx @@ -6,7 +6,6 @@ import { ActionButtonStyle, ActionMenuFlyoutStyle } from 'components/Tokens/Toke import useCopyClipboard from 'hooks/useCopyClipboard' import useDisableScrolling from 'hooks/useDisableScrolling' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useRef } from 'react' import { Link } from 'react-feather' @@ -16,6 +15,7 @@ import { ApplicationModal } from 'state/application/reducer' import { colors } from 'theme/colors' import { ThemedText } from 'theme/components' import { opacify } from 'theme/utils' +import { Trans, t } from 'uniswap/src/i18n' import { isMobile } from 'utilities/src/platform' const TWITTER_WIDTH = 560 diff --git a/apps/web/src/components/Tokens/TokenDetails/Skeleton.tsx b/apps/web/src/components/Tokens/TokenDetails/Skeleton.tsx index fa8a952474e..8c73d332f4e 100644 --- a/apps/web/src/components/Tokens/TokenDetails/Skeleton.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/Skeleton.tsx @@ -13,7 +13,6 @@ import { useChainFromUrlParam } from 'constants/chains' import { NATIVE_CHAIN_ID } from 'constants/tokens' import { getSupportedGraphQlChain } from 'graphql/data/util' import { useCurrency } from 'hooks/Tokens' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { ReactNode } from 'react' import { ChevronRight } from 'react-feather' @@ -22,6 +21,7 @@ import { BREAKPOINTS } from 'theme' import { ClickableStyle } from 'theme/components' import { textFadeIn } from 'theme/styles' import { capitalize } from 'tsafe' +import { Trans } from 'uniswap/src/i18n' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' const SWAP_COMPONENT_WIDTH = 360 @@ -193,9 +193,8 @@ export function getLoadingTitle( } else { tokenName = tokenAddress || '' } - const chainSuffix = chainName ? ` on ${capitalize(chainName)}` : '' const tokenLink = token?.isNative ? ( - tokenName + <>{tokenName} ) : ( ) - return + return chainName ? ( + + ) : ( + + ) } export function LoadingChart() { diff --git a/apps/web/src/components/Tokens/TokenDetails/StatsSection.tsx b/apps/web/src/components/Tokens/TokenDetails/StatsSection.tsx index 970480dac51..7b094601109 100644 --- a/apps/web/src/components/Tokens/TokenDetails/StatsSection.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/StatsSection.tsx @@ -4,12 +4,12 @@ import { TokenSortMethod } from 'components/Tokens/state' import { MouseoverTooltip } from 'components/Tooltip' import { useIsSupportedChainId } from 'constants/chains' import { TokenQueryData } from 'graphql/data/Token' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ReactNode } from 'react' import { ExternalLink, ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Tokens/TokenDetails/TokenDescription.tsx b/apps/web/src/components/Tokens/TokenDetails/TokenDescription.tsx index 809ccdcd760..33295772833 100644 --- a/apps/web/src/components/Tokens/TokenDetails/TokenDescription.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/TokenDescription.tsx @@ -8,12 +8,12 @@ import { NoInfoAvailable, truncateDescription, TruncateDescriptionButton } from import Tooltip, { MouseoverTooltip, TooltipSize } from 'components/Tooltip' import useCopyClipboard from 'hooks/useCopyClipboard' import { useSwapTaxes } from 'hooks/useSwapTaxes' -import { t, Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useTDPContext } from 'pages/TokenDetails/TDPContext' import { useCallback, useReducer } from 'react' import { Copy } from 'react-feather' import { ClickableStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components' +import { t, Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { shortenAddress } from 'utilities/src/addresses' import { useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Tokens/TokenDetails/TokenDetailsHeader.tsx b/apps/web/src/components/Tokens/TokenDetails/TokenDetailsHeader.tsx index 36f4896009c..c7fd406f9af 100644 --- a/apps/web/src/components/Tokens/TokenDetails/TokenDetailsHeader.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/TokenDetailsHeader.tsx @@ -14,7 +14,6 @@ import { MouseoverTooltip, TooltipSize } from 'components/Tooltip' import { useScreenSize } from 'hooks/screenSize' import useCopyClipboard from 'hooks/useCopyClipboard' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useTDPContext } from 'pages/TokenDetails/TDPContext' import { useReducer, useRef } from 'react' @@ -23,6 +22,7 @@ import { useSearchParams } from 'react-router-dom' import { ClickableStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components' import { opacify } from 'theme/utils' import { Z_INDEX } from 'theme/zIndex' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { isMobile } from 'utilities/src/platform' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Skeleton.test.tsx.snap b/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Skeleton.test.tsx.snap index 913f1825dca..57b0f9203d2 100644 --- a/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Skeleton.test.tsx.snap +++ b/apps/web/src/components/Tokens/TokenDetails/__snapshots__/Skeleton.test.tsx.snap @@ -909,13 +909,44 @@ exports[`TDP Skeleton should render correctly 1`] = ` exports[`getLoadingTitle should return correct title 1`] = ` - - token data for [object Object] on Ethereum + token data for + + USD//C (USDC) + + on Ethereum diff --git a/apps/web/src/components/Tokens/TokenDetails/index.tsx b/apps/web/src/components/Tokens/TokenDetails/index.tsx index 448a550a48c..10f7d4eeb76 100644 --- a/apps/web/src/components/Tokens/TokenDetails/index.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/index.tsx @@ -7,7 +7,6 @@ import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal' import { ActivitySection } from 'components/Tokens/TokenDetails/ActivitySection' import BalanceSummary, { PageChainBalanceSummary } from 'components/Tokens/TokenDetails/BalanceSummary' import ChartSection from 'components/Tokens/TokenDetails/ChartSection' -import MobileBalanceSummaryFooter from 'components/Tokens/TokenDetails/MobileBalanceSummaryFooter' import { LeftPanel, RightPanel, TokenDetailsLayout, TokenInfoContainer } from 'components/Tokens/TokenDetails/Skeleton' import StatsSection from 'components/Tokens/TokenDetails/StatsSection' import { TokenDescription } from 'components/Tokens/TokenDetails/TokenDescription' @@ -21,7 +20,6 @@ import { useScreenSize } from 'hooks/screenSize' import { useAccount } from 'hooks/useAccount' import useParsedQueryString from 'hooks/useParsedQueryString' import { ScrollDirection, useScroll } from 'hooks/useScroll' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Swap } from 'pages/Swap' import { useTDPContext } from 'pages/TokenDetails/TDPContext' @@ -29,9 +27,9 @@ import { PropsWithChildren, useCallback, useMemo, useState } from 'react' import { ChevronRight } from 'react-feather' import { useNavigate } from 'react-router-dom' import { CurrencyState } from 'state/swap/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useIsTouchDevice } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { addressesAreEquivalent } from 'utils/addressesAreEquivalent' import { getInitialLogoUrl } from 'utils/getInitialLogoURL' @@ -197,9 +195,9 @@ export default function TokenDetails() { const { address, currency, tokenQuery, currencyChain, multiChainMap } = useTDPContext() const tokenQueryData = tokenQuery.data?.token const pageChainBalance = multiChainMap[currencyChain]?.balance - const isLegacyNav = !useFeatureFlag(FeatureFlags.NavRefresh) const { lg: showRightPanel } = useScreenSize() const { direction: scrollDirection } = useScroll() + const isTouchDevice = useIsTouchDevice() return ( @@ -210,7 +208,7 @@ export default function TokenDetails() { - {!isLegacyNav && !showRightPanel && !!pageChainBalance && ( + {!showRightPanel && !!pageChainBalance && ( @@ -228,13 +226,12 @@ export default function TokenDetails() { )} - {isLegacyNav ? ( - - ) : ( - - - - )} + + + ) diff --git a/apps/web/src/components/Tokens/TokenDetails/tables/TransactionsTable.tsx b/apps/web/src/components/Tokens/TokenDetails/tables/TransactionsTable.tsx index 9f1446057dd..0219d4ad54c 100644 --- a/apps/web/src/components/Tokens/TokenDetails/tables/TransactionsTable.tsx +++ b/apps/web/src/components/Tokens/TokenDetails/tables/TransactionsTable.tsx @@ -18,11 +18,11 @@ import { useUpdateManualOutage } from 'featureFlags/flags/outageBanner' import { TokenTransactionType, useTokenTransactions } from 'graphql/data/useTokenTransactions' import { OrderDirection, unwrapToken } from 'graphql/data/util' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useMemo, useReducer, useState } from 'react' import { EllipsisStyle, ThemedText } from 'theme/components' import { Token as GQLToken } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' import { useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx b/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx index 05c04ef4fef..1e6e572ab80 100644 --- a/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx +++ b/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx @@ -9,7 +9,6 @@ import { useIsSupportedChainIdCallback, } from 'constants/chains' import { getSupportedGraphQlChain, supportedChainIdFromGQLChain } from 'graphql/data/util' -import { Trans } from 'i18n' import styled, { css, useTheme } from 'lib/styled-components' import { ExploreTab } from 'pages/Explore' import { useExploreParams } from 'pages/Explore/redirects' @@ -20,6 +19,7 @@ import { EllipsisStyle } from 'theme/components' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useTranslation } from 'uniswap/src/i18n' const NetworkLabel = styled.div` ${EllipsisStyle} @@ -46,7 +46,8 @@ const StyledMenuFlyout = css` left: 0px; } ` -export default function NetworkFilter() { +export default function TableNetworkFilter() { + const { t } = useTranslation() const theme = useTheme() const navigate = useNavigate() const [isMenuOpen, toggleMenu] = useReducer((s) => !s, false) @@ -83,7 +84,7 @@ export default function NetworkFilter() { }} > - All networks + {t('transaction.network.all')} {!exploreParams.chainName && } diff --git a/apps/web/src/components/Tokens/TokenTable/SearchBar.tsx b/apps/web/src/components/Tokens/TokenTable/SearchBar.tsx index af11921877c..1f0bf1c1507 100644 --- a/apps/web/src/components/Tokens/TokenTable/SearchBar.tsx +++ b/apps/web/src/components/Tokens/TokenTable/SearchBar.tsx @@ -4,11 +4,11 @@ import xIcon from 'assets/svg/x.svg' import { MEDIUM_MEDIA_BREAKPOINT } from 'components/Tokens/constants' import { exploreSearchStringAtom } from 'components/Tokens/state' import useDebounce from 'hooks/useDebounce' -import { t } from 'i18n' import { useAtomValue, useUpdateAtom } from 'jotai/utils' import styled from 'lib/styled-components' import { useEffect, useState } from 'react' import Trace from 'uniswap/src/features/telemetry/Trace' +import { t } from 'uniswap/src/i18n' const ICON_SIZE = '20px' const SearchBarContainer = styled.div` @@ -99,7 +99,9 @@ export default function SearchBar({ tab }: { tab?: string }) { !s, false) const [activeTime, setTime] = useAtom(filterTimeAtom) @@ -67,7 +68,7 @@ export default function TimeSelector() { toggleOpen={toggleMenu} menuLabel={ <> - {DISPLAYS[activeTime]} {isLargeScreen && } + {DISPLAYS[activeTime]} {isLargeScreen && t('common.volume').toLowerCase()} } internalMenuItems={ @@ -82,7 +83,7 @@ export default function TimeSelector() { }} >
- {DISPLAYS[time]} + {DISPLAYS[time]} {t('common.volume').toLowerCase()}
{time === activeTime && } diff --git a/apps/web/src/components/Tokens/TokenTable/index.tsx b/apps/web/src/components/Tokens/TokenTable/index.tsx index 0b7f0908732..cf03b40f247 100644 --- a/apps/web/src/components/Tokens/TokenTable/index.tsx +++ b/apps/web/src/components/Tokens/TokenTable/index.tsx @@ -23,11 +23,11 @@ import { SupportedInterfaceChainId, chainIdToBackendChain, useChainFromUrlParam import { NATIVE_CHAIN_ID } from 'constants/tokens' import { SparklineMap, TopToken, useTopTokens } from 'graphql/data/TopTokens' import { OrderDirection, getSupportedGraphQlChain, getTokenDetailsURL } from 'graphql/data/util' -import { Trans } from 'i18n' import { useAtomValue } from 'jotai/utils' import styled from 'lib/styled-components' import { ReactElement, ReactNode, useMemo } from 'react' import { EllipsisStyle, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const TableWrapper = styled.div` diff --git a/apps/web/src/components/TopLevelBanners/MobileAppPromoBanner.tsx b/apps/web/src/components/TopLevelBanners/MobileAppPromoBanner.tsx index 728c9c67437..8382ad8a671 100644 --- a/apps/web/src/components/TopLevelBanners/MobileAppPromoBanner.tsx +++ b/apps/web/src/components/TopLevelBanners/MobileAppPromoBanner.tsx @@ -7,11 +7,11 @@ import { useAtomValue } from 'jotai/utils' import styled, { useTheme } from 'lib/styled-components' import { useState } from 'react' import { X } from 'react-feather' -import { Trans } from 'react-i18next' import { hideMobileAppPromoBannerAtom } from 'state/application/atoms' import { BREAKPOINTS } from 'theme' import { ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' +import { Trans } from 'uniswap/src/i18n' import { isWebAndroid, isWebIOS } from 'utilities/src/platform' import { getWalletMeta } from 'utils/walletMeta' diff --git a/apps/web/src/components/TopLevelBanners/UkBanner.tsx b/apps/web/src/components/TopLevelBanners/UkBanner.tsx index 2afbb61986c..ff6e3399c3f 100644 --- a/apps/web/src/components/TopLevelBanners/UkBanner.tsx +++ b/apps/web/src/components/TopLevelBanners/UkBanner.tsx @@ -1,4 +1,3 @@ -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { useOpenModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' @@ -7,6 +6,7 @@ import { AppState } from 'state/reducer' import { BREAKPOINTS } from 'theme' import { ButtonText, ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' +import { Trans, t } from 'uniswap/src/i18n' const BannerWrapper = styled.div` position: relative; diff --git a/apps/web/src/components/TopLevelModals/ExtensionLaunchModal.tsx b/apps/web/src/components/TopLevelModals/ExtensionLaunchModal.tsx index 22bb5d8236c..df7d79d2e71 100644 --- a/apps/web/src/components/TopLevelModals/ExtensionLaunchModal.tsx +++ b/apps/web/src/components/TopLevelModals/ExtensionLaunchModal.tsx @@ -1,20 +1,24 @@ import { InterfaceElementName, InterfaceModalName } from '@uniswap/analytics-events' -import UNIWALLET_ICON from 'assets/wallets/uniswap-wallet-icon.png' import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button' import Column from 'components/Column' import Modal from 'components/Modal' import Row from 'components/Row' -import { AppIcon } from 'components/WalletModal/UniswapWalletOptions' +import { useConnectorWithId } from 'components/WalletModal/useOrderedConnections' +import { CONNECTION } from 'components/Web3Provider/constants' import { useIsMobile } from 'hooks/screenSize' -import { Trans } from 'i18n' +import { useIsLandingPage } from 'hooks/useIsLandingPage' import { useAtom } from 'jotai' import { atomWithStorage } from 'jotai/utils' import styled from 'lib/styled-components' import { X } from 'react-feather' import { BREAKPOINTS } from 'theme' import { ClickableStyle, ExternalLink, ThemedText } from 'theme/components' +import { Image } from 'ui/src' +import { UNISWAP_LOGO } from 'ui/src/assets' +import { iconSizes } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' const ModalWrapper = styled.div` display: flex; @@ -25,6 +29,9 @@ const ModalWrapper = styled.div` @media screen and (max-width: ${BREAKPOINTS.sm}px) { flex-direction: column; } + * { + outline: none; + } ` const PromoImage = styled.img` @@ -35,7 +42,7 @@ const PromoImage = styled.img` background: url('/images/extension_promo/announcement_modal_desktop.png'); background-repeat: no-repeat; background-size: cover; - flex-shrink: 0; + flex: 1; @media screen and (max-width: ${BREAKPOINTS.sm}px) { background: url('/images/extension_promo/announcement_modal_mobile.png'); @@ -43,6 +50,7 @@ const PromoImage = styled.img` background-position: 50%; height: 392px; width: 100%; + flex: unset; } ` @@ -57,6 +65,7 @@ const TextWrapper = styled(Column)` padding: 20px 24px; gap: 16px; height: 100%; + flex: 1; @media screen and (max-width: ${BREAKPOINTS.sm}px) { gap: 12px; @@ -95,6 +104,8 @@ const showExtensionLaunchAtom = atomWithStorage('showUniswapExtensionLaunchAtom' export function ExtensionLaunchModal() { const [showExtensionLaunch, setShowExtensionLaunch] = useAtom(showExtensionLaunchAtom) + const isOnLandingPage = useIsLandingPage() + const uniswapExtensionConnector = useConnectorWithId(CONNECTION.UNISWAP_EXTENSION_RDNS) const isMobile = useIsMobile() return ( @@ -102,14 +113,15 @@ export function ExtensionLaunchModal() { setShowExtensionLaunch(false)} > - + setShowExtensionLaunch(false)} /> @@ -141,7 +153,7 @@ export function ExtensionLaunchModal() { emphasis={isMobile ? ButtonEmphasis.high : ButtonEmphasis.medium} onClick={() => setShowExtensionLaunch(false)} > - + diff --git a/apps/web/src/components/TopLevelModals/UkDisclaimerModal.tsx b/apps/web/src/components/TopLevelModals/UkDisclaimerModal.tsx index 37684ab755b..94464a1e94d 100644 --- a/apps/web/src/components/TopLevelModals/UkDisclaimerModal.tsx +++ b/apps/web/src/components/TopLevelModals/UkDisclaimerModal.tsx @@ -2,12 +2,12 @@ import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button' import Column from 'components/Column' import Modal from 'components/Modal' import { bannerText } from 'components/TopLevelBanners/UkBanner' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { X } from 'react-feather' import { useCloseModal, useModalIsOpen } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ButtonText, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Wrapper = styled(Column)` padding: 8px; diff --git a/apps/web/src/components/TransactionConfirmationModal/index.tsx b/apps/web/src/components/TransactionConfirmationModal/index.tsx index f5305dc4941..cbb33aaca26 100644 --- a/apps/web/src/components/TransactionConfirmationModal/index.tsx +++ b/apps/web/src/components/TransactionConfirmationModal/index.tsx @@ -12,7 +12,6 @@ import AnimatedConfirmation from 'components/TransactionConfirmationModal/Animat import { useIsSupportedChainId } from 'constants/chains' import { useCurrencyInfo } from 'hooks/Tokens' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ReactNode, useCallback, useState } from 'react' import { AlertCircle, ArrowUpCircle, CheckCircle } from 'react-feather' @@ -21,6 +20,7 @@ import { CloseIcon, CustomLightSpinner, ExternalLink, ThemedText } from 'theme/c import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { isL2ChainId } from 'uniswap/src/features/chains/utils' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -292,10 +292,13 @@ function L2Content({
) : (
- - - {secondsToConfirm} seconds 🎉 - + , + }} + count={secondsToConfirm} + />
)} diff --git a/apps/web/src/components/V2Unsupported/index.tsx b/apps/web/src/components/V2Unsupported/index.tsx index b0446d0553f..27146450e63 100644 --- a/apps/web/src/components/V2Unsupported/index.tsx +++ b/apps/web/src/components/V2Unsupported/index.tsx @@ -1,7 +1,7 @@ import { AutoColumn } from 'components/Column' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const TextWrapper = styled.div` border: 1px solid ${({ theme }) => theme.neutral3}; diff --git a/apps/web/src/components/WalletModal/ConnectionErrorView.tsx b/apps/web/src/components/WalletModal/ConnectionErrorView.tsx index 9e1340a2a9e..ebfdf39df92 100644 --- a/apps/web/src/components/WalletModal/ConnectionErrorView.tsx +++ b/apps/web/src/components/WalletModal/ConnectionErrorView.tsx @@ -1,12 +1,12 @@ import { ButtonEmpty, ButtonPrimary } from 'components/Button' import Modal from 'components/Modal' import { useConnect } from 'hooks/useConnect' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useCallback } from 'react' import { AlertTriangle } from 'react-feather' import { ThemedText } from 'theme/components' import { flexColumnNoWrap } from 'theme/styles' +import { Trans } from 'uniswap/src/i18n' const Wrapper = styled.div` ${flexColumnNoWrap}; diff --git a/apps/web/src/components/WalletModal/DownloadWalletOption.tsx b/apps/web/src/components/WalletModal/DownloadWalletOption.tsx index 58567ea1f22..719aaac5384 100644 --- a/apps/web/src/components/WalletModal/DownloadWalletOption.tsx +++ b/apps/web/src/components/WalletModal/DownloadWalletOption.tsx @@ -3,7 +3,6 @@ import UNIWALLET_ICON from 'assets/wallets/uniswap-wallet-icon.png' import Column from 'components/Column' import Row from 'components/Row' import { AppIcon, OptionContainer } from 'components/WalletModal/UniswapWalletOptions' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useState } from 'react' import { useOpenModal } from 'state/application/hooks' @@ -12,6 +11,7 @@ import { colors } from 'theme/colors' import { Z_INDEX } from 'theme/zIndex' import { Text } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' // The light background is needed so that when hovered the background image always becomes lighter even when the app is in dark mode const LightBackground = styled.div` @@ -54,10 +54,10 @@ export const DownloadWalletOption = () => { - + - + diff --git a/apps/web/src/components/WalletModal/Option.tsx b/apps/web/src/components/WalletModal/Option.tsx index f2216fb8278..7dd90821098 100644 --- a/apps/web/src/components/WalletModal/Option.tsx +++ b/apps/web/src/components/WalletModal/Option.tsx @@ -3,11 +3,11 @@ import Badge, { BadgeVariant } from 'components/Badge' import Loader from 'components/Icons/LoadingSpinner' import { CONNECTOR_ICON_OVERRIDE_MAP, useRecentConnectorId } from 'components/Web3Provider/constants' import { useConnect } from 'hooks/useConnect' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ThemedText } from 'theme/components' import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { isIFramed } from 'utils/isIFramed' import { Connector } from 'wagmi' diff --git a/apps/web/src/components/WalletModal/PrivacyPolicyNotice.tsx b/apps/web/src/components/WalletModal/PrivacyPolicyNotice.tsx index 0f49d04e30e..d6f414096d1 100644 --- a/apps/web/src/components/WalletModal/PrivacyPolicyNotice.tsx +++ b/apps/web/src/components/WalletModal/PrivacyPolicyNotice.tsx @@ -1,6 +1,6 @@ -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const StyledLink = styled(ExternalLink)` font-weight: 535; @@ -10,14 +10,13 @@ const StyledLink = styled(ExternalLink)` export default function PrivacyPolicyNotice() { return ( - - - {' '} - - {' '} - - - + , + privacyLink: , + }} + /> ) } diff --git a/apps/web/src/components/WalletModal/UniswapWalletOptions.tsx b/apps/web/src/components/WalletModal/UniswapWalletOptions.tsx index 5982ab11990..e47ea4301e6 100644 --- a/apps/web/src/components/WalletModal/UniswapWalletOptions.tsx +++ b/apps/web/src/components/WalletModal/UniswapWalletOptions.tsx @@ -1,18 +1,19 @@ -import UNIWALLET_ICON from 'assets/wallets/uniswap-wallet-icon.png' import Column from 'components/Column' import Row from 'components/Row' import { DownloadWalletOption } from 'components/WalletModal/DownloadWalletOption' import { useConnectorWithId } from 'components/WalletModal/useOrderedConnections' import { CONNECTION } from 'components/Web3Provider/constants' import { useConnect } from 'hooks/useConnect' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { BREAKPOINTS } from 'theme' import { Z_INDEX } from 'theme/zIndex' -import { Text } from 'ui/src' -import { Mobile, QrCode } from 'ui/src/components/icons' +import { Image, Text } from 'ui/src' +import { UNISWAP_LOGO } from 'ui/src/assets' +import { ScanQr } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { Trans } from 'uniswap/src/i18n' export const OptionContainer = styled(Row)` padding: 16px; @@ -62,7 +63,7 @@ export function UniswapWalletOptions() { onClick={() => connect({ connector: uniswapExtensionConnector })} data-testid="connect-uniswap-extension" > - + @@ -79,20 +80,17 @@ export function UniswapWalletOptions() { ) : null} connect({ connector: uniswapWalletConnectConnector })}> - + - + - - - diff --git a/apps/web/src/components/WalletModal/__snapshots__/UniswapWalletOptions.test.tsx.snap b/apps/web/src/components/WalletModal/__snapshots__/UniswapWalletOptions.test.tsx.snap index bcc643582e4..0ec9ad67b77 100644 --- a/apps/web/src/components/WalletModal/__snapshots__/UniswapWalletOptions.test.tsx.snap +++ b/apps/web/src/components/WalletModal/__snapshots__/UniswapWalletOptions.test.tsx.snap @@ -159,12 +159,6 @@ exports[`UniswapWalletOptions Test Download wallet option should be visible if e filter: drop-shadow(0px 1.179px 3.537px rgba(255,117,249,0.24)); } -@media screen and (max-width:396px) { - .c11 { - display: none; - } -} - @@ -199,13 +193,13 @@ exports[`UniswapWalletOptions Test Download wallet option should be visible if e class="c9" > Get Uniswap Wallet Available on iOS, Android, and Chrome @@ -220,12 +214,15 @@ exports[`UniswapWalletOptions Test Download wallet option should be visible if e
- Mobile Wallet + Uniswap Mobile Scan QR code to connect
-
- - - - - - - - - -
@@ -424,12 +361,6 @@ exports[`UniswapWalletOptions Test Download wallet option should not be visible background: #22222212; } -@media screen and (max-width:396px) { - .c7 { - display: none; - } -} - @@ -448,12 +379,15 @@ exports[`UniswapWalletOptions Test Download wallet option should not be visible
- Mobile Wallet + Uniswap Mobile Scan QR code to connect
-
- - - - - - - - - -
@@ -576,7 +450,7 @@ exports[`UniswapWalletOptions Test Extension connecter should be shown if detect gap: 12px; } -.c9 { +.c8 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -613,7 +487,7 @@ exports[`UniswapWalletOptions Test Extension connecter should be shown if detect justify-content: flex-start; } -.c6 { +.c5 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -631,7 +505,7 @@ exports[`UniswapWalletOptions Test Extension connecter should be shown if detect gap: 4px; } -.c8 { +.c7 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -669,17 +543,8 @@ exports[`UniswapWalletOptions Test Extension connecter should be shown if detect background: #22222212; } -.c5 { - height: 40px; - width: 40px; - border-radius: 10px; - fill: linear-gradient(0deg,#fff1ff 0%,rgba(255,255,255,0) 100%),#fffbff; - -webkit-filter: drop-shadow(0px 1.179px 3.537px rgba(255,117,249,0.24)); - filter: drop-shadow(0px 1.179px 3.537px rgba(255,117,249,0.24)); -} - @media screen and (max-width:396px) { - .c7 { + .c6 { display: none; } } @@ -700,26 +565,40 @@ exports[`UniswapWalletOptions Test Extension connecter should be shown if detect class="c2 c3 c4" data-testid="connect-uniswap-extension" > - uniswap-app-icon + +
+
+ +
+
Uniswap Extension
Detected @@ -727,99 +606,42 @@ exports[`UniswapWalletOptions Test Extension connecter should be shown if detect
- Mobile Wallet + Uniswap Mobile Scan QR code to connect
-
- - - - - - - - - -
diff --git a/apps/web/src/components/WalletModal/index.tsx b/apps/web/src/components/WalletModal/index.tsx index 12b269ec90e..25500a38605 100644 --- a/apps/web/src/components/WalletModal/index.tsx +++ b/apps/web/src/components/WalletModal/index.tsx @@ -1,9 +1,7 @@ -import IconButton from 'components/AccountDrawer/IconButton' import { useShowMoonpayText } from 'components/AccountDrawer/MiniPortfolio/hooks' import Column from 'components/Column' import { CollapsedIcon } from 'components/Icons/Collapse' import { ExpandIcon } from 'components/Icons/Expand' -import { Settings } from 'components/Icons/Settings' import Row, { AutoRow } from 'components/Row' import ConnectionErrorView from 'components/WalletModal/ConnectionErrorView' import { Option } from 'components/WalletModal/Option' @@ -11,18 +9,18 @@ import PrivacyPolicyNotice from 'components/WalletModal/PrivacyPolicyNotice' import { UniswapWalletOptions } from 'components/WalletModal/UniswapWalletOptions' import { useOrderedConnections } from 'components/WalletModal/useOrderedConnections' import { useIsUniExtensionAvailable, useUniswapWalletOptions } from 'hooks/useUniswapWalletOptions' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { useReducer } from 'react' import { ClickableStyle, ThemedText } from 'theme/components' import { flexColumnNoWrap } from 'theme/styles' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' const Wrapper = styled.div<{ isUniExtensionAvailable?: boolean }>` ${flexColumnNoWrap}; background-color: ${({ theme }) => theme.surface1}; width: 100%; - padding: ${({ isUniExtensionAvailable }) => (isUniExtensionAvailable ? 0 : 14)}px 16px 16px; + padding: ${({ isUniExtensionAvailable }) => (isUniExtensionAvailable ? 0 : 14)}px 16px 20px; flex: 1; gap: 16px; ` @@ -70,19 +68,20 @@ const StyledCollapsedIcon = styled(CollapsedIcon)` ${OtherWalletIconStyles} ` -export default function WalletModal({ openSettings }: { openSettings: () => void }) { +export default function WalletModal() { const showMoonpayText = useShowMoonpayText() const showUniswapWalletOptions = useUniswapWalletOptions() const connectors = useOrderedConnections(showUniswapWalletOptions) const isUniExtensionAvailable = useIsUniExtensionAvailable() - const [showOtherWallets, toggleShowOtherWallets] = useReducer((s) => !s, false) + const [showOtherWallets, toggleShowOtherWallets] = useReducer((s) => !s, true) return ( - Connect a wallet - + + + {showUniswapWalletOptions && ( <> diff --git a/apps/web/src/components/Web3Status/index.tsx b/apps/web/src/components/Web3Status/index.tsx index 47b6cdcf237..5a9762f858f 100644 --- a/apps/web/src/components/Web3Status/index.tsx +++ b/apps/web/src/components/Web3Status/index.tsx @@ -10,7 +10,6 @@ import { useAccountIdentifier } from 'components/Web3Status/useAccountIdentifier import { PrefetchBalancesWrapper } from 'graphql/data/apollo/TokenBalancesProvider' import { navSearchInputVisibleSize } from 'hooks/screenSize/useScreenSize' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import { atom, useAtom } from 'jotai' import styled from 'lib/styled-components' import { Portal } from 'nft/components/common/Portal' @@ -21,6 +20,7 @@ import { flexRowNoWrap } from 'theme/styles' import { Unitag } from 'ui/src/components/icons' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { isIFramed } from 'utils/isIFramed' // https://stackoverflow.com/a/31617326 diff --git a/apps/web/src/components/addLiquidity/OutOfSyncWarning.tsx b/apps/web/src/components/addLiquidity/OutOfSyncWarning.tsx index f8da7d10aeb..c047547be8d 100644 --- a/apps/web/src/components/addLiquidity/OutOfSyncWarning.tsx +++ b/apps/web/src/components/addLiquidity/OutOfSyncWarning.tsx @@ -1,6 +1,6 @@ import { PoolWarning } from 'components/addLiquidity/PoolWarning' -import { Trans } from 'react-i18next' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { Trans } from 'uniswap/src/i18n' export function OutOfSyncWarning() { return ( diff --git a/apps/web/src/components/addLiquidity/OwnershipWarning.tsx b/apps/web/src/components/addLiquidity/OwnershipWarning.tsx index cc39cb8c710..f0d921fe2a9 100644 --- a/apps/web/src/components/addLiquidity/OwnershipWarning.tsx +++ b/apps/web/src/components/addLiquidity/OwnershipWarning.tsx @@ -1,7 +1,7 @@ -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { AlertTriangle } from 'react-feather' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const ExplainerText = styled.div` color: ${({ theme }) => theme.neutral2}; @@ -31,7 +31,7 @@ const OwnershipWarning = ({ ownerAddress }: OwnershipWarningProps) => ( - + diff --git a/apps/web/src/components/addLiquidity/PoolWarning.tsx b/apps/web/src/components/addLiquidity/PoolWarning.tsx index ed9178332dc..f0d66941234 100644 --- a/apps/web/src/components/addLiquidity/PoolWarning.tsx +++ b/apps/web/src/components/addLiquidity/PoolWarning.tsx @@ -1,12 +1,12 @@ import Column from 'components/Column' import Row from 'components/Row' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ReactNode } from 'react' import { ExternalLink } from 'theme/components' import { Text } from 'ui/src' import { AlertTriangle } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' +import { Trans } from 'uniswap/src/i18n' const Container = styled.div` height: 100%; @@ -53,7 +53,7 @@ export function PoolWarning({ title, subtitle, link }: PoolWarningProps) { - + diff --git a/apps/web/src/components/addLiquidity/TokenTaxV3Warning.tsx b/apps/web/src/components/addLiquidity/TokenTaxV3Warning.tsx index bbaba8d29a8..dead159b338 100644 --- a/apps/web/src/components/addLiquidity/TokenTaxV3Warning.tsx +++ b/apps/web/src/components/addLiquidity/TokenTaxV3Warning.tsx @@ -1,6 +1,6 @@ import { PoolWarning } from 'components/addLiquidity/PoolWarning' -import { Trans } from 'react-i18next' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { Trans } from 'uniswap/src/i18n' export function TokenTaxV3Warning() { return ( diff --git a/apps/web/src/components/claim/AddressClaimModal.tsx b/apps/web/src/components/claim/AddressClaimModal.tsx index bd12edaab95..fc69d00af7a 100644 --- a/apps/web/src/components/claim/AddressClaimModal.tsx +++ b/apps/web/src/components/claim/AddressClaimModal.tsx @@ -10,7 +10,6 @@ import { RowBetween } from 'components/Row' import { Break, CardBGImage, CardBGImageSmaller, CardNoise, CardSection, DataCard } from 'components/earn/styled' import { useAccount } from 'hooks/useAccount' import useENS from 'hooks/useENS' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useState } from 'react' import { useClaimCallback, useUserHasAvailableClaim, useUserUnclaimedAmount } from 'state/claim/hooks' @@ -96,6 +95,8 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole const amount = unclaimedAmount?.toFixed(0, { groupSeparator: ',' } ?? '-') const unclaimedUni = unclaimedAmount?.toFixed(0, { groupSeparator: ',' } ?? '-') + // Avoiding translating because the structure for "Claiming UNI for address" is wrong but this modal is rarely used + // and ran into difficulties with testing it return ( {!attempting && ( @@ -106,7 +107,7 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole - + Claim UNI token @@ -118,14 +119,11 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole - + Enter an address to trigger a UNI claim. If the address has any claimable UNI it will be sent to them on + submission. - {parsedAddress && !hasAvailableClaim && ( - - - - )} + {parsedAddress && !hasAvailableClaim && Address has no available claim} - + Claim UNI @@ -157,7 +155,7 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole - {claimConfirmed ? : } + {claimConfirmed ? 'Claimed' : 'Claiming'} {!claimConfirmed && ( @@ -166,7 +164,7 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole )} {parsedAddress && ( - + for {shortenAddress(parsedAddress)} )} @@ -176,7 +174,7 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole 🎉{' '} - + {'Welcome to team Unicorn :) '} 🎉 @@ -185,12 +183,12 @@ export default function AddressClaimModal({ isOpen, onDismiss }: { isOpen: boole )} {attempting && !hash && ( - + Confirm this transaction in your wallet )} {attempting && hash && !claimConfirmed && chainId && hash && ( - + View transaction on Explorer )} diff --git a/apps/web/src/components/swap/GasBreakdownTooltip.tsx b/apps/web/src/components/swap/GasBreakdownTooltip.tsx index 3418567589f..2371bd2e9bf 100644 --- a/apps/web/src/components/swap/GasBreakdownTooltip.tsx +++ b/apps/web/src/components/swap/GasBreakdownTooltip.tsx @@ -4,13 +4,13 @@ import UniswapXRouterLabel, { UniswapXGradient } from 'components/RouterLabel/Un import Row from 'components/Row' import { chainIdToBackendChain, useSupportedChainId } from 'constants/chains' import { nativeOnChain } from 'constants/tokens' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ReactNode } from 'react' import { InterfaceTrade } from 'state/routing/types' import { isPreviewTrade, isUniswapXTrade } from 'state/routing/utils' import { Divider, ExternalLink, ThemedText } from 'theme/components' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const Container = styled(AutoColumn)` @@ -90,7 +90,7 @@ function NetworkCostDescription({ native }: { native: Currency }) { {' '} - + ) @@ -109,7 +109,7 @@ export function UniswapXDescription() { }} />{' '} - + ) diff --git a/apps/web/src/components/swap/GasEstimateTooltip.tsx b/apps/web/src/components/swap/GasEstimateTooltip.tsx index 7ba1860f6a5..5edbd601079 100644 --- a/apps/web/src/components/swap/GasEstimateTooltip.tsx +++ b/apps/web/src/components/swap/GasEstimateTooltip.tsx @@ -9,7 +9,7 @@ import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains' import styled from 'lib/styled-components' import { SubmittableTrade } from 'state/routing/types' import { isUniswapXTrade } from 'state/routing/utils' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { ThemedText } from 'theme/components' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/swap/LimitDisclaimer.tsx b/apps/web/src/components/swap/LimitDisclaimer.tsx index 6649086d517..3ec215511a1 100644 --- a/apps/web/src/components/swap/LimitDisclaimer.tsx +++ b/apps/web/src/components/swap/LimitDisclaimer.tsx @@ -1,7 +1,7 @@ import Column from 'components/Column' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Container = styled(Column)` background-color: ${({ theme }) => theme.surface2}; @@ -23,7 +23,7 @@ export function LimitDisclaimer({ className }: { className?: string }) { Canceling a limit has a network cost. - + diff --git a/apps/web/src/components/swap/PriceImpactModal.tsx b/apps/web/src/components/swap/PriceImpactModal.tsx index 8399a282117..1f2bd1b424f 100644 --- a/apps/web/src/components/swap/PriceImpactModal.tsx +++ b/apps/web/src/components/swap/PriceImpactModal.tsx @@ -3,10 +3,10 @@ import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button' import { ColumnCenter } from 'components/Column' import Modal from 'components/Modal' import Row from 'components/Row' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { AlertTriangle } from 'react-feather' import { CloseIcon, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' const Wrapper = styled(ColumnCenter)` @@ -50,7 +50,7 @@ export default function PriceImpactModal({ priceImpact, onDismiss, onContinue }: - + - + - + diff --git a/apps/web/src/components/swap/PriceImpactWarning.tsx b/apps/web/src/components/swap/PriceImpactWarning.tsx index 1e365d35b40..d1c8af794b7 100644 --- a/apps/web/src/components/swap/PriceImpactWarning.tsx +++ b/apps/web/src/components/swap/PriceImpactWarning.tsx @@ -3,10 +3,10 @@ import { OutlineCard } from 'components/Card' import { AutoColumn } from 'components/Column' import { RowBetween, RowFixed } from 'components/Row' import { MouseoverTooltip } from 'components/Tooltip' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ThemedText } from 'theme/components' import { opacify } from 'theme/utils' +import { Trans } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' const StyledCard = styled(OutlineCard)` diff --git a/apps/web/src/components/swap/SwapBuyFiatButton.tsx b/apps/web/src/components/swap/SwapBuyFiatButton.tsx index 14182c9c9c4..1d8e5bcd3aa 100644 --- a/apps/web/src/components/swap/SwapBuyFiatButton.tsx +++ b/apps/web/src/components/swap/SwapBuyFiatButton.tsx @@ -3,12 +3,12 @@ import { useAccountDrawer, useSetShowMoonpayText } from 'components/AccountDrawe import { MouseoverTooltip } from 'components/Tooltip' import { SwapHeaderTabButton } from 'components/swap/styled' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import { useCallback, useEffect, useState } from 'react' import { useFiatOnrampAvailability, useOpenModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ExternalLink } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { isPathBlocked } from 'utils/blockedPaths' export const MOONPAY_REGION_AVAILABILITY_ARTICLE = @@ -111,7 +111,7 @@ export default function SwapBuyFiatButton({ - +
diff --git a/apps/web/src/components/swap/SwapDetails.tsx b/apps/web/src/components/swap/SwapDetails.tsx index ba7563036f5..c19557e9594 100644 --- a/apps/web/src/components/swap/SwapDetails.tsx +++ b/apps/web/src/components/swap/SwapDetails.tsx @@ -11,7 +11,6 @@ import SwapLineItem, { SwapLineItemProps, SwapLineItemType } from 'components/sw import { SwapCallbackError, SwapShowAcceptChanges } from 'components/swap/styled' import { Allowance, AllowanceState } from 'hooks/usePermit2Allowance' import { SwapResult } from 'hooks/useSwapCallback' -import { Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import ms from 'ms' import { ReactNode, useMemo, useState } from 'react' @@ -24,6 +23,7 @@ import { useRouterPreference, useUserSlippageTolerance } from 'state/user/hooks' import { ExternalLink, Separator, ThemedText } from 'theme/components' import { SpinningLoader } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans, t } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries' import { formatSwapButtonClickEventProperties } from 'utils/loggingFormatters' @@ -209,8 +209,8 @@ export function SwapDetails({ > {isLoading ? ( - - + + diff --git a/apps/web/src/components/swap/SwapDetailsDropdown.tsx b/apps/web/src/components/swap/SwapDetailsDropdown.tsx index e13ab12753d..b4f8150ea60 100644 --- a/apps/web/src/components/swap/SwapDetailsDropdown.tsx +++ b/apps/web/src/components/swap/SwapDetailsDropdown.tsx @@ -7,7 +7,6 @@ import { RowBetween, RowFixed } from 'components/Row' import GasEstimateTooltip from 'components/swap/GasEstimateTooltip' import SwapLineItem, { SwapLineItemType } from 'components/swap/SwapLineItem' import TradePrice from 'components/swap/TradePrice' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { formatCommonPropertiesForTrade } from 'lib/utils/analytics' import { useState } from 'react' @@ -16,6 +15,7 @@ import { InterfaceTrade } from 'state/routing/types' import { isSubmittableTrade } from 'state/routing/utils' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/swap/SwapHeader.test.tsx b/apps/web/src/components/swap/SwapHeader.test.tsx index b07ba01e9f9..eb979037dc6 100644 --- a/apps/web/src/components/swap/SwapHeader.test.tsx +++ b/apps/web/src/components/swap/SwapHeader.test.tsx @@ -27,7 +27,9 @@ function Wrapper(props: PropsWithChildren) { chainId: props.chainId ?? UniverseChainId.Mainnet, currentTab: SwapTab.Swap, setCurrentTab: props.setCurrentTab ?? jest.fn(), + setIsUserSelectedChainId: jest.fn(), isSwapAndLimitContext: true, + isUserSelectedChainId: false, }} > { if (pathname === '/buy') { diff --git a/apps/web/src/components/swap/SwapLineItem.tsx b/apps/web/src/components/swap/SwapLineItem.tsx index cf508af5124..2e2ab1989ee 100644 --- a/apps/web/src/components/swap/SwapLineItem.tsx +++ b/apps/web/src/components/swap/SwapLineItem.tsx @@ -11,7 +11,6 @@ import { RoutingTooltip, SwapRoute } from 'components/swap/SwapRoute' import TradePrice from 'components/swap/TradePrice' import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains' import { useUSDPrice } from 'hooks/useUSDPrice' -import { Trans, t } from 'i18n' import styled, { DefaultTheme } from 'lib/styled-components' import React, { ReactNode, useEffect, useState } from 'react' import { SpringValue, animated } from 'react-spring' @@ -20,6 +19,7 @@ import { isLimitTrade, isPreviewTrade, isUniswapXTrade, isUniswapXTradeType } fr import { useUserSlippageTolerance } from 'state/user/hooks' import { SlippageTolerance } from 'state/user/types' import { ExternalLink, ThemedText } from 'theme/components' +import { Trans, t } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' import { getPriceImpactColor } from 'utils/prices' @@ -49,10 +49,6 @@ const AutoBadge = styled(ThemedText.LabelMicro).attrs({ fontWeight: 535 })` height: 20px; padding: 0 6px; align-items: center; - - ::after { - content: '${t('common.automatic')}'; - } ` function BaseTooltipContent({ children, url }: { children: ReactNode; url: string }) { @@ -61,7 +57,7 @@ function BaseTooltipContent({ children, url }: { children: ReactNode; url: strin {children}
- + ) @@ -188,18 +184,18 @@ function useLineItem(props: SwapLineItemProps): LineItemData | undefined { TooltipBody: () => , Value: () => ( - {isAutoSlippage && } {formatPercent(allowedSlippage)} + {isAutoSlippage && {t(`common.automatic`)}} {formatPercent(allowedSlippage)} ), } case SwapLineItemType.SWAP_FEE: { if (isPreview) { - return { Label: () => , Value: () => } + return { Label: () => , Value: () => } } return { Label: () => ( <> - {trade.swapFee && `(${formatPercent(trade.swapFee.percent)})`} + {trade.swapFee && `(${formatPercent(trade.swapFee.percent)})`} ), TooltipBody: () => , @@ -262,8 +258,12 @@ function getFOTLineItem({ type, trade }: SwapLineItemProps): LineItemData | unde return } + const tokenSymbol = currency.symbol ?? currency.name + return { - Label: () => <>{t(`swap.namedFee`, { name: currency.symbol ?? currency.name ?? t('common.token') })}, + Label: () => ( + <>{tokenSymbol ? t('swap.details.feeOnTransfer', { tokenSymbol }) : t('swap.details.feeOnTransfer.default')} + ), TooltipBody: FOTTooltipContent, Value: () => , } diff --git a/apps/web/src/components/swap/SwapPreview.tsx b/apps/web/src/components/swap/SwapPreview.tsx index b831ed0a226..7072b0b4610 100644 --- a/apps/web/src/components/swap/SwapPreview.tsx +++ b/apps/web/src/components/swap/SwapPreview.tsx @@ -3,11 +3,11 @@ import Column, { AutoColumn } from 'components/Column' import { SwapModalHeaderAmount } from 'components/swap/SwapModalHeaderAmount' import { Field } from 'components/swap/constants' import { useUSDPrice } from 'hooks/useUSDPrice' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { InterfaceTrade } from 'state/routing/types' import { isPreviewTrade } from 'state/routing/utils' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const HeaderContainer = styled(AutoColumn)` margin-top: 0px; diff --git a/apps/web/src/components/swap/SwapRoute.tsx b/apps/web/src/components/swap/SwapRoute.tsx index 34d50ef455a..f7101215185 100644 --- a/apps/web/src/components/swap/SwapRoute.tsx +++ b/apps/web/src/components/swap/SwapRoute.tsx @@ -4,10 +4,10 @@ import RoutingDiagram from 'components/RoutingDiagram/RoutingDiagram' import { RowBetween } from 'components/Row' import { UniswapXDescription } from 'components/swap/GasBreakdownTooltip' import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains' -import { Trans } from 'i18n' import { ClassicTrade, SubmittableTrade } from 'state/routing/types' import { isClassicTrade } from 'state/routing/utils' import { Separator, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries' @@ -25,7 +25,9 @@ function useGasPrice({ gasUseEstimateUSD, inputAmount }: ClassicTrade) { function RouteLabel({ trade }: { trade: SubmittableTrade }) { return ( - Order Routing + + + ) @@ -36,7 +38,7 @@ function PriceImpactRow({ trade }: { trade: ClassicTrade }) { return ( - +
{formatPercent(trade.priceImpact)}
diff --git a/apps/web/src/components/swap/SwapSkeleton.tsx b/apps/web/src/components/swap/SwapSkeleton.tsx index 09f629316c8..afbdb96cd70 100644 --- a/apps/web/src/components/swap/SwapSkeleton.tsx +++ b/apps/web/src/components/swap/SwapSkeleton.tsx @@ -1,8 +1,8 @@ import { ArrowContainer, ArrowWrapper } from 'components/swap/styled' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ArrowDown } from 'react-feather' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const StyledArrowWrapper = styled(ArrowWrapper)` position: absolute; diff --git a/apps/web/src/components/swap/UnsupportedCurrencyFooter.test.tsx b/apps/web/src/components/swap/UnsupportedCurrencyFooter.test.tsx index 905a6fee01a..258c2e05504 100644 --- a/apps/web/src/components/swap/UnsupportedCurrencyFooter.test.tsx +++ b/apps/web/src/components/swap/UnsupportedCurrencyFooter.test.tsx @@ -7,6 +7,7 @@ import { useCurrencyInfo } from 'hooks/Tokens' import { mocked } from 'test-utils/mocked' import { act, render, screen, waitForElementToBeRemoved, within } from 'test-utils/render' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { getExplorerLink } from 'utils/getExplorerLink' const unsupportedTokenAddress = '0x4e83b6287588a96321B2661c5E041845fF7814af' @@ -35,7 +36,7 @@ describe('UnsupportedCurrencyFooter.tsx with unsupported tokens', () => { it('works as expected when one unsupported token exists', async () => { const rendered = render() - await act(() => userEvent.click(screen.getByTestId('read-more-button'))) + await act(() => userEvent.click(screen.getByTestId(TestID.ReadMoreButton))) expect(screen.getByText('Unsupported assets')).toBeInTheDocument() expect( screen.getByText((content) => content.startsWith('Some assets are not available through this interface')), @@ -66,7 +67,7 @@ describe('UnsupportedCurrencyFooter.tsx with no unsupported tokens', () => { currency: unsupportedToken, }) const rendered = render() - await act(() => userEvent.click(screen.getByTestId('read-more-button'))) + await act(() => userEvent.click(screen.getByTestId(TestID.ReadMoreButton))) expect(screen.getByText('Unsupported assets')).toBeInTheDocument() expect( screen.getByText((content) => content.startsWith('Some assets are not available through this interface')), diff --git a/apps/web/src/components/swap/UnsupportedCurrencyFooter.tsx b/apps/web/src/components/swap/UnsupportedCurrencyFooter.tsx index dc8fb6dfcf4..012c17539ec 100644 --- a/apps/web/src/components/swap/UnsupportedCurrencyFooter.tsx +++ b/apps/web/src/components/swap/UnsupportedCurrencyFooter.tsx @@ -7,13 +7,13 @@ import Modal from 'components/Modal' import { AutoRow, RowBetween } from 'components/Row' import { useCurrencyInfo } from 'hooks/Tokens' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useState } from 'react' import { CloseIcon, ExternalLink, ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' import { Text } from 'ui/src' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/swap/__snapshots__/SwapBuyFiatButton.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapBuyFiatButton.test.tsx.snap index d362e876dd4..3edfb909f45 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapBuyFiatButton.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapBuyFiatButton.test.tsx.snap @@ -23,10 +23,6 @@ exports[`SwapBuyFiatButton.tsx matches base snapshot 1`] = ` transition-property: opacity,color,background-color; } -.c1:hover { - opacity: 0.6; -} - .c1:focus { -webkit-text-decoration: underline; text-decoration: underline; @@ -50,6 +46,12 @@ exports[`SwapBuyFiatButton.tsx matches base snapshot 1`] = ` text-decoration: none; } +@media (hover:hover) and (pointer:fine) { + .c1:hover { + opacity: 0.6; + } +} + diff --git a/apps/web/src/components/swap/__snapshots__/SwapDetails.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapDetails.test.tsx.snap index dd23d5fd6ff..f622043ed11 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapDetails.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapDetails.test.tsx.snap @@ -102,6 +102,7 @@ exports[`SwapDetails.tsx matches base snapshot, test trade exact input 1`] = ` } .c19 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -317,10 +318,6 @@ exports[`SwapDetails.tsx matches base snapshot, test trade exact input 1`] = ` align-items: center; } -.c16::after { - content: 'Auto'; -} - .c1 { padding: 0px 12px 8px; } @@ -490,7 +487,9 @@ exports[`SwapDetails.tsx matches base snapshot, test trade exact input 1`] = ` >
+ > + Auto +
2%
@@ -921,10 +920,6 @@ exports[`SwapDetails.tsx renders a preview trade while disabling submission 1`] align-items: center; } -.c16::after { - content: 'Auto'; -} - .c1 { padding: 0px 12px 8px; } @@ -1059,7 +1054,9 @@ exports[`SwapDetails.tsx renders a preview trade while disabling submission 1`] >
+ > + Auto +
2%
@@ -1159,33 +1156,36 @@ exports[`SwapDetails.tsx renders a preview trade while disabling submission 1`] class="c5 css-1jyz67g" >
+
diff --git a/apps/web/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap index 6f2d6a364f2..e169b490665 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap @@ -106,12 +106,14 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = ` } .c6 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; } .c11 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -213,10 +215,6 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = ` align-items: center; } -.c21::after { - content: 'Auto'; -} - .c5 { padding: 0; -webkit-align-items: center; @@ -368,7 +366,9 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = ` >
+ > + Auto +
2%
diff --git a/apps/web/src/components/swap/__snapshots__/SwapHeader.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapHeader.test.tsx.snap index 3bf07ef6483..43cc622747e 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapHeader.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapHeader.test.tsx.snap @@ -33,6 +33,7 @@ exports[`SwapHeader.tsx matches base snapshot 1`] = ` } .c4 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -54,10 +55,6 @@ exports[`SwapHeader.tsx matches base snapshot 1`] = ` transition-property: opacity,color,background-color; } -.c6:hover { - opacity: 0.6; -} - .c6:focus { -webkit-text-decoration: underline; text-decoration: underline; @@ -146,6 +143,12 @@ exports[`SwapHeader.tsx matches base snapshot 1`] = ` gap: 16px; } +@media (hover:hover) and (pointer:fine) { + .c6:hover { + opacity: 0.6; + } +} + diff --git a/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap index f47ed9121a9..bbd501c72d5 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap @@ -102,6 +102,7 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -265,10 +266,6 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` align-items: center; } -.c18::after { - content: 'Auto'; -} - @supports (-webkit-background-clip:text) and (-webkit-text-fill-color:transparent) { .c12 { background-image: linear-gradient(91.39deg,#4673fa -101.76%,#9646fa 101.76%); @@ -460,7 +457,9 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` >
+ > + Auto +
2%
@@ -804,6 +803,7 @@ exports[`SwapLineItem.tsx dutch v2 order eth input 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -967,10 +967,6 @@ exports[`SwapLineItem.tsx dutch v2 order eth input 1`] = ` align-items: center; } -.c18::after { - content: 'Auto'; -} - @supports (-webkit-background-clip:text) and (-webkit-text-fill-color:transparent) { .c12 { background-image: linear-gradient(91.39deg,#4673fa -101.76%,#9646fa 101.76%); @@ -1162,7 +1158,9 @@ exports[`SwapLineItem.tsx dutch v2 order eth input 1`] = ` >
+ > + Auto +
2%
@@ -1517,6 +1515,7 @@ exports[`SwapLineItem.tsx exact input 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -1914,10 +1913,6 @@ exports[`SwapLineItem.tsx exact input 1`] = ` align-items: center; } -.c17::after { - content: 'Auto'; -} - @@ -2064,7 +2059,9 @@ exports[`SwapLineItem.tsx exact input 1`] = ` >
+ > + Auto +
2%
@@ -2251,15 +2248,19 @@ exports[`SwapLineItem.tsx exact input 1`] = ` class="c24" >
- - + > + + +
@@ -2357,15 +2358,19 @@ exports[`SwapLineItem.tsx exact input 1`] = ` class="c24" >
- - + > + + +
@@ -2504,6 +2509,7 @@ exports[`SwapLineItem.tsx exact input api 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -2901,10 +2907,6 @@ exports[`SwapLineItem.tsx exact input api 1`] = ` align-items: center; } -.c17::after { - content: 'Auto'; -} - @@ -3051,7 +3053,9 @@ exports[`SwapLineItem.tsx exact input api 1`] = ` >
+ > + Auto +
2%
@@ -3238,15 +3242,19 @@ exports[`SwapLineItem.tsx exact input api 1`] = ` class="c24" >
- - + > + + +
@@ -3344,15 +3352,19 @@ exports[`SwapLineItem.tsx exact input api 1`] = ` class="c24" >
- - + > + + +
@@ -3491,6 +3503,7 @@ exports[`SwapLineItem.tsx exact output 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -3888,10 +3901,6 @@ exports[`SwapLineItem.tsx exact output 1`] = ` align-items: center; } -.c17::after { - content: 'Auto'; -} - @@ -4038,7 +4047,9 @@ exports[`SwapLineItem.tsx exact output 1`] = ` >
+ > + Auto +
2% @@ -4266,15 +4277,19 @@ exports[`SwapLineItem.tsx exact output 1`] = ` class="c24" >
- - + > + + +
@@ -4372,15 +4387,19 @@ exports[`SwapLineItem.tsx exact output 1`] = ` class="c24" >
- - + > + + +
@@ -4519,6 +4538,7 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -4916,10 +4936,6 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` align-items: center; } -.c17::after { - content: 'Auto'; -} - @@ -5120,7 +5136,9 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` >
+ > + Auto +
2% @@ -5307,15 +5325,19 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` class="c24" >
- - + > + + +
@@ -5413,15 +5435,19 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` class="c24" >
- - + > + + +
@@ -5560,6 +5586,7 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` } .c9 { + position: relative; width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -5957,10 +5984,6 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` align-items: center; } -.c17::after { - content: 'Auto'; -} - @@ -6161,7 +6184,9 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` >
+ > + Auto +
2% @@ -6348,15 +6373,19 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` class="c24" >
- - + > + + +
@@ -6454,15 +6483,19 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` class="c24" >
- - + > + + +
@@ -6705,10 +6738,6 @@ exports[`SwapLineItem.tsx preview exact in 1`] = ` align-items: center; } -.c14::after { - content: 'Auto'; -} - @@ -6795,7 +6824,9 @@ exports[`SwapLineItem.tsx preview exact in 1`] = ` >
+ > + Auto +
2% diff --git a/apps/web/src/components/swap/__snapshots__/SwapPreview.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapPreview.test.tsx.snap index 52ac5d479ea..4d74455e372 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapPreview.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapPreview.test.tsx.snap @@ -207,15 +207,19 @@ exports[`SwapPreview.tsx matches base snapshot, test trade exact input 1`] = ` class="c12" >
- - + > + + +
@@ -266,15 +270,19 @@ exports[`SwapPreview.tsx matches base snapshot, test trade exact input 1`] = ` class="c12" >
- - + > + + +
@@ -493,15 +501,19 @@ exports[`SwapPreview.tsx renders ETH input token for an ETH input UniswapX swap class="c12" >
- - + > + + +
@@ -552,15 +564,19 @@ exports[`SwapPreview.tsx renders ETH input token for an ETH input UniswapX swap class="c12" >
- - + > + + +
@@ -779,15 +795,19 @@ exports[`SwapPreview.tsx renders ETH input token for an ETH input UniswapX v2 sw class="c12" >
- - + > + + +
@@ -838,15 +858,19 @@ exports[`SwapPreview.tsx renders ETH input token for an ETH input UniswapX v2 sw class="c12" >
- - + > + + +
@@ -1065,15 +1089,19 @@ exports[`SwapPreview.tsx renders preview trades with loading states 1`] = ` class="c12" >
- - + > + + +
@@ -1124,15 +1152,19 @@ exports[`SwapPreview.tsx renders preview trades with loading states 1`] = ` class="c12" >
- - + > + + +
@@ -1351,15 +1383,19 @@ exports[`SwapPreview.tsx test trade exact output, no recipient 1`] = ` class="c12" >
- - + > + + +
@@ -1410,15 +1446,19 @@ exports[`SwapPreview.tsx test trade exact output, no recipient 1`] = ` class="c12" >
- - + > + + +
diff --git a/apps/web/src/components/swap/__snapshots__/UnsupportedCurrencyFooter.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/UnsupportedCurrencyFooter.test.tsx.snap index a0001998aea..ce47058ec71 100644 --- a/apps/web/src/components/swap/__snapshots__/UnsupportedCurrencyFooter.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/UnsupportedCurrencyFooter.test.tsx.snap @@ -164,7 +164,7 @@ exports[`UnsupportedCurrencyFooter.tsx with unsupported tokens renders 1`] = ` data-testid="read-more-button" > Read more about unsupported assets diff --git a/apps/web/src/components/vote/DelegateModal.tsx b/apps/web/src/components/vote/DelegateModal.tsx index 70f094dba41..50ffe189e77 100644 --- a/apps/web/src/components/vote/DelegateModal.tsx +++ b/apps/web/src/components/vote/DelegateModal.tsx @@ -8,7 +8,6 @@ import { RowBetween } from 'components/Row' import { UNI } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' import useENS from 'hooks/useENS' -import { Trans } from 'i18n' import { useTokenBalance } from 'lib/hooks/useCurrencyBalance' import styled from 'lib/styled-components' import { ReactNode, useState } from 'react' @@ -16,6 +15,7 @@ import { X } from 'react-feather' import { useDelegateCallback } from 'state/governance/hooks' import { ThemedText } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' diff --git a/apps/web/src/components/vote/ExecuteModal.tsx b/apps/web/src/components/vote/ExecuteModal.tsx index 0e551269d7f..7bfa124c262 100644 --- a/apps/web/src/components/vote/ExecuteModal.tsx +++ b/apps/web/src/components/vote/ExecuteModal.tsx @@ -4,12 +4,12 @@ import { AutoColumn, ColumnCenter } from 'components/Column' import Modal from 'components/Modal' import { RowBetween } from 'components/Row' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useState } from 'react' import { ArrowUpCircle, X } from 'react-feather' import { useExecuteCallback } from 'state/governance/hooks' import { CustomLightSpinner, ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/vote/ProposalEmptyState.tsx b/apps/web/src/components/vote/ProposalEmptyState.tsx index 6d34808b455..503e0864d2a 100644 --- a/apps/web/src/components/vote/ProposalEmptyState.tsx +++ b/apps/web/src/components/vote/ProposalEmptyState.tsx @@ -1,7 +1,7 @@ import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' const EmptyProposals = styled.div` diff --git a/apps/web/src/components/vote/QueueModal.tsx b/apps/web/src/components/vote/QueueModal.tsx index c49f50b3e7a..0ebee43e6c8 100644 --- a/apps/web/src/components/vote/QueueModal.tsx +++ b/apps/web/src/components/vote/QueueModal.tsx @@ -4,12 +4,12 @@ import { AutoColumn, ColumnCenter } from 'components/Column' import Modal from 'components/Modal' import { RowBetween } from 'components/Row' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useState } from 'react' import { ArrowUpCircle, X } from 'react-feather' import { useQueueCallback } from 'state/governance/hooks' import { CustomLightSpinner, ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/components/vote/VoteModal.tsx b/apps/web/src/components/vote/VoteModal.tsx index c70d1ffbd54..2f54e4494ce 100644 --- a/apps/web/src/components/vote/VoteModal.tsx +++ b/apps/web/src/components/vote/VoteModal.tsx @@ -4,13 +4,13 @@ import { AutoColumn, ColumnCenter } from 'components/Column' import Modal from 'components/Modal' import { RowBetween } from 'components/Row' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useState } from 'react' import { ArrowUpCircle, X } from 'react-feather' import { useUserVotes, useVoteCallback } from 'state/governance/hooks' import { VoteOption } from 'state/governance/types' import { CustomLightSpinner, ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -92,7 +92,7 @@ export default function VoteModal({ isOpen, onDismiss, proposalId, voteOption }: ) : voteOption === VoteOption.For ? ( ) : ( - + )} diff --git a/apps/web/src/constants/tokenSafety.tsx b/apps/web/src/constants/tokenSafety.tsx index 8d53a37dda5..ac68608ccf5 100644 --- a/apps/web/src/constants/tokenSafety.tsx +++ b/apps/web/src/constants/tokenSafety.tsx @@ -1,6 +1,6 @@ import { useCurrencyInfo } from 'hooks/Tokens' -import { Plural, Trans, t } from 'i18n' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans, t } from 'uniswap/src/i18n' import { InterfaceChainId } from 'uniswap/src/types/chains' export const TOKEN_SAFETY_ARTICLE = 'https://support.uniswap.org/hc/en-us/articles/8723118437133' @@ -31,39 +31,27 @@ export function getWarningCopy(warning: Warning | undefined, plural = false, tok if (warning) { switch (warning.level) { case SafetyLevel.MediumWarning: - heading = ( - - ) - description = + heading = tokenSymbol + ? t('token.safety.warning.medium.heading.named', { + tokenSymbol, + }) + : t('token.safety.warning.medium.heading.default', { count: plural ? 2 : 1 }) + description = t('token.safety.warning.description') break case SafetyLevel.StrongWarning: - heading = ( - - ) - description = + heading = tokenSymbol + ? t('token.safety.warning.strong.heading.named', { + tokenSymbol, + }) + : t('token.safety.warning.strong.heading.default', { count: plural ? 2 : 1 }) + description = t('token.safety.warning.description') break case SafetyLevel.Blocked: - description = ( - - ) + description = tokenSymbol + ? t(`token.safety.warning.blocked.description.named`, { + tokenSymbol, + }) + : t('token.safety.warning.blocked.description.default', { count: plural ? 2 : 1 }) break } } @@ -79,19 +67,19 @@ export type Warning = { export const MediumWarning: Warning = { level: SafetyLevel.MediumWarning, - message: , + message: , canProceed: true, } export const StrongWarning: Warning = { level: SafetyLevel.StrongWarning, - message: , + message: , canProceed: true, } export const BlockedWarning: Warning = { level: SafetyLevel.Blocked, - message: , + message: , canProceed: false, } diff --git a/apps/web/src/graphql/data/SearchTokens.ts b/apps/web/src/graphql/data/SearchTokens.ts index 90fe7fc27e4..de10c574d4c 100644 --- a/apps/web/src/graphql/data/SearchTokens.ts +++ b/apps/web/src/graphql/data/SearchTokens.ts @@ -8,11 +8,10 @@ import { Token, useSearchTokensWebQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' const ARB_ADDRESS = ARB.address.toLowerCase() -export type SearchToken = NonNullable[number]> - /* Returns the more relevant cross-chain token based on native status and search chain */ function dedupeCrosschainTokens(current: SearchToken, existing: SearchToken | undefined, searchChain: Chain) { if (!existing) { @@ -107,3 +106,14 @@ export function useSearchTokens(searchQuery: string | undefined, chainId: Suppor error, } } + +export type TokenSearchResultWeb = Omit & { + type: SearchResultType.Token | SearchResultType.NFTCollection + address: string + chain: Chain + isNft?: boolean + isToken?: boolean + isNative?: boolean +} + +export type SearchToken = NonNullable[number]> diff --git a/apps/web/src/hooks/screenSize/index.ts b/apps/web/src/hooks/screenSize/index.ts index d7a83e4e6a8..db517b196f4 100644 --- a/apps/web/src/hooks/screenSize/index.ts +++ b/apps/web/src/hooks/screenSize/index.ts @@ -1,4 +1,3 @@ export { useIsMobile } from './useIsMobile' -export { useIsTablet } from './useIsTablet' export { useScreenSize } from './useScreenSize' export { useWindowSize } from './useWindowSize' diff --git a/apps/web/src/hooks/screenSize/useIsTablet.ts b/apps/web/src/hooks/screenSize/useIsTablet.ts deleted file mode 100644 index c213ae9fa89..00000000000 --- a/apps/web/src/hooks/screenSize/useIsTablet.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @deprecated in favor of useScreenSize -import { useScreenSize } from 'hooks/screenSize/useScreenSize' - -export function useIsTablet(): boolean { - const isScreenSize = useScreenSize() - const isTablet = !isScreenSize['lg'] && isScreenSize['sm'] - - return isTablet -} diff --git a/apps/web/src/hooks/useConfirmModalState.ts b/apps/web/src/hooks/useConfirmModalState.ts index 5e325964f5b..9e0355f2de4 100644 --- a/apps/web/src/hooks/useConfirmModalState.ts +++ b/apps/web/src/hooks/useConfirmModalState.ts @@ -7,12 +7,14 @@ import { useAccount } from 'hooks/useAccount' import { useMaxAmountIn } from 'hooks/useMaxAmountIn' import { Allowance, AllowanceState } from 'hooks/usePermit2Allowance' import usePrevious from 'hooks/usePrevious' +import useSelectChain from 'hooks/useSelectChain' import useWrapCallback from 'hooks/useWrapCallback' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import { getPriceUpdateBasisPoints } from 'lib/utils/analytics' import { useCallback, useEffect, useState } from 'react' import { InterfaceTrade } from 'state/routing/types' import { isUniswapXTrade } from 'state/routing/utils' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { useIsTransactionConfirmed } from 'state/transactions/hooks' import invariant from 'tiny-invariant' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -51,6 +53,9 @@ export function useConfirmModalState({ const [pendingModalSteps, setPendingModalSteps] = useState([]) const { formatCurrencyAmount } = useFormatter() + const account = useAccount() + const { chainId } = useSwapAndLimitContext() + // This is a function instead of a memoized value because we do _not_ want it to update as the allowance changes. // For example, if the user needs to complete 3 steps initially, we should always show 3 step indicators // at the bottom of the modal, even after they complete steps 1 and 2. @@ -77,7 +82,6 @@ export function useConfirmModalState({ return steps }, [allowance, trade]) - const { chainId } = useAccount() const trace = useTrace() const maximumAmountIn = useMaxAmountIn(trade, allowedSlippage) @@ -106,6 +110,7 @@ export function useConfirmModalState({ [trade], ) + const selectChain = useSelectChain() const performStep = useCallback( async (step: ConfirmModalState) => { switch (step) { @@ -169,11 +174,17 @@ export function useConfirmModalState({ ], ) - const startSwapFlow = useCallback(() => { + const startSwapFlow = useCallback(async () => { + if (chainId && chainId !== account.chainId) { + const switchChainResult = await selectChain(chainId) + if (!switchChainResult) { + return + } + } const steps = generateRequiredSteps() setPendingModalSteps(steps) performStep(steps[0]) - }, [generateRequiredSteps, performStep]) + }, [account.chainId, chainId, generateRequiredSteps, performStep, selectChain]) const previousSetupApprovalNeeded = usePrevious( allowance.state === AllowanceState.REQUIRED ? allowance.needsSetupApproval : undefined, diff --git a/apps/web/src/hooks/useConnect.tsx b/apps/web/src/hooks/useConnect.tsx index 6d43ca74e46..d21129207dd 100644 --- a/apps/web/src/hooks/useConnect.tsx +++ b/apps/web/src/hooks/useConnect.tsx @@ -1,5 +1,6 @@ import { InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-events' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' +import { useDisconnect } from 'hooks/useDisconnect' import { PropsWithChildren, createContext, useContext, useEffect } from 'react' import { useLocation } from 'react-router-dom' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -7,7 +8,7 @@ import { logger } from 'utilities/src/logger/logger' import { getCurrentPageFromLocation } from 'utils/urlRoutes' import { UserRejectedRequestError } from 'viem' // eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { ResolvedRegister, UseConnectReturnType, useConnect as useConnectWagmi, useDisconnect } from 'wagmi' +import { ResolvedRegister, UseConnectReturnType, useConnect as useConnectWagmi } from 'wagmi' const ConnectionContext = createContext | undefined>(undefined) diff --git a/apps/web/src/hooks/useContract.ts b/apps/web/src/hooks/useContract.ts index a8572243a1d..d4f943d7b5d 100644 --- a/apps/web/src/hooks/useContract.ts +++ b/apps/web/src/hooks/useContract.ts @@ -51,7 +51,7 @@ const { abi: V2MigratorABI } = V3MigratorJson // returns null on errors export function useContract( - addressOrAddressMap: string | { [chainId: number]: string } | undefined, + address: string | undefined, ABI: any, withSignerIfPossible = true, chainId?: InterfaceChainId, @@ -60,16 +60,7 @@ export function useContract( const provider = useEthersProvider({ chainId: chainId ?? account.chainId }) return useMemo(() => { - if (!addressOrAddressMap || !ABI || !provider || !account.chainId) { - return null - } - let address: string | undefined - if (typeof addressOrAddressMap === 'string') { - address = addressOrAddressMap - } else { - address = addressOrAddressMap[chainId ?? account.chainId] - } - if (!address) { + if (!address || !ABI || !provider) { return null } try { @@ -78,12 +69,12 @@ export function useContract( const wrappedError = new Error('failed to get contract', { cause: error }) logger.warn('useContract', 'useContract', wrappedError.message, { error: wrappedError, - addressOrAddressMap, - address: account.address, + contractAddress: address, + accountAddress: account.address, }) return null } - }, [addressOrAddressMap, ABI, provider, chainId, account.chainId, account.address, withSignerIfPossible]) as T + }, [address, ABI, provider, withSignerIfPossible, account.address]) as T } function useMainnetContract(address: string | undefined, ABI: any): T | null { @@ -110,11 +101,16 @@ function useMainnetContract(address: string | und } export function useV2MigratorContract() { - return useContract(V3_MIGRATOR_ADDRESSES, V2MigratorABI, true) + const account = useAccount() + return useContract( + account.chainId ? V3_MIGRATOR_ADDRESSES[account.chainId] : undefined, + V2MigratorABI, + true, + ) } -export function useTokenContract(tokenAddress?: string, withSignerIfPossible?: boolean) { - return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible) +export function useTokenContract(tokenAddress?: string, withSignerIfPossible?: boolean, chainId?: InterfaceChainId) { + return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible, chainId) } export function useWETHContract(withSignerIfPossible?: boolean, chainId?: InterfaceChainId) { @@ -135,7 +131,12 @@ export function useERC1155Contract(nftAddress?: string) { } export function useArgentWalletDetectorContract() { - return useContract(ARGENT_WALLET_DETECTOR_ADDRESS, ARGENT_WALLET_DETECTOR_ABI, false) + const account = useAccount() + return useContract( + account.chainId ? ARGENT_WALLET_DETECTOR_ADDRESS[account.chainId] : undefined, + ARGENT_WALLET_DETECTOR_ABI, + false, + ) } export function useENSRegistrarContract() { @@ -163,8 +164,15 @@ export function useV2RouterContract(): Contract | null { return useContract(chainId ? V2_ROUTER_ADDRESSES[chainId] : undefined, IUniswapV2Router02ABI, true) } -export function useInterfaceMulticall() { - return useContract(MULTICALL_ADDRESSES, MulticallABI, false) as UniswapInterfaceMulticall +export function useInterfaceMulticall(chainId?: InterfaceChainId) { + const account = useAccount() + const chain = chainId ?? account.chainId + return useContract( + chain ? MULTICALL_ADDRESSES[chain] : undefined, + MulticallABI, + false, + chain, + ) as UniswapInterfaceMulticall } export function useMainnetInterfaceMulticall() { @@ -177,7 +185,7 @@ export function useMainnetInterfaceMulticall() { export function useV3NFTPositionManagerContract(withSignerIfPossible?: boolean): NonfungiblePositionManager | null { const account = useAccount() const contract = useContract( - NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, + account.chainId ? NONFUNGIBLE_POSITION_MANAGER_ADDRESSES[account.chainId] : undefined, NFTPositionManagerABI, withSignerIfPossible, ) diff --git a/apps/web/src/hooks/useDisconnect.ts b/apps/web/src/hooks/useDisconnect.ts new file mode 100644 index 00000000000..eb9e3964f14 --- /dev/null +++ b/apps/web/src/hooks/useDisconnect.ts @@ -0,0 +1,12 @@ +import { useCallback, useMemo } from 'react' +import { UseDisconnectReturnType, useDisconnect as useDisconnectWagmi } from 'wagmi' + +export function useDisconnect(): UseDisconnectReturnType { + const { connectors, disconnect, ...rest } = useDisconnectWagmi() + const disconnectAll = useCallback(() => { + connectors.forEach((connector) => { + disconnect({ connector }) + }) + }, [connectors, disconnect]) + return useMemo(() => ({ ...rest, disconnect: disconnectAll, connectors }), [disconnectAll, connectors, rest]) +} diff --git a/apps/web/src/hooks/useEthersProvider.ts b/apps/web/src/hooks/useEthersProvider.ts index 82bd2bdd432..91a1b793f78 100644 --- a/apps/web/src/hooks/useEthersProvider.ts +++ b/apps/web/src/hooks/useEthersProvider.ts @@ -1,4 +1,5 @@ import { Web3Provider } from '@ethersproject/providers' +import { useAccount } from 'hooks/useAccount' import { useMemo } from 'react' import { UniverseChainInfo } from 'uniswap/src/types/chains' import type { Client, Transport } from 'viem' @@ -37,9 +38,13 @@ function clientToProvider(client?: Client, chainId /** Hook to convert a viem Client to an ethers.js Provider with a default disconnected Network fallback. */ export function useEthersProvider({ chainId }: { chainId?: number } = {}) { + const account = useAccount() const { data: client } = useConnectorClient({ chainId }) const disconnectedClient = useClient({ chainId }) - return useMemo(() => clientToProvider(client ?? disconnectedClient, chainId), [chainId, client, disconnectedClient]) + return useMemo( + () => clientToProvider(account.chainId !== chainId ? disconnectedClient : client ?? disconnectedClient, chainId), + [account.chainId, chainId, client, disconnectedClient], + ) } /** Hook to convert a connected viem Client to an ethers.js Provider. */ diff --git a/apps/web/src/hooks/useEthersSigner.ts b/apps/web/src/hooks/useEthersSigner.ts index d51b63e07b4..02fb7512faf 100644 --- a/apps/web/src/hooks/useEthersSigner.ts +++ b/apps/web/src/hooks/useEthersSigner.ts @@ -20,8 +20,6 @@ function clientToSigner(client?: Client) } /** Hook to convert a Viem Client to an ethers.js Signer. */ -// TODO(wagmi migration): Remove eslinst disable when hook is used -// eslint-disable-next-line import/no-unused-modules export function useEthersSigner({ chainId }: { chainId?: number } = {}) { const { data: client } = useConnectorClient({ chainId }) return useMemo(() => clientToSigner(client), [client]) diff --git a/apps/web/src/hooks/useIsPoolsPage.ts b/apps/web/src/hooks/useIsPoolsPage.ts deleted file mode 100644 index e4eeab53b7c..00000000000 --- a/apps/web/src/hooks/useIsPoolsPage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useLocation } from 'react-router-dom' - -export function useIsPoolsPage() { - const { pathname } = useLocation() - return ( - pathname.startsWith('/pools') || - pathname.startsWith('/pool') || - pathname.startsWith('/add') || - pathname.startsWith('/remove') - ) -} diff --git a/apps/web/src/hooks/usePermitAllowance.ts b/apps/web/src/hooks/usePermitAllowance.ts index 0079c2d33db..280ecfa8f8b 100644 --- a/apps/web/src/hooks/usePermitAllowance.ts +++ b/apps/web/src/hooks/usePermitAllowance.ts @@ -5,7 +5,7 @@ import { useContract } from 'hooks/useContract' import { useEthersSigner } from 'hooks/useEthersSigner' import { useSingleCallResult } from 'lib/hooks/multicall' import ms from 'ms' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { trace } from 'tracing/trace' import PERMIT2_ABI from 'uniswap/src/abis/permit2.json' import { Permit2 } from 'uniswap/src/abis/types' @@ -21,7 +21,7 @@ function toDeadline(expiration: number): number { } export function usePermitAllowance(token?: Token, owner?: string, spender?: string) { - const contract = useContract(permit2Address(token?.chainId), PERMIT2_ABI) + const contract = useContract(permit2Address(token?.chainId), PERMIT2_ABI, undefined, token?.chainId) const inputs = useMemo(() => [owner, token?.address, spender], [owner, spender, token?.address]) // If there is no allowance yet, re-check next observed block. @@ -60,19 +60,22 @@ export function useUpdatePermitAllowance( ) { const account = useAccount() const signer = useEthersSigner() + const accountRef = useRef(account) + accountRef.current = account + const signerRef = useRef(signer) + signerRef.current = signer + return useCallback( () => trace({ name: 'Permit2', op: 'permit.permit2.signature' }, async (trace) => { try { + const account = accountRef.current if (account.status !== 'connected') { throw new Error('wallet not connected') } if (!account.chainId) { throw new Error('connected to an unsupported network') } - if (!signer) { - throw new Error('missing signer') - } if (!token) { throw new Error('missing token') } @@ -101,6 +104,10 @@ export function useUpdatePermitAllowance( ) const signature = await trace.child({ name: 'Sign', op: 'wallet.sign' }, async (walletTrace) => { try { + const signer = signerRef.current + if (!signer) { + throw new Error('missing signer') + } return await signTypedData(signer, domain, types, values) } catch (error) { if (didUserReject(error)) { @@ -124,6 +131,6 @@ export function useUpdatePermitAllowance( } } }), - [account.chainId, account.status, nonce, onPermitSignature, signer, spender, token], + [nonce, onPermitSignature, spender, token], ) } diff --git a/apps/web/src/hooks/useSendCallback.ts b/apps/web/src/hooks/useSendCallback.ts index 43a03acf0a0..f79e39390e0 100644 --- a/apps/web/src/hooks/useSendCallback.ts +++ b/apps/web/src/hooks/useSendCallback.ts @@ -5,7 +5,7 @@ import { useAccount } from 'hooks/useAccount' import { useEthersProvider } from 'hooks/useEthersProvider' import { useSwitchChain } from 'hooks/useSwitchChain' import { GasFeeResult } from 'hooks/useTransactionGasFee' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { useTransactionAdder } from 'state/transactions/hooks' import { SendTransactionInfo, TransactionType } from 'state/transactions/types' import { trace } from 'tracing/trace' @@ -25,7 +25,12 @@ export function useSendCallback({ gasFee?: GasFeeResult }) { const account = useAccount() + const accountRef = useRef(account) + accountRef.current = account const provider = useEthersProvider({ chainId: account.chainId }) + const providerRef = useRef(provider) + providerRef.current = provider + const addTransaction = useTransactionAdder() const switchChain = useSwitchChain() const supportedTransactionChainId = useSupportedChainId(transactionRequest?.chainId) @@ -33,12 +38,6 @@ export function useSendCallback({ return useCallback( () => trace({ name: 'Send', op: 'send' }, async (trace) => { - if (account.status !== 'connected') { - throw new Error('wallet must be connected to send') - } - if (!provider) { - throw new Error('missing provider') - } if (!transactionRequest) { throw new Error('missing to transaction to execute') } @@ -57,6 +56,14 @@ export function useSendCallback({ { name: 'Send transaction', op: 'wallet.send_transaction' }, async (walletTrace) => { try { + const account = accountRef.current + const provider = providerRef.current + if (account.status !== 'connected') { + throw new Error('wallet must be connected to send') + } + if (!provider) { + throw new Error('missing provider') + } if (account.chainId !== supportedTransactionChainId) { await switchChain(supportedTransactionChainId) } @@ -91,16 +98,13 @@ export function useSendCallback({ } }), [ - account.status, - account.chainId, - provider, - transactionRequest, - currencyAmount, - recipient, addTransaction, + currencyAmount, gasFee?.params, + recipient, supportedTransactionChainId, switchChain, + transactionRequest, ], ) } diff --git a/apps/web/src/hooks/useSwapCallback.tsx b/apps/web/src/hooks/useSwapCallback.tsx index 53c7194ccb8..488a1195693 100644 --- a/apps/web/src/hooks/useSwapCallback.tsx +++ b/apps/web/src/hooks/useSwapCallback.tsx @@ -13,7 +13,7 @@ import { InterfaceTrade, OffchainOrderType, TradeFillType } from 'state/routing/ import { isClassicTrade, isUniswapXTrade } from 'state/routing/utils' import { useAddOrder } from 'state/signatures/hooks' import { UniswapXOrderDetails } from 'state/signatures/types' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { useTransactionAdder } from 'state/transactions/hooks' import { ExactInputSwapTransactionInfo, diff --git a/apps/web/src/hooks/useSyncChainQuery.ts b/apps/web/src/hooks/useSyncChainQuery.ts index d7e90ebc5fd..286012d21ff 100644 --- a/apps/web/src/hooks/useSyncChainQuery.ts +++ b/apps/web/src/hooks/useSyncChainQuery.ts @@ -5,9 +5,9 @@ import useParsedQueryString from 'hooks/useParsedQueryString' import useSelectChain from 'hooks/useSelectChain' import { useEffect } from 'react' import { useLocation, useSearchParams } from 'react-router-dom' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' import { UniverseChainId } from 'uniswap/src/types/chains' import { usePrevious } from 'utilities/src/react/hooks' import { getParsedChainId } from 'utils/chains' @@ -19,7 +19,9 @@ export default function useSyncChainQuery(chainIdRef: React.MutableRefObject isSyncing: boolean } { - const contract = useTokenContract(token?.address, false) + const { chainId } = useSwapAndLimitContext() + const contract = useTokenContract(token?.address, false, chainId) const inputs = useMemo(() => [owner, spender], [owner, spender]) // If there is no allowance yet, re-check next observed block. @@ -46,13 +48,17 @@ export function useUpdateTokenAllowance( amount: CurrencyAmount | undefined, spender: string, ): () => Promise<{ response: ContractTransaction; info: ApproveTransactionInfo }> { - const contract = useTokenContract(amount?.currency.address) const analyticsTrace = useTrace() + const contract = useTokenContract(amount?.currency.address, true, amount?.currency.chainId) + const contractRef = useRef(contract) + contractRef.current = contract + return useCallback( () => trace({ name: 'Allowance', op: 'permit.allowance' }, async (trace) => { try { + const contract = contractRef.current if (!amount) { throw new Error('missing amount') } @@ -65,7 +71,11 @@ export function useUpdateTokenAllowance( const allowance = amount.equalTo(0) ? '0' : MAX_ALLOWANCE const response = await trace.child({ name: 'Approve', op: 'wallet.approve' }, async (walletTrace) => { + const contract = contractRef.current try { + if (!contract) { + throw new Error('missing contract') + } return await contract.approve(spender, allowance) } catch (error) { if (didUserReject(error)) { @@ -102,7 +112,7 @@ export function useUpdateTokenAllowance( } } }), - [amount, contract, spender, analyticsTrace], + [amount, spender, analyticsTrace], ) } diff --git a/apps/web/src/hooks/useTransactionDeadline.ts b/apps/web/src/hooks/useTransactionDeadline.ts index 1dec433d5c5..8b5ab872a60 100644 --- a/apps/web/src/hooks/useTransactionDeadline.ts +++ b/apps/web/src/hooks/useTransactionDeadline.ts @@ -5,6 +5,7 @@ import { useInterfaceMulticall } from 'hooks/useContract' import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' import { useCallback, useMemo } from 'react' import { useAppSelector } from 'state/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { isL2ChainId } from 'uniswap/src/features/chains/utils' export default function useTransactionDeadline(): BigNumber | undefined { @@ -19,9 +20,9 @@ export default function useTransactionDeadline(): BigNumber | undefined { * Should be used for any submitted transactions, as it uses an on-chain timestamp instead of a client timestamp. */ export function useGetTransactionDeadline(): () => Promise { - const { chainId } = useAccount() + const { chainId } = useSwapAndLimitContext() const ttl = useAppSelector((state) => state.user.userDeadline) - const multicall = useInterfaceMulticall() + const multicall = useInterfaceMulticall(chainId) return useCallback(async () => { const blockTimestamp = await multicall.getCurrentBlockTimestamp() return timestampToDeadline(chainId, blockTimestamp, ttl) diff --git a/apps/web/src/hooks/useUniswapXSwapCallback.ts b/apps/web/src/hooks/useUniswapXSwapCallback.ts index 4bd2b842f09..59e1483b352 100644 --- a/apps/web/src/hooks/useUniswapXSwapCallback.ts +++ b/apps/web/src/hooks/useUniswapXSwapCallback.ts @@ -8,7 +8,7 @@ import { useTotalBalancesUsdForAnalytics } from 'graphql/data/apollo/TokenBalanc import { useAccount } from 'hooks/useAccount' import { useEthersWeb3Provider } from 'hooks/useEthersProvider' import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { DutchOrderTrade, LimitOrderTrade, @@ -16,6 +16,7 @@ import { TradeFillType, V2DutchOrderTrade, } from 'state/routing/types' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { trace } from 'tracing/trace' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -81,8 +82,13 @@ export function useUniswapXSwapCallback({ allowedSlippage: Percent }) { const account = useAccount() - const provider = useEthersWeb3Provider() - const connectorName = useAccount().connector?.name + const accountRef = useRef(account) + accountRef.current = account + + const { chainId } = useSwapAndLimitContext() + const provider = useEthersWeb3Provider({ chainId }) + const providerRef = useRef(provider) + providerRef.current = provider const analyticsContext = useTrace() const portfolioBalanceUsd = useTotalBalancesUsdForAnalytics() @@ -90,6 +96,8 @@ export function useUniswapXSwapCallback({ return useCallback( () => trace({ name: 'Swap (Dutch)', op: 'swap.x.dutch' }, async (trace) => { + const account = accountRef.current + const provider = providerRef.current if (account.status !== 'connected') { throw new Error('wallet not connected') } @@ -100,7 +108,7 @@ export function useUniswapXSwapCallback({ throw new Error('missing trade') } const connectedChainId = await provider.getSigner().getChainId() - if (account.chainId !== connectedChainId) { + if (account.chainId !== connectedChainId || account.chainId !== chainId) { throw new WrongChainError() } @@ -160,6 +168,11 @@ export function useUniswapXSwapCallback({ const signature = await trace.child({ name: 'Sign', op: 'wallet.sign' }, async (walletTrace) => { try { + const provider = providerRef.current + if (!provider) { + throw new Error('missing provider') + } + const account = accountRef.current return await signTypedData(provider.getSigner(account.address), domain, types, values) } catch (error) { if (didUserReject(error)) { @@ -184,7 +197,7 @@ export function useUniswapXSwapCallback({ ...analyticsContext, // TODO (WEB-2993): remove these after debugging missing user properties. [CustomUserProperties.WALLET_ADDRESS]: account.address, - [CustomUserProperties.WALLET_TYPE]: connectorName, + [CustomUserProperties.WALLET_TYPE]: account.connector.name, [CustomUserProperties.PEER_WALLET_AGENT]: provider ? getWalletMeta(provider)?.agent : undefined, }) @@ -278,17 +291,6 @@ export function useUniswapXSwapCallback({ } } }), - [ - account.status, - account.address, - account.chainId, - provider, - trade, - allowedSlippage, - fiatValues, - portfolioBalanceUsd, - analyticsContext, - connectorName, - ], + [chainId, trade, allowedSlippage, fiatValues, portfolioBalanceUsd, analyticsContext], ) } diff --git a/apps/web/src/hooks/useUniversalRouter.ts b/apps/web/src/hooks/useUniversalRouter.ts index c069a4e03e5..f5b5afab550 100644 --- a/apps/web/src/hooks/useUniversalRouter.ts +++ b/apps/web/src/hooks/useUniversalRouter.ts @@ -9,14 +9,15 @@ import { useAccount } from 'hooks/useAccount' import { useEthersWeb3Provider } from 'hooks/useEthersProvider' import { PermitSignature } from 'hooks/usePermitAllowance' import { useGetTransactionDeadline } from 'hooks/useTransactionDeadline' -import { t } from 'i18n' import useBlockNumber from 'lib/hooks/useBlockNumber' import { formatCommonPropertiesForTrade, formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { ClassicTrade, TradeFillType } from 'state/routing/types' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { useUserSlippageTolerance } from 'state/user/hooks' import { trace } from 'tracing/trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { t } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -55,8 +56,13 @@ export function useUniversalRouterSwapCallback( options: SwapOptions, ) { const account = useAccount() - const provider = useEthersWeb3Provider() - const connectorName = useAccount().connector?.name + const accountRef = useRef(account) + accountRef.current = account + + const { chainId } = useSwapAndLimitContext() + const provider = useEthersWeb3Provider({ chainId }) + const providerRef = useRef(provider) + providerRef.current = provider const analyticsContext = useTrace() const blockNumber = useBlockNumber() @@ -68,6 +74,8 @@ export function useUniversalRouterSwapCallback( (): Promise<{ type: TradeFillType.Classic; response: TransactionResponse; deadline?: BigNumber }> => trace({ name: 'Swap (Classic)', op: 'swap.classic' }, async (trace) => { try { + const account = accountRef.current + const provider = providerRef.current if (account.status !== 'connected') { throw new Error('wallet not connected') } @@ -78,7 +86,7 @@ export function useUniversalRouterSwapCallback( throw new Error('missing trade') } const connectedChainId = await provider.getSigner().getChainId() - if (account.chainId !== connectedChainId) { + if (account.chainId !== connectedChainId || account.chainId !== chainId) { throw new WrongChainError() } @@ -94,7 +102,7 @@ export function useUniversalRouterSwapCallback( }) const tx = { from: account.address, - to: UNIVERSAL_ROUTER_ADDRESS(account.chainId), + to: UNIVERSAL_ROUTER_ADDRESS(chainId), data, // TODO(https://github.com/Uniswap/universal-router-sdk/issues/113): universal-router-sdk returns a non-hexlified value. ...(value && !isZero(value) ? { value: toHex(value) } : {}), @@ -122,6 +130,10 @@ export function useUniversalRouterSwapCallback( { name: 'Send transaction', op: 'wallet.send_transaction' }, async (walletTrace) => { try { + const provider = providerRef.current + if (!provider) { + throw new Error('missing provider') + } return await provider.getSigner().sendTransaction({ ...tx, gasLimit }) } catch (error) { if (didUserReject(error)) { @@ -145,7 +157,7 @@ export function useUniversalRouterSwapCallback( ...analyticsContext, // TODO (WEB-2993): remove these after debugging missing user properties. [CustomUserProperties.WALLET_ADDRESS]: account.address, - [CustomUserProperties.WALLET_TYPE]: connectorName, + [CustomUserProperties.WALLET_TYPE]: account.connector.name, [CustomUserProperties.PEER_WALLET_AGENT]: provider ? getWalletMeta(provider)?.agent : undefined, }) if (tx.data !== response.data) { @@ -176,11 +188,8 @@ export function useUniversalRouterSwapCallback( } }), [ - account.status, - account.chainId, - account.address, - provider, trade, + chainId, getDeadline, options.slippageTolerance, options.permit, @@ -189,7 +198,6 @@ export function useUniversalRouterSwapCallback( fiatValues, portfolioBalanceUsd, analyticsContext, - connectorName, blockNumber, isAutoSlippage, ], diff --git a/apps/web/src/hooks/useWrapCallback.tsx b/apps/web/src/hooks/useWrapCallback.tsx index f0c97a8c0a5..99e43605637 100644 --- a/apps/web/src/hooks/useWrapCallback.tsx +++ b/apps/web/src/hooks/useWrapCallback.tsx @@ -3,17 +3,17 @@ import { Currency } from '@uniswap/sdk-core' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' import { useWETHContract } from 'hooks/useContract' -import { Trans } from 'i18n' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import { formatToDecimal, getTokenAddress } from 'lib/utils/analytics' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useCurrencyBalance } from 'state/connection/hooks' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { useTransactionAdder } from 'state/transactions/hooks' import { TransactionType } from 'state/transactions/types' import { trace } from 'tracing/trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { WrapType } from 'uniswap/src/types/wrap' import { logger } from 'utilities/src/logger/logger' @@ -60,7 +60,11 @@ export default function useWrapCallback( ): { wrapType: WrapType; execute?: () => Promise; inputError?: WrapInputError } { const account = useAccount() const { chainId } = useSwapAndLimitContext() + const wethContract = useWETHContract(true, chainId) + const wethContractRef = useRef(wethContract) + wethContractRef.current = wethContract + const balance = useCurrencyBalance(account.address, inputCurrency ?? undefined) // we can always parse the amount typed as the input currency, since wrapping is 1:1 const inputAmount = useMemo( @@ -77,7 +81,7 @@ export default function useWrapCallback( } return useMemo(() => { - if (!wethContract || !chainId || !inputCurrency || !outputCurrency) { + if (!wethContractRef.current || !chainId || !inputCurrency || !outputCurrency) { return NOT_APPLICABLE } const weth = WRAPPED_NATIVE_CURRENCY[chainId] @@ -104,6 +108,10 @@ export default function useWrapCallback( sufficientBalance && inputAmount ? () => trace({ name: 'Wrap', op: 'swap.wrap' }, async (trace) => { + const wethContract = wethContractRef.current + if (!wethContract) { + throw new Error('wethContract is null') + } const network = await wethContract.provider.getNetwork() if ( network.chainId !== chainId || @@ -151,6 +159,10 @@ Please file a bug detailing how this happened - https://github.com/Uniswap/inter ? () => trace({ name: 'Wrap', op: 'swap.wrap' }, async (trace) => { try { + const wethContract = wethContractRef.current + if (!wethContract) { + throw new Error('wethContract is null') + } const txReceipt = await trace.child({ name: 'Withdraw', op: 'wallet.send_transaction' }, () => wethContract.withdraw(`0x${inputAmount.quotient.toString(16)}`), ) @@ -180,5 +192,5 @@ Please file a bug detailing how this happened - https://github.com/Uniswap/inter } else { return NOT_APPLICABLE } - }, [wethContract, chainId, inputCurrency, outputCurrency, inputAmount, balance, addTransaction]) + }, [chainId, inputCurrency, outputCurrency, inputAmount, balance, addTransaction]) } diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx deleted file mode 100644 index 039c79e2bb9..00000000000 --- a/apps/web/src/i18n.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { dynamicActivate } from 'i18n/dynamicActivate' -import { initialLocale } from 'i18n/initialLocale' -import enUsLocale from 'i18n/locales/source/en-US.json' -import i18n from 'i18next' -import resourcesToBackend from 'i18next-resources-to-backend' -import { initReactI18next } from 'react-i18next' -import { logger } from 'utilities/src/logger/logger' - -export { t } from 'i18next' -export { Plural } from './i18n/Plural' -export { Trans } from './i18n/Trans' - -i18n - .use(initReactI18next) - .use( - resourcesToBackend((language: string) => { - // not sure why but it tries to load es THEN es-ES, for any language, but we just want the second - if (!language.includes('-')) { - return - } - if (language === 'en-US') { - return enUsLocale - } - return import(`./i18n/locales/translations/${language}.json`) - }), - ) - .on('failedLoading', (language, namespace, msg) => { - logger.error(new Error(`Error loading language ${language} ${namespace}: ${msg}`), { - tags: { - file: 'i18n', - function: 'onFailedLoading', - }, - }) - }) - -i18n - .init({ - returnEmptyString: false, - keySeparator: false, - lng: 'en-US', - fallbackLng: 'en-US', - interpolation: { - escapeValue: false, // react already safes from xss - }, - }) - .catch(() => undefined) - -// add default english ns right away -i18n.addResourceBundle('en-US', 'translations', { - 'en-US': { - translation: enUsLocale, - }, -}) - -dynamicActivate(initialLocale) diff --git a/apps/web/src/i18n/LanguageProvider.tsx b/apps/web/src/i18n/LanguageProvider.tsx index 0b7052331c1..bb989ea6bfb 100644 --- a/apps/web/src/i18n/LanguageProvider.tsx +++ b/apps/web/src/i18n/LanguageProvider.tsx @@ -1,7 +1,18 @@ -import { useActiveLocale } from 'hooks/useActiveLocale' -import { dynamicActivate } from 'i18n/dynamicActivate' +import { DEFAULT_LOCALE } from 'constants/locales' +import { navigatorLocale, parseLocale, storeLocale, useActiveLocale } from 'hooks/useActiveLocale' import { ReactNode, useEffect } from 'react' import { useUserLocaleManager } from 'state/user/hooks' +import { changeLanguage } from 'uniswap/src/i18n' + +function setupInitialLanguage() { + const lngQuery = typeof window !== 'undefined' ? new URL(window.location.href).searchParams.get('lng') : '' + const initialLocale = parseLocale(lngQuery) ?? storeLocale() ?? navigatorLocale() ?? DEFAULT_LOCALE + changeLanguage(initialLocale) +} + +if (process.env.NODE_ENV !== 'test') { + setupInitialLanguage() +} export function LanguageProvider({ children }: { children: ReactNode }): JSX.Element { const activeLocale = useActiveLocale() @@ -9,7 +20,7 @@ export function LanguageProvider({ children }: { children: ReactNode }): JSX.Ele const locale = userLocale || activeLocale useEffect(() => { - dynamicActivate(locale) + changeLanguage(locale) document.documentElement.setAttribute('lang', locale) setUserLocale(locale) // stores the selected locale to persist across sessions }, [setUserLocale, locale]) diff --git a/apps/web/src/i18n/initialLocale.ts b/apps/web/src/i18n/initialLocale.ts deleted file mode 100644 index 657521d1b92..00000000000 --- a/apps/web/src/i18n/initialLocale.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DEFAULT_LOCALE } from 'constants/locales' -import { navigatorLocale, parseLocale, storeLocale } from 'hooks/useActiveLocale' -import { parsedQueryString } from 'hooks/useParsedQueryString' - -export const initialLocale = - parseLocale(parsedQueryString().lng) ?? storeLocale() ?? navigatorLocale() ?? DEFAULT_LOCALE diff --git a/apps/web/src/i18n/useTranslation.tsx b/apps/web/src/i18n/useTranslation.tsx deleted file mode 100644 index 5a8fdb77639..00000000000 --- a/apps/web/src/i18n/useTranslation.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import i18n, { t } from 'i18next' -import { useTranslation as useTranslationOG } from 'react-i18next' -import { isTestEnv } from 'utilities/src/environment' - -export function useTranslation() { - if (isTestEnv()) { - return { i18n, t } - } - // eslint-disable-next-line react-hooks/rules-of-hooks - return useTranslationOG() -} diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index a5be2b4abff..e22bac5ced3 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -1,17 +1,5 @@ -// Ordering is intentional and must be preserved: styling, polyfilling, tracing, and then functionality. -// prettier-ignore -import '@reach/dialog/styles.css' -// prettier-ignore -import 'inter-ui' -// prettier-ignore -import 'polyfills' -// prettier-ignore -import 'tracing' -// ensure translations load before things -// prettier-ignore -import 'i18n' -// prettier-ignore -import 'setupRive' +// Ordering is intentional and must be preserved: sideEffects followed by functionality. +import 'sideEffects' import { getDeviceId } from '@amplitude/analytics-browser' import { ApolloProvider } from '@apollo/client' diff --git a/apps/web/src/lib/hooks/multicall.ts b/apps/web/src/lib/hooks/multicall.ts index 5f901fea6f2..d2004e74852 100644 --- a/apps/web/src/lib/hooks/multicall.ts +++ b/apps/web/src/lib/hooks/multicall.ts @@ -1,5 +1,5 @@ -import { useAccount } from 'hooks/useAccount' -import useBlockNumber, { useMainnetBlockNumber } from 'lib/hooks/useBlockNumber' +import { useMainnetBlockNumber } from 'lib/hooks/useBlockNumber' +import { useCallContext } from 'lib/hooks/useCallContext' import multicall from 'lib/state/multicall' import { SkipFirst } from 'types/tuple' import { UniverseChainId } from 'uniswap/src/types/chains' @@ -34,9 +34,3 @@ export function useSingleContractMultipleData( const { chainId, latestBlock } = useCallContext() return multicall.hooks.useSingleContractMultipleData(chainId, latestBlock, ...args) } - -function useCallContext() { - const { chainId } = useAccount() - const latestBlock = useBlockNumber() - return { chainId, latestBlock } -} diff --git a/apps/web/src/lib/hooks/useApproval.ts b/apps/web/src/lib/hooks/useApproval.ts index 37c76468bb5..df31c685bfa 100644 --- a/apps/web/src/lib/hooks/useApproval.ts +++ b/apps/web/src/lib/hooks/useApproval.ts @@ -67,7 +67,7 @@ export function useApproval( // check the current approval status const approvalState = useApprovalStateForSpender(amountToApprove, spender, useIsPendingApproval) - const tokenContract = useTokenContract(token?.address) + const tokenContract = useTokenContract(token?.address, undefined, token?.chainId) const approve = useCallback(async () => { function logFailure(error: Error | string): undefined { diff --git a/apps/web/src/lib/hooks/useBlockNumber.tsx b/apps/web/src/lib/hooks/useBlockNumber.tsx index 6d78c4e6f97..0e8d21c12b0 100644 --- a/apps/web/src/lib/hooks/useBlockNumber.tsx +++ b/apps/web/src/lib/hooks/useBlockNumber.tsx @@ -1,11 +1,21 @@ -import { useWeb3React } from '@web3-react/core' +/* eslint-disable import/no-unused-modules */ +// TODO(WEB-4448): for multichain, refactored our custom useBlockNumber in favor of wagmi's hook. Remove this provider import { RPC_PROVIDERS } from 'constants/providers' import { useAccount } from 'hooks/useAccount' +import { useEthersProvider } from 'hooks/useEthersProvider' import useIsWindowVisible from 'hooks/useIsWindowVisible' +import { atom } from 'jotai' +import { useAtomValue } from 'jotai/utils' import { PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { UniverseChainId } from 'uniswap/src/types/chains' +// MulticallUpdater is outside of the SwapAndLimitContext but we still want to use the swap context chainId for swap-related multicalls +export const multicallUpdaterSwapChainIdAtom = atom(undefined) + const MISSING_PROVIDER = Symbol() + export const BlockNumberContext = createContext< | { fastForward(block: number): void @@ -14,7 +24,6 @@ export const BlockNumberContext = createContext< } | typeof MISSING_PROVIDER >(MISSING_PROVIDER) - function useBlockNumberContext() { const blockNumber = useContext(BlockNumberContext) if (blockNumber === MISSING_PROVIDER) { @@ -22,30 +31,28 @@ function useBlockNumberContext() { } return blockNumber } - export function useFastForwardBlockNumber(): (block: number) => void { return useBlockNumberContext().fastForward } - /** Requires that BlockUpdater be installed in the DOM tree. */ export default function useBlockNumber(): number | undefined { return useBlockNumberContext().block } - export function useMainnetBlockNumber(): number | undefined { return useBlockNumberContext().mainnetBlock } - export function BlockNumberProvider({ children }: PropsWithChildren) { const account = useAccount() - const { provider } = useWeb3React() + const multicallUpdaterSwapChainId = useAtomValue(multicallUpdaterSwapChainIdAtom) + const multichainFlagEnabled = useFeatureFlag(FeatureFlags.MultichainUX) + const multicallChainId = multichainFlagEnabled ? multicallUpdaterSwapChainId ?? account.chainId : account.chainId + const provider = useEthersProvider({ chainId: multicallChainId }) const [{ chainId, block, mainnetBlock }, setChainBlock] = useState<{ chainId?: number block?: number mainnetBlock?: number }>({}) - const activeBlock = chainId === account.chainId ? block : undefined - + const activeBlock = chainId === multicallChainId ? block : undefined const onChainBlock = useCallback((chainId: number | undefined, block: number) => { setChainBlock((chainBlock) => { if (chainBlock.chainId === chainId) { @@ -61,28 +68,25 @@ export function BlockNumberProvider({ children }: PropsWithChildren) { return chainBlock }) }, []) - // Poll for block number on the active provider. const windowVisible = useIsWindowVisible() useEffect(() => { - if (provider && account.chainId && windowVisible) { + if (provider && multicallChainId && windowVisible) { setChainBlock((chainBlock) => { - if (chainBlock.chainId !== account.chainId) { - return { chainId: account.chainId, mainnetBlock: chainBlock.mainnetBlock } + if (chainBlock.chainId !== multicallChainId) { + return { chainId: multicallChainId, mainnetBlock: chainBlock.mainnetBlock } } // If chainId hasn't changed, don't invalidate the reference, as it will trigger re-fetching of still-valid data. return chainBlock }) - - const onBlock = (block: number) => onChainBlock(account.chainId, block) + const onBlock = (block: number) => onChainBlock(multicallChainId, block) provider.on('block', onBlock) return () => { provider.removeListener('block', onBlock) } } return - }, [account.chainId, provider, windowVisible, onChainBlock]) - + }, [provider, windowVisible, onChainBlock, multicallChainId]) // Poll once for the mainnet block number using the network provider. useEffect(() => { RPC_PROVIDERS[UniverseChainId.Mainnet] @@ -91,18 +95,17 @@ export function BlockNumberProvider({ children }: PropsWithChildren) { // swallow errors - it's ok if this fails, as we'll try again if we activate mainnet .catch(() => undefined) }, [onChainBlock]) - const value = useMemo( () => ({ fastForward: (update: number) => { - if (account.chainId) { - onChainBlock(account.chainId, update) + if (multicallChainId) { + onChainBlock(multicallChainId, update) } }, block: activeBlock, mainnetBlock, }), - [activeBlock, account.chainId, mainnetBlock, onChainBlock], + [activeBlock, mainnetBlock, multicallChainId, onChainBlock], ) return {children} } diff --git a/apps/web/src/lib/hooks/useCallContext.ts b/apps/web/src/lib/hooks/useCallContext.ts new file mode 100644 index 00000000000..e8456461ab9 --- /dev/null +++ b/apps/web/src/lib/hooks/useCallContext.ts @@ -0,0 +1,8 @@ +import useBlockNumber from 'lib/hooks/useBlockNumber' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' + +export function useCallContext() { + const { chainId } = useSwapAndLimitContext() + const latestBlock = useBlockNumber() + return { chainId, latestBlock } +} diff --git a/apps/web/src/lib/state/multicall.tsx b/apps/web/src/lib/state/multicall.tsx index 39bf756ca87..b9dd0445f31 100644 --- a/apps/web/src/lib/state/multicall.tsx +++ b/apps/web/src/lib/state/multicall.tsx @@ -1,10 +1,12 @@ import { createMulticall, ListenerOptions } from '@uniswap/redux-multicall' -import { useSupportedChainId } from 'constants/chains' import { useAccount } from 'hooks/useAccount' import { useInterfaceMulticall, useMainnetInterfaceMulticall } from 'hooks/useContract' -import useBlockNumber, { useMainnetBlockNumber } from 'lib/hooks/useBlockNumber' +import { useAtomValue } from 'jotai/utils' +import useBlockNumber, { multicallUpdaterSwapChainIdAtom, useMainnetBlockNumber } from 'lib/hooks/useBlockNumber' import { useMemo } from 'react' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { UniverseChainId } from 'uniswap/src/types/chains' const multicall = createMulticall() @@ -14,13 +16,15 @@ export default multicall const MAINNET_LISTENER_OPTIONS = { blocksPerFetch: 1 } export function MulticallUpdater() { - const { chainId } = useAccount() - const supportedChain = useSupportedChainId(chainId) + const account = useAccount() + const multichainFlagEnabled = useFeatureFlag(FeatureFlags.MultichainUX) + const multicallUpdaterSwapChainId = useAtomValue(multicallUpdaterSwapChainIdAtom) + const chainId = multichainFlagEnabled ? multicallUpdaterSwapChainId ?? account.chainId : account.chainId const latestBlockNumber = useBlockNumber() - const contract = useInterfaceMulticall() + const contract = useInterfaceMulticall(chainId) const listenerOptions: ListenerOptions = useMemo( - () => ({ blocksPerFetch: supportedChain ? UNIVERSE_CHAIN_INFO[supportedChain].blockPerMainnetEpochForChainId : 1 }), - [supportedChain], + () => ({ blocksPerFetch: chainId ? UNIVERSE_CHAIN_INFO[chainId].blockPerMainnetEpochForChainId : 1 }), + [chainId], ) const latestMainnetBlockNumber = useMainnetBlockNumber() diff --git a/apps/web/src/lib/utils/searchBar.ts b/apps/web/src/lib/utils/searchBar.ts index 0a8425f67a8..8adcdf81f07 100644 --- a/apps/web/src/lib/utils/searchBar.ts +++ b/apps/web/src/lib/utils/searchBar.ts @@ -1,5 +1,8 @@ -import { SearchToken } from 'graphql/data/SearchTokens' +import { SearchToken, TokenSearchResultWeb } from 'graphql/data/SearchTokens' import { GenieCollection } from 'nft/types' +import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { SearchResultType } from 'uniswap/src/features/search/SearchResult' +import { UniverseChainId } from 'uniswap/src/types/chains' /** * Organizes the number of Token and NFT results to be shown to a user depending on if they're in the NFT or Token experience @@ -19,3 +22,34 @@ export function organizeSearchResults( const reducedCollections = collectionResults.slice(0, 8 - reducedTokens.length) return [reducedTokens, reducedCollections] } + +export const searchTokenToTokenSearchResult = ( + searchToken: SearchToken & { chainId: UniverseChainId; address: string; isToken: boolean; isNative: boolean }, +): TokenSearchResultWeb => { + return { + type: SearchResultType.Token, + chain: searchToken.chain, + chainId: searchToken.chainId, + symbol: searchToken.symbol ?? '', + address: searchToken.address, + name: searchToken.name ?? null, + isToken: searchToken.isToken, + isNative: searchToken.isNative, + logoUrl: searchToken.project?.logoUrl ?? null, + safetyLevel: searchToken.project?.safetyLevel ?? null, + } +} + +export const searchGenieCollectionToTokenSearchResult = (searchToken: GenieCollection): TokenSearchResultWeb => { + return { + type: SearchResultType.NFTCollection, + chain: Chain.Ethereum, + chainId: UniverseChainId.Mainnet, + symbol: '', + address: searchToken.address ?? '', + name: searchToken.name ?? null, + logoUrl: searchToken.imageUrl, + safetyLevel: null, + isNft: true, + } +} diff --git a/apps/web/src/nft/components/bag/Bag.tsx b/apps/web/src/nft/components/bag/Bag.tsx index e1cd460be80..dd7c0b6ac4f 100644 --- a/apps/web/src/nft/components/bag/Bag.tsx +++ b/apps/web/src/nft/components/bag/Bag.tsx @@ -1,7 +1,6 @@ import { NFTEventName } from '@uniswap/analytics-events' import { useIsMobile } from 'hooks/screenSize' import { useIsNftDetailsPage, useIsNftPage, useIsNftProfilePage } from 'hooks/useIsNftPage' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Box } from 'nft/components/Box' import { Column } from 'nft/components/Flex' @@ -19,6 +18,7 @@ import { formatAssetEventProperties, recalculateBagUsingPooledAssets } from 'nft import { useCallback, useEffect, useMemo, useState } from 'react' import { Z_INDEX } from 'theme/zIndex' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' export const BAG_WIDTH = 320 export const XXXL_BAG_WIDTH = 360 @@ -179,7 +179,7 @@ const Bag = () => { }) }} > - + )} diff --git a/apps/web/src/nft/components/bag/BagFooter.tsx b/apps/web/src/nft/components/bag/BagFooter.tsx index 28b87e5fda6..8d006fba363 100644 --- a/apps/web/src/nft/components/bag/BagFooter.tsx +++ b/apps/web/src/nft/components/bag/BagFooter.tsx @@ -7,8 +7,8 @@ import Column from 'components/Column' import Loader from 'components/Icons/LoadingSpinner' import CurrencyLogo from 'components/Logo/CurrencyLogo' import Row from 'components/Row' -import { CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' import CurrencySearchModal from 'components/SearchModal/CurrencySearchModal' +import { CurrencySearchFilters } from 'components/SearchModal/DeprecatedCurrencySearch' import { LoadingBubble } from 'components/Tokens/loading' import { MouseoverTooltip } from 'components/Tooltip' import { useIsSupportedChainId } from 'constants/chains' @@ -19,7 +19,6 @@ import { useAccount } from 'hooks/useAccount' import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance' import { useStablecoinValue } from 'hooks/useStablecoinPrice' import { useSwitchChain } from 'hooks/useSwitchChain' -import { Trans, t } from 'i18n' import JSBI from 'jsbi' import useCurrencyBalance, { useTokenBalance } from 'lib/hooks/useCurrencyBalance' import styled, { useTheme } from 'lib/styled-components' @@ -40,6 +39,7 @@ import { InterfaceTrade, TradeFillType, TradeState } from 'state/routing/types' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/nft/components/bag/BagHeader.tsx b/apps/web/src/nft/components/bag/BagHeader.tsx index 2bc391bc7ce..c2a5d91409e 100644 --- a/apps/web/src/nft/components/bag/BagHeader.tsx +++ b/apps/web/src/nft/components/bag/BagHeader.tsx @@ -1,9 +1,9 @@ import { OpacityHoverState } from 'components/Common' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { BagCloseIcon } from 'nft/components/icons' import { useMemo } from 'react' import { ButtonText, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const ClearButton = styled(ButtonText)` color: ${({ theme }) => theme.neutral2}; @@ -85,7 +85,7 @@ export const BagHeader = ({ numberOfAssets, closeBag, resetFlow, isProfilePage } <> {numberOfAssets} - + )} diff --git a/apps/web/src/nft/components/bag/ButtonStates.tsx b/apps/web/src/nft/components/bag/ButtonStates.tsx index 36f822df659..141793d0d9d 100644 --- a/apps/web/src/nft/components/bag/ButtonStates.tsx +++ b/apps/web/src/nft/components/bag/ButtonStates.tsx @@ -1,7 +1,7 @@ -import { Trans } from 'i18n' import { DefaultTheme } from 'lib/styled-components' import { PriceImpact } from 'nft/hooks/usePriceImpact' import { ReactNode } from 'react' +import { Trans } from 'uniswap/src/i18n' export enum BuyButtonStates { WALLET_NOT_CONNECTED, diff --git a/apps/web/src/nft/components/card/icons.tsx b/apps/web/src/nft/components/card/icons.tsx index 080999d12c2..efe9a9895c2 100644 --- a/apps/web/src/nft/components/card/icons.tsx +++ b/apps/web/src/nft/components/card/icons.tsx @@ -1,6 +1,5 @@ import Row from 'components/Row' import { MouseoverTooltip } from 'components/Tooltip' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { getMarketplaceIcon } from 'nft/components/card/utils' import { CollectionSelectedAssetIcon } from 'nft/components/icons' @@ -8,6 +7,7 @@ import { Markets } from 'nft/types' import { AlertTriangle, Check, Tag } from 'react-feather' import { ThemedText } from 'theme/components' import { NftStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const StyledMarketplaceContainer = styled.div<{ isText?: boolean }>` diff --git a/apps/web/src/nft/components/card/media.tsx b/apps/web/src/nft/components/card/media.tsx index 423721a148c..d2d2163012e 100644 --- a/apps/web/src/nft/components/card/media.tsx +++ b/apps/web/src/nft/components/card/media.tsx @@ -1,5 +1,4 @@ import Row from 'components/Row' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { getHeightFromAspectRatio, getMediaAspectRatio, handleUniformAspectRatio } from 'nft/components/card/utils' import { UniformAspectRatio, UniformAspectRatios } from 'nft/types' @@ -8,6 +7,7 @@ import { Pause, Play } from 'react-feather' import { BREAKPOINTS } from 'theme' import { colors } from 'theme/colors' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const StyledImageContainer = styled.div<{ isDisabled?: boolean }>` position: relative; @@ -247,9 +247,7 @@ const NoContentContainer = ({ height }: { height?: number }) => ( <> - -
- + }} />
diff --git a/apps/web/src/nft/components/collection/ActivityCells.tsx b/apps/web/src/nft/components/collection/ActivityCells.tsx index 38a31869134..4687354f653 100644 --- a/apps/web/src/nft/components/collection/ActivityCells.tsx +++ b/apps/web/src/nft/components/collection/ActivityCells.tsx @@ -1,6 +1,5 @@ import { InterfacePageName, NFTEventName } from '@uniswap/analytics-events' import { MouseoverTooltip } from 'components/Tooltip' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Box } from 'nft/components/Box' import { Column, Row } from 'nft/components/Flex' @@ -34,6 +33,7 @@ import { OrderStatus, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { shortenAddress } from 'utilities/src/addresses' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' diff --git a/apps/web/src/nft/components/collection/CollectionAsset.tsx b/apps/web/src/nft/components/collection/CollectionAsset.tsx index 6920aff4560..dbe5725ffe6 100644 --- a/apps/web/src/nft/components/collection/CollectionAsset.tsx +++ b/apps/web/src/nft/components/collection/CollectionAsset.tsx @@ -1,12 +1,12 @@ import { BigNumber } from '@ethersproject/bignumber' import { InterfacePageName, NFTEventName } from '@uniswap/analytics-events' -import { Trans } from 'i18n' import { NftCard, NftCardDisplayProps } from 'nft/components/card' import { Ranking as RankingContainer, Suspicious as SuspiciousContainer } from 'nft/components/card/icons' import { useBag } from 'nft/hooks' import { GenieAsset, UniformAspectRatio } from 'nft/types' import { useCallback, useMemo } from 'react' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/nft/components/collection/Sweep.tsx b/apps/web/src/nft/components/collection/Sweep.tsx index e537ee229dd..69871c08c5c 100644 --- a/apps/web/src/nft/components/collection/Sweep.tsx +++ b/apps/web/src/nft/components/collection/Sweep.tsx @@ -3,7 +3,6 @@ import 'rc-slider/assets/index.css' import { BigNumber } from '@ethersproject/bignumber' import { formatEther as ethersFormatEther, parseEther } from '@ethersproject/units' import { SweepFetcherParams, useSweepNftAssets } from 'graphql/data/nft/Asset' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useBag, useCollectionFilters } from 'nft/hooks' import { GenieAsset, Markets, isPooledMarket } from 'nft/types' @@ -11,6 +10,7 @@ import { calcPoolPrice, isInSameSudoSwapPool } from 'nft/utils' import { default as Slider } from 'rc-slider' import { useEffect, useMemo, useReducer, useState } from 'react' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const SweepContainer = styled.div` diff --git a/apps/web/src/nft/components/collection/TransactionCompleteModal.tsx b/apps/web/src/nft/components/collection/TransactionCompleteModal.tsx index d01d8e8649c..80918b323d9 100644 --- a/apps/web/src/nft/components/collection/TransactionCompleteModal.tsx +++ b/apps/web/src/nft/components/collection/TransactionCompleteModal.tsx @@ -4,7 +4,6 @@ import clsx from 'clsx' import { OpacityHoverState } from 'components/Common' import { UniIcon } from 'components/Logo/UniIcon' import { useIsMobile } from 'hooks/screenSize' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Box } from 'nft/components/Box' import { Row } from 'nft/components/Flex' @@ -19,6 +18,7 @@ import { generateTweetForPurchase, getSuccessfulImageSize, parseTransactionRespo import { formatAssetEventProperties } from 'nft/utils/formatEventProperties' import { useEffect, useMemo, useRef, useState } from 'react' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { NumberType, useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/nft/components/collection/UnavailableCollectionPage.tsx b/apps/web/src/nft/components/collection/UnavailableCollectionPage.tsx index 5d43cb1d703..c319372e926 100644 --- a/apps/web/src/nft/components/collection/UnavailableCollectionPage.tsx +++ b/apps/web/src/nft/components/collection/UnavailableCollectionPage.tsx @@ -1,9 +1,9 @@ import Column from 'components/Column' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { AlertTriangle } from 'react-feather' import { ExternalLink, StyledInternalLink, ThemedText } from 'theme/components' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { Trans } from 'uniswap/src/i18n' const Container = styled(Column)` height: 75vh; diff --git a/apps/web/src/nft/components/details/AssetActivity.tsx b/apps/web/src/nft/components/details/AssetActivity.tsx index 62a298cc5bb..805360ea486 100644 --- a/apps/web/src/nft/components/details/AssetActivity.tsx +++ b/apps/web/src/nft/components/details/AssetActivity.tsx @@ -1,12 +1,12 @@ import { OpacityHoverState, ScrollBarStyles } from 'components/Common' import { LoadingBubble } from 'components/Tokens/loading' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { EventCell } from 'nft/components/collection/ActivityCells' import { ActivityEvent } from 'nft/types' import { getMarketplaceIcon } from 'nft/utils' import { getTimeDifference } from 'nft/utils/date' import { ReactNode } from 'react' +import { Trans } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/nft/components/icons.tsx b/apps/web/src/nft/components/icons.tsx index c34124d998d..d9e92393045 100644 --- a/apps/web/src/nft/components/icons.tsx +++ b/apps/web/src/nft/components/icons.tsx @@ -1,5 +1,5 @@ import styled, { useTheme } from 'lib/styled-components' -import { themeVars, vars } from 'nft/css/sprinkles.css' +import { themeVars } from 'nft/css/sprinkles.css' import React from 'react' // ESLint reports `fill` is missing, whereas it exists on an SVGProps type @@ -235,15 +235,6 @@ export const BrokenLinkIcon = (props: SVGProps) => ( ) -export const ClockIcon = () => ( - - - -) - export const ApprovedCheckmarkIcon = (props: SVGProps) => ( @@ -265,26 +256,6 @@ export const FilterIcon = (props: SVGProps) => ( ) -export const NavMagnifyingGlassIcon = () => ( - - - - -) - export const BagIcon = (props: SVGProps) => ( ( - - - -) export const ActivityListingIcon = (props: SVGProps) => ( ` background: ${({ showResolveIssues, theme }) => (showResolveIssues ? theme.critical : theme.accent1)}; diff --git a/apps/web/src/nft/components/profile/list/MarketplaceRow.tsx b/apps/web/src/nft/components/profile/list/MarketplaceRow.tsx index 98e00400d8f..cd115c6d4de 100644 --- a/apps/web/src/nft/components/profile/list/MarketplaceRow.tsx +++ b/apps/web/src/nft/components/profile/list/MarketplaceRow.tsx @@ -3,7 +3,6 @@ import { CollapsedIcon } from 'components/Icons/Collapse' import { ExpandIcon } from 'components/Icons/Expand' import Row from 'components/Row' import { MouseoverTooltip } from 'components/Tooltip' -import { t } from 'i18n' import styled from 'lib/styled-components' import { PriceTextInput } from 'nft/components/profile/list/PriceTextInput' import { RoyaltyTooltip } from 'nft/components/profile/list/RoyaltyTooltip' @@ -16,6 +15,7 @@ import { getMarketplaceIcon } from 'nft/utils' import { Dispatch, DispatchWithoutAction, useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { BREAKPOINTS } from 'theme' import { ThemedText } from 'theme/components' +import { t } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const LastPriceInfo = styled(Column)` @@ -160,6 +160,13 @@ export const MarketplaceRow = ({ return maxFee }, [asset, selectedMarkets]) + const feesDelta = formatDelta(fees) + const feesDeltaDisplay = + selectedMarkets.length > 1 + ? t('nfts.marketplace.fees.deltaMax', { + percentChanged: feesDelta, + }) + : feesDelta const feeInEth = price && (price * fees) / 100 const userReceives = price && feeInEth && price - feeInEth @@ -242,9 +249,7 @@ export const MarketplaceRow = ({ placement="left" > - - {fees > 0 ? `${formatDelta(fees)}${selectedMarkets.length > 1 ? t('max') : ''}` : '--%'} - + {fees > 0 ? feesDeltaDisplay : '--%'} diff --git a/apps/web/src/nft/components/profile/list/Modal/BelowFloorWarningModal.tsx b/apps/web/src/nft/components/profile/list/Modal/BelowFloorWarningModal.tsx index e956596b226..3539d0b6635 100644 --- a/apps/web/src/nft/components/profile/list/Modal/BelowFloorWarningModal.tsx +++ b/apps/web/src/nft/components/profile/list/Modal/BelowFloorWarningModal.tsx @@ -1,6 +1,5 @@ import { ButtonPrimary } from 'components/Button' import Column from 'components/Column' -import { Plural, Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { Portal } from 'nft/components/common/Portal' import { Overlay } from 'nft/components/modals/Overlay' @@ -10,6 +9,7 @@ import { AlertTriangle, X } from 'react-feather' import { BREAKPOINTS } from 'theme' import { ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { useFormatter } from 'utils/formatNumbers' const ModalWrapper = styled(Column)` @@ -82,6 +82,7 @@ export const BelowFloorWarningModal = ({ closeModal: () => void startListing: () => void }) => { + const { t } = useTranslation() const theme = useTheme() const { formatDelta } = useFormatter() const clickContinue = (e: React.MouseEvent) => { @@ -90,6 +91,11 @@ export const BelowFloorWarningModal = ({ startListing() closeModal() } + + const delta = formatDelta( + (1 - (listingsBelowFloor[0][1].price ?? 0) / (listingsBelowFloor[0][0].floorPrice ?? 0)) * 100, + ) + return ( @@ -103,22 +109,13 @@ export const BelowFloorWarningModal = ({ - -   - + {t('nft.listedSignificantly', { + count: listingsBelowFloor.length, + percentage: delta, + })} - + diff --git a/apps/web/src/nft/components/profile/list/Modal/ContentRow.tsx b/apps/web/src/nft/components/profile/list/Modal/ContentRow.tsx index 80cf7b9f00e..56e571e9ef6 100644 --- a/apps/web/src/nft/components/profile/list/Modal/ContentRow.tsx +++ b/apps/web/src/nft/components/profile/list/Modal/ContentRow.tsx @@ -1,7 +1,6 @@ import Column from 'components/Column' import Loader from 'components/Icons/LoadingSpinner' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { css, useTheme } from 'lib/styled-components' import { VerifiedIcon } from 'nft/components/icons' import { AssetRow, CollectionRow, ListingStatus } from 'nft/types' @@ -10,6 +9,7 @@ import { useEffect, useRef } from 'react' import { Check, XOctagon } from 'react-feather' import { ThemedText } from 'theme/components' import { opacify } from 'theme/utils' +import { Trans } from 'uniswap/src/i18n' const ContentColumn = styled(Column)<{ failed: boolean }>` background-color: ${({ theme, failed }) => failed && opacify(12, theme.critical)}; @@ -177,7 +177,7 @@ export const ContentRow = ({ - + )} diff --git a/apps/web/src/nft/components/profile/list/Modal/ListModal.tsx b/apps/web/src/nft/components/profile/list/Modal/ListModal.tsx index 66dde30ab9b..8f9f9887d4f 100644 --- a/apps/web/src/nft/components/profile/list/Modal/ListModal.tsx +++ b/apps/web/src/nft/components/profile/list/Modal/ListModal.tsx @@ -2,7 +2,6 @@ import { InterfaceModalName, NFTEventName } from '@uniswap/analytics-events' import { useAccount } from 'hooks/useAccount' import { useEthersWeb3Provider } from 'hooks/useEthersProvider' import { useStablecoinValue } from 'hooks/useStablecoinPrice' -import { Trans } from 'i18n' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import styled from 'lib/styled-components' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' @@ -21,6 +20,7 @@ import { ThemedText } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -138,7 +138,7 @@ export const ListModal = ({ overlayClick }: { overlayClick: () => void }) => { <> - + diff --git a/apps/web/src/nft/components/profile/list/Modal/ListModalSection.tsx b/apps/web/src/nft/components/profile/list/Modal/ListModalSection.tsx index 6c04c6af018..ba7d01a6c1d 100644 --- a/apps/web/src/nft/components/profile/list/Modal/ListModalSection.tsx +++ b/apps/web/src/nft/components/profile/list/Modal/ListModalSection.tsx @@ -2,7 +2,6 @@ import Column from 'components/Column' import { ScrollBarStyles } from 'components/Common' import Row from 'components/Row' import { MouseoverTooltip } from 'components/Tooltip' -import { Plural, Trans, t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ChevronUpIcon, ListingModalWindowActive, ListingModalWindowClosed } from 'nft/components/icons' import { ContentRow } from 'nft/components/profile/list/Modal/ContentRow' @@ -13,6 +12,7 @@ import { Info } from 'react-feather' import { colors } from 'theme/colors' import { ThemedText } from 'theme/components' import { TRANSITION_DURATIONS } from 'theme/styles' +import { Trans, useTranslation } from 'uniswap/src/i18n' const SectionHeader = styled(Row)` justify-content: space-between; @@ -67,7 +67,9 @@ interface ListModalSectionProps { } export const ListModalSection = ({ sectionType, active, content, toggleSection }: ListModalSectionProps) => { + const { t } = useTranslation() const theme = useTheme() + const sellAssets = useSellAsset((state) => state.sellAssets) const removeAssetMarketplace = useSellAsset((state) => state.removeAssetMarketplace) const allContentApproved = useMemo(() => !content.some((row) => row.status !== ListingStatus.APPROVED), [content]) @@ -106,18 +108,9 @@ export const ListModalSection = ({ sectionType, active, content, toggleSection } )} - {isCollectionApprovalSection ? ( - <> - -   - - - ) : ( - <> -  {content.length} {' '} - - - )} + {isCollectionApprovalSection + ? t('nfts.collection.action.approve', { count: uniqueCollections ?? 1 }) + : t('nfts.collection.action.sign', { count: content.length })} ` diff --git a/apps/web/src/nft/components/profile/list/NFTListingsGrid.tsx b/apps/web/src/nft/components/profile/list/NFTListingsGrid.tsx index 3cf35f5eb1a..40fcaa13620 100644 --- a/apps/web/src/nft/components/profile/list/NFTListingsGrid.tsx +++ b/apps/web/src/nft/components/profile/list/NFTListingsGrid.tsx @@ -1,7 +1,6 @@ import Column from 'components/Column' import Row from 'components/Row' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { Dropdown } from 'nft/components/profile/list/Dropdown' import { NFTListRow } from 'nft/components/profile/list/NFTListRow' @@ -11,6 +10,7 @@ import { DropDownOption, ListingMarket } from 'nft/types' import { useMemo, useReducer, useRef, useState } from 'react' import { ChevronDown } from 'react-feather' import { BREAKPOINTS } from 'theme' +import { Trans } from 'uniswap/src/i18n' const TableHeader = styled.div` display: flex; @@ -210,15 +210,13 @@ export const NFTListingsGrid = ({ selectedMarkets }: { selectedMarkets: ListingM return ( - - - + NFT - + diff --git a/apps/web/src/nft/components/profile/list/PriceTextInput.tsx b/apps/web/src/nft/components/profile/list/PriceTextInput.tsx index 3d16f8b958f..ad2ba15b422 100644 --- a/apps/web/src/nft/components/profile/list/PriceTextInput.tsx +++ b/apps/web/src/nft/components/profile/list/PriceTextInput.tsx @@ -1,6 +1,5 @@ import Column from 'components/Column' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { BrokenLinkIcon } from 'nft/components/icons' import { NumericInput } from 'nft/components/layout/Input' @@ -13,6 +12,7 @@ import { Dispatch, useRef, useState } from 'react' import { AlertTriangle, Link } from 'react-feather' import { BREAKPOINTS } from 'theme' import { colors } from 'theme/colors' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const PriceTextInputWrapper = styled(Column)` @@ -73,19 +73,6 @@ const WarningAction = styled.div` color: ${({ theme }) => theme.accent1}; ` -const getWarningMessage = (warning: WarningType) => { - let message = <> - switch (warning) { - case WarningType.BELOW_FLOOR: - message = - break - case WarningType.ALREADY_LISTED: - message = - break - } - return message -} - interface PriceTextInputProps { listPrice?: number setListPrice: Dispatch @@ -103,6 +90,7 @@ export const PriceTextInput = ({ globalOverride, asset, }: PriceTextInputProps) => { + const { t } = useTranslation() const { formatNumberOrString, formatDelta } = useFormatter() const [warningType, setWarningType] = useState(WarningType.NONE) const removeSellAsset = useSellAsset((state) => state.removeSellAsset) @@ -130,6 +118,23 @@ export const PriceTextInput = ({ setListPrice(isNaN(val) ? undefined : val) } + let warningMessage = '' + switch (warningType) { + case WarningType.BELOW_FLOOR: + warningMessage = t('nft.profile.priceInput.warning.belowFloor', { + percentage: formatDelta(percentBelowFloor), + }) + break + case WarningType.ALREADY_LISTED: + warningMessage = t('nft.profile.priceInput.warning.alreadyListed', { + tokenAmountWithSymbol: `${formatNumberOrString({ + input: asset?.floor_sell_order_price ?? 0, + type: NumberType.NFTToken, + })} ETH`, + }) + break + } + useUpdateInputAndWarnings(setWarningType, inputRef, asset, listPrice) return ( @@ -158,16 +163,7 @@ export const PriceTextInput = ({ {warningType !== WarningType.NONE && ( - - {warningType === WarningType.BELOW_FLOOR && `${formatDelta(percentBelowFloor)} `} - {getWarningMessage(warningType)} -   - {warningType === WarningType.ALREADY_LISTED && - `${formatNumberOrString({ - input: asset?.floor_sell_order_price ?? 0, - type: NumberType.NFTToken, - })} ETH`} - + {warningMessage} { warningType === WarningType.ALREADY_LISTED && removeSellAsset(asset) diff --git a/apps/web/src/nft/components/profile/list/RoyaltyTooltip.tsx b/apps/web/src/nft/components/profile/list/RoyaltyTooltip.tsx index ead75f81c38..8b9ce670a80 100644 --- a/apps/web/src/nft/components/profile/list/RoyaltyTooltip.tsx +++ b/apps/web/src/nft/components/profile/list/RoyaltyTooltip.tsx @@ -1,11 +1,11 @@ import Column from 'components/Column' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { getRoyalty } from 'nft/components/profile/list/utils' import { ListingMarket, WalletAsset } from 'nft/types' import { getMarketplaceIcon } from 'nft/utils' import { ThemedText } from 'theme/components' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const FeeWrap = styled(Row)` @@ -57,6 +57,7 @@ export const RoyaltyTooltip = ({ asset: WalletAsset fees?: number }) => { + const { t } = useTranslation() const { formatNumberOrString, formatDelta } = useFormatter() const maxRoyalty = Math.max(...selectedMarkets.map((market) => getRoyalty(market, asset) ?? 0)) return ( @@ -66,8 +67,7 @@ export const RoyaltyTooltip = ({ {getMarketplaceIcon(market.name, '16')} - {market.name}  - + {t('nft.marketplace.royalty.header', { marketName: market.name })} {formatDelta(market.fee)} diff --git a/apps/web/src/nft/components/profile/list/SetDurationModal.tsx b/apps/web/src/nft/components/profile/list/SetDurationModal.tsx index 499d951d9ad..cc9010e1969 100644 --- a/apps/web/src/nft/components/profile/list/SetDurationModal.tsx +++ b/apps/web/src/nft/components/profile/list/SetDurationModal.tsx @@ -1,5 +1,4 @@ import { useOnClickOutside } from 'hooks/useOnClickOutside' -import { Plural, t } from 'i18n' import styled from 'lib/styled-components' import ms from 'ms' import { Column, Row } from 'nft/components/Flex' @@ -11,6 +10,7 @@ import { DropDownOption } from 'nft/types' import { useEffect, useMemo, useReducer, useRef, useState } from 'react' import { AlertTriangle, ChevronDown } from 'react-feather' import { Z_INDEX } from 'theme/zIndex' +import { Plural, useTranslation } from 'uniswap/src/i18n' const ModalWrapper = styled(Column)` gap: 4px; @@ -91,6 +91,7 @@ enum ErrorState { } export const SetDurationModal = () => { + const { t } = useTranslation() const [duration, setDuration] = useState(Duration.day) const [amount, setAmount] = useState('7') const [errorState, setErrorState] = useState(ErrorState.valid) @@ -106,7 +107,7 @@ export const SetDurationModal = () => { const durationOptions: DropDownOption[] = useMemo( () => [ { - displayText: 'hours', + displayText: t('common.time.hours'), isSelected: duration === Duration.hour, onClick: () => { setDuration(Duration.hour) @@ -114,7 +115,7 @@ export const SetDurationModal = () => { }, }, { - displayText: 'days', + displayText: t('common.time.days'), isSelected: duration === Duration.day, onClick: () => { setDuration(Duration.day) @@ -122,7 +123,7 @@ export const SetDurationModal = () => { }, }, { - displayText: 'weeks', + displayText: t('common.time.weeks'), isSelected: duration === Duration.week, onClick: () => { setDuration(Duration.week) @@ -130,7 +131,7 @@ export const SetDurationModal = () => { }, }, { - displayText: 'months', + displayText: t('common.time.months'), isSelected: duration === Duration.month, onClick: () => { setDuration(Duration.month) @@ -138,22 +139,22 @@ export const SetDurationModal = () => { }, }, ], - [duration], + [duration, t], ) let prompt switch (duration) { case Duration.hour: - prompt = + prompt = break case Duration.day: - prompt = + prompt = break case Duration.week: - prompt = + prompt = break case Duration.month: - prompt = + prompt = break default: break diff --git a/apps/web/src/nft/components/profile/view/EmptyWalletContent.tsx b/apps/web/src/nft/components/profile/view/EmptyWalletContent.tsx index b337b2c9496..2f970a6e763 100644 --- a/apps/web/src/nft/components/profile/view/EmptyWalletContent.tsx +++ b/apps/web/src/nft/components/profile/view/EmptyWalletContent.tsx @@ -1,10 +1,10 @@ -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { EmptyActivityIcon, EmptyNftsIcon, EmptyPoolsIcon, EmptyTokensIcon } from 'nft/components/profile/view/icons' import { headlineMedium } from 'nft/css/common.css' import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const EmptyWalletContainer = styled.div` display: flex; @@ -61,7 +61,7 @@ const EMPTY_WALLET_CONTENT: { [key in EmptyWalletContentType]: EmptyWalletConten icon: , }, token: { - title: , + title: , subtitle: , actionText: , urlPath: '/tokens', diff --git a/apps/web/src/nft/components/profile/view/ViewMyNftsAsset.tsx b/apps/web/src/nft/components/profile/view/ViewMyNftsAsset.tsx index 008efb69e0e..515233643d9 100644 --- a/apps/web/src/nft/components/profile/view/ViewMyNftsAsset.tsx +++ b/apps/web/src/nft/components/profile/view/ViewMyNftsAsset.tsx @@ -1,6 +1,5 @@ import { NFTEventName } from '@uniswap/analytics-events' import { useIsMobile } from 'hooks/screenSize' -import { Trans } from 'i18n' import { NftCard, NftCardDisplayProps } from 'nft/components/card' import { detailsHref } from 'nft/components/card/utils' import { VerifiedIcon } from 'nft/components/icons' @@ -9,6 +8,7 @@ import { WalletAsset } from 'nft/types' import { useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' interface ViewMyNftsAssetProps { diff --git a/apps/web/src/nft/css/common.css.ts b/apps/web/src/nft/css/common.css.ts index 697c411e9bb..3e01f46763c 100644 --- a/apps/web/src/nft/css/common.css.ts +++ b/apps/web/src/nft/css/common.css.ts @@ -1,4 +1,3 @@ -import { style } from '@vanilla-extract/css' import { sprinkles } from 'nft/css/sprinkles.css' export const center = sprinkles({ @@ -20,40 +19,3 @@ export const caption = sprinkles({ fontWeight: 'book', fontSize: '12', lineHeigh export const buttonTextMedium = sprinkles({ fontWeight: 'medium', fontSize: '16', lineHeight: '20' }) export const buttonTextSmall = sprinkles({ fontWeight: 'medium', fontSize: '14', lineHeight: '16' }) - -const magicalGradient = style({ - selectors: { - '&::before': { - content: '', - position: 'absolute', - inset: '-1px', - background: 'linear-gradient(45deg, #FC72FF80 0%, #FC72FF80 100.13%) border-box', - borderColor: 'transparent', - WebkitMask: 'linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);', - WebkitMaskComposite: 'xor;', - maskComposite: 'exclude', - borderStyle: 'solid', - borderWidth: '1px', - borderRadius: 'inherit', - pointerEvents: 'none', - }, - }, -}) - -export const magicalGradientOnHover = style([ - magicalGradient, - { - selectors: { - '&::before': { - opacity: '0', - WebkitTransition: 'opacity 0.25s ease', - MozTransition: 'opacity 0.25s ease', - msTransition: 'opacity 0.25s ease', - transition: 'opacity 0.25s ease-out', - }, - '&:hover::before': { - opacity: '1', - }, - }, - }, -]) diff --git a/apps/web/src/nft/pages/asset/Asset.tsx b/apps/web/src/nft/pages/asset/Asset.tsx index b92aefd182e..662d24c2e1f 100644 --- a/apps/web/src/nft/pages/asset/Asset.tsx +++ b/apps/web/src/nft/pages/asset/Asset.tsx @@ -1,6 +1,5 @@ import { InterfacePageName } from '@uniswap/analytics-events' import { useNftAssetDetails } from 'graphql/data/nft/Details' -import { t } from 'i18n' import styled from 'lib/styled-components' import { AssetDetails } from 'nft/components/details/AssetDetails' import { AssetDetailsLoading } from 'nft/components/details/AssetDetailsLoading' @@ -12,6 +11,7 @@ import { Helmet } from 'react-helmet-async/lib/index' import { Navigate, useParams } from 'react-router-dom' import { formatNFTAssetMetatagTitleName } from 'shared-cloud/metatags' import Trace from 'uniswap/src/features/telemetry/Trace' +import { t } from 'uniswap/src/i18n' import { isIFramed } from 'utils/isIFramed' const AssetContainer = styled.div` diff --git a/apps/web/src/nft/pages/collection/index.tsx b/apps/web/src/nft/pages/collection/index.tsx index e9e32805670..c8f56daa593 100644 --- a/apps/web/src/nft/pages/collection/index.tsx +++ b/apps/web/src/nft/pages/collection/index.tsx @@ -6,7 +6,6 @@ import { LoadingBubble } from 'components/Tokens/loading' import { useCollection } from 'graphql/data/nft/Collection' import { useIsMobile, useScreenSize } from 'hooks/screenSize' import { useAccount } from 'hooks/useAccount' -import { t } from 'i18n' import styled from 'lib/styled-components' import { BAG_WIDTH, XXXL_BAG_WIDTH } from 'nft/components/bag/Bag' import { MobileHoverBag } from 'nft/components/bag/MobileHoverBag' @@ -27,6 +26,7 @@ import { ThemedText } from 'theme/components' import { TRANSITION_DURATIONS } from 'theme/styles' import { Z_INDEX } from 'theme/zIndex' import Trace from 'uniswap/src/features/telemetry/Trace' +import { t } from 'uniswap/src/i18n' const FILTER_WIDTH = 332 const EMPTY_TRAIT_OBJ = {} @@ -202,9 +202,7 @@ const Collection = () => { <> - {t(`{{name}} | Explore and buy on Uniswap`, { - name: collectionStats.name, - })} + {collectionStats.name} | {t(`nft.collection.title`)} {metaTags.map((tag, index) => ( diff --git a/apps/web/src/nft/pages/profile/index.tsx b/apps/web/src/nft/pages/profile/index.tsx index 142e902742a..4b7fcaf200b 100644 --- a/apps/web/src/nft/pages/profile/index.tsx +++ b/apps/web/src/nft/pages/profile/index.tsx @@ -3,7 +3,6 @@ import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { ButtonPrimary } from 'components/Button' import { useAccount } from 'hooks/useAccount' import useENSName from 'hooks/useENSName' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { XXXL_BAG_WIDTH } from 'nft/components/bag/Bag' import { ListPage } from 'nft/components/profile/list/ListPage' @@ -16,6 +15,7 @@ import { Helmet } from 'react-helmet-async/lib/index' import { BREAKPOINTS } from 'theme' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans, t } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' const ProfilePageWrapper = styled.div` diff --git a/apps/web/src/pages/AddLiquidity/blastAlerts.tsx b/apps/web/src/pages/AddLiquidity/blastAlerts.tsx index 4635c8db20d..58630150a0e 100644 --- a/apps/web/src/pages/AddLiquidity/blastAlerts.tsx +++ b/apps/web/src/pages/AddLiquidity/blastAlerts.tsx @@ -2,12 +2,12 @@ import Column from 'components/Column' import { Dialog, DialogButtonType } from 'components/Dialog/Dialog' import AlertTriangleFilled from 'components/Icons/AlertTriangleFilled' import Row from 'components/Row' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { useCallback, useState } from 'react' import { ChevronDown } from 'react-feather' import { useNavigate } from 'react-router-dom' import { ButtonText, ExternalLink, ThemedText } from 'theme/components' +import { Trans, t } from 'uniswap/src/i18n' const StyledAlertIcon = styled(AlertTriangleFilled)` path { @@ -127,13 +127,13 @@ export function BlastRebasingAlert() { {' '} - + - {expanded ? t('Read less') : t('Read more')} + {expanded ? t('common.longText.button.less') : t('common.longText.button.more')} diff --git a/apps/web/src/pages/AddLiquidity/index.tsx b/apps/web/src/pages/AddLiquidity/index.tsx index e71eebd9126..9fefe0a024b 100644 --- a/apps/web/src/pages/AddLiquidity/index.tsx +++ b/apps/web/src/pages/AddLiquidity/index.tsx @@ -51,7 +51,6 @@ import usePrevious from 'hooks/usePrevious' import { useStablecoinValue } from 'hooks/useStablecoinPrice' import { useGetTransactionDeadline } from 'hooks/useTransactionDeadline' import { useV3PositionFromTokenId } from 'hooks/useV3Positions' -import { Trans, t } from 'i18n' import { atomWithStorage, useAtomValue, useUpdateAtom } from 'jotai/utils' import { useSingleCallResult } from 'lib/hooks/multicall' import styled, { useTheme } from 'lib/styled-components' @@ -87,6 +86,7 @@ import { Text } from 'ui/src' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' @@ -583,7 +583,7 @@ function AddLiquidity() { ) : ( )} @@ -604,7 +604,7 @@ function AddLiquidity() { ) : ( )} @@ -722,7 +722,7 @@ function AddLiquidity() { - + @@ -880,14 +880,23 @@ function AddLiquidity() { - - {price && } + + + {price && } + + ), + outputToken: ( + + {baseCurrency?.symbol ?? ''} + + ), + }} + /> - {baseCurrency && ( - - {quoteCurrency?.symbol} {baseCurrency.symbol} - - )} )} - + {noLiquidity ? '100' : poolTokenPercentage?.toSignificant(4)}% diff --git a/apps/web/src/pages/AddLiquidityV2/PoolPriceBar.tsx b/apps/web/src/pages/AddLiquidityV2/PoolPriceBar.tsx index e7e6f61b403..ac6d80a02e0 100644 --- a/apps/web/src/pages/AddLiquidityV2/PoolPriceBar.tsx +++ b/apps/web/src/pages/AddLiquidityV2/PoolPriceBar.tsx @@ -2,11 +2,11 @@ import { Currency, Percent, Price } from '@uniswap/sdk-core' import { AutoColumn } from 'components/Column' import { AutoRow } from 'components/Row' import { ONE_BIPS } from 'constants/misc' -import { Trans } from 'i18n' import { useTheme } from 'lib/styled-components' import { Text } from 'rebass' import { Field } from 'state/mint/actions' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' export function PoolPriceBar({ currencies, diff --git a/apps/web/src/pages/AddLiquidityV2/index.tsx b/apps/web/src/pages/AddLiquidityV2/index.tsx index 8e427f67cd4..9f6c6c736ae 100644 --- a/apps/web/src/pages/AddLiquidityV2/index.tsx +++ b/apps/web/src/pages/AddLiquidityV2/index.tsx @@ -32,7 +32,6 @@ import { useIsSwapUnsupported } from 'hooks/useIsSwapUnsupported' import { useNetworkSupportsV2 } from 'hooks/useNetworkSupportsV2' import { useGetTransactionDeadline } from 'hooks/useTransactionDeadline' import { PairState } from 'hooks/useV2Pairs' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ConfirmAddModalBottom } from 'pages/AddLiquidityV2/ConfirmAddModalBottom' import { PoolPriceBar } from 'pages/AddLiquidityV2/PoolPriceBar' @@ -50,6 +49,7 @@ import { ThemedText } from 'theme/components' import { Text } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -495,7 +495,7 @@ export default function AddLiquidity() { ) : ( )} @@ -516,7 +516,7 @@ export default function AddLiquidity() { ) : ( )} diff --git a/apps/web/src/pages/App/Chrome.tsx b/apps/web/src/pages/App/Chrome.tsx index 3b8c7bbc814..bb25dd002bc 100644 --- a/apps/web/src/pages/App/Chrome.tsx +++ b/apps/web/src/pages/App/Chrome.tsx @@ -1,4 +1,4 @@ -import Polling from 'components/Polling' +import { ChainConnectivityWarning } from 'components/ChainConnectivityWarning' import Popups from 'components/Popups' import TopLevelModals from 'components/TopLevelModals' @@ -6,7 +6,7 @@ export default function AppChrome() { return ( <> - + ) diff --git a/apps/web/src/pages/App/Header.tsx b/apps/web/src/pages/App/Header.tsx index c18cf5f90fe..9ba508f74dc 100644 --- a/apps/web/src/pages/App/Header.tsx +++ b/apps/web/src/pages/App/Header.tsx @@ -8,8 +8,7 @@ import { GRID_AREAS } from 'pages/App/utils/shared' import { memo } from 'react' import { useLocation } from 'react-router-dom' import { Z_INDEX } from 'theme/zIndex' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useIsTouchDevice } from 'ui/src' const AppHeader = styled.div` grid-area: ${GRID_AREAS.HEADER}; @@ -39,7 +38,7 @@ export const Header = memo(function Header() { const isHeaderTransparent = !isScrolledDown && !isBagExpanded const renderUkBanner = useRenderUkBanner() const extensionEligible = useMobileAppPromoBannerEligible() - const isLegacyNav = !useFeatureFlag(FeatureFlags.NavRefresh) + const isTouchDevice = useIsTouchDevice() return ( @@ -48,10 +47,10 @@ export const Header = memo(function Header() { {renderUkBanner && } - + ) diff --git a/apps/web/src/pages/App/Layout.tsx b/apps/web/src/pages/App/Layout.tsx index a10ec2d2196..6c5314f065b 100644 --- a/apps/web/src/pages/App/Layout.tsx +++ b/apps/web/src/pages/App/Layout.tsx @@ -1,13 +1,8 @@ -import { PageTabs } from 'components/NavBar/LEGACY' -import { MobileBottomBarLegacy } from 'components/NavBar/MobileBottomBar' import styled from 'lib/styled-components' import { Body } from 'pages/App/Body' import { Header } from 'pages/App/Header' import { GRID_AREAS } from 'pages/App/utils/shared' import { BREAKPOINTS } from 'theme' -import { Z_INDEX } from 'theme/zIndex' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' const AppContainer = styled.div` min-height: 100vh; @@ -36,30 +31,14 @@ const AppBody = styled.div` padding-right: 10px; } ` -const MobileBar = styled.div` - grid-area: mobile-bar; - width: 100vw; - position: fixed; - bottom: 0px; - z-index: ${Z_INDEX.sticky}; -` export function AppLayout() { - const isLegacyNav = !useFeatureFlag(FeatureFlags.NavRefresh) - return (
- - {isLegacyNav && ( - - - - )} - ) } diff --git a/apps/web/src/pages/CreateProposal/ProposalActionDetail.tsx b/apps/web/src/pages/CreateProposal/ProposalActionDetail.tsx index 370fae0681c..7e5b45e67bb 100644 --- a/apps/web/src/pages/CreateProposal/ProposalActionDetail.tsx +++ b/apps/web/src/pages/CreateProposal/ProposalActionDetail.tsx @@ -1,10 +1,10 @@ import { Currency } from '@uniswap/sdk-core' import AddressInputPanel from 'components/AddressInputPanel' import CurrencyInputPanel from 'components/CurrencyInputPanel' -import { CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' -import { Trans } from 'i18n' +import { CurrencySearchFilters } from 'components/SearchModal/DeprecatedCurrencySearch' import styled from 'lib/styled-components' import { ProposalAction } from 'pages/CreateProposal/ProposalActionSelector' +import { Trans } from 'uniswap/src/i18n' enum ProposalActionDetailField { ADDRESS, diff --git a/apps/web/src/pages/CreateProposal/ProposalActionSelector.tsx b/apps/web/src/pages/CreateProposal/ProposalActionSelector.tsx index 861e92706c7..0596b38f367 100644 --- a/apps/web/src/pages/CreateProposal/ProposalActionSelector.tsx +++ b/apps/web/src/pages/CreateProposal/ProposalActionSelector.tsx @@ -3,11 +3,11 @@ import Column from 'components/Column' import Modal from 'components/Modal' import { RowBetween } from 'components/Row' import { MenuItem, PaddedColumn, Separator } from 'components/SearchModal/styled' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { useCallback } from 'react' import { Text } from 'rebass' import { CloseIcon } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' export enum ProposalAction { TRANSFER_TOKEN = 'Transfer Token', diff --git a/apps/web/src/pages/CreateProposal/ProposalEditor.tsx b/apps/web/src/pages/CreateProposal/ProposalEditor.tsx index 95671dd83c5..cee5e99ce04 100644 --- a/apps/web/src/pages/CreateProposal/ProposalEditor.tsx +++ b/apps/web/src/pages/CreateProposal/ProposalEditor.tsx @@ -1,9 +1,9 @@ // eslint-disable-next-line no-restricted-imports import { ResizingTextArea, TextInput } from 'components/TextInput' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { memo } from 'react' import { Text } from 'rebass' +import { Trans, t } from 'uniswap/src/i18n' const ProposalEditorHeader = styled(Text)` font-size: 14px; diff --git a/apps/web/src/pages/CreateProposal/ProposalSubmissionModal.tsx b/apps/web/src/pages/CreateProposal/ProposalSubmissionModal.tsx index bd34571d59b..7c6a54a20fd 100644 --- a/apps/web/src/pages/CreateProposal/ProposalSubmissionModal.tsx +++ b/apps/web/src/pages/CreateProposal/ProposalSubmissionModal.tsx @@ -2,11 +2,11 @@ import { ButtonPrimary } from 'components/Button' import { AutoColumn } from 'components/Column' import Modal from 'components/Modal' import { LoadingView, SubmittedView } from 'components/ModalViews' -import { Trans } from 'i18n' import { useTheme } from 'lib/styled-components' import { Link } from 'react-router-dom' import { Text } from 'rebass' import { ExternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' export const ProposalSubmissionModal = ({ diff --git a/apps/web/src/pages/CreateProposal/index.tsx b/apps/web/src/pages/CreateProposal/index.tsx index 3ebaa27070f..ef3ee6609b6 100644 --- a/apps/web/src/pages/CreateProposal/index.tsx +++ b/apps/web/src/pages/CreateProposal/index.tsx @@ -8,7 +8,6 @@ import { AutoColumn } from 'components/Column' import { LATEST_GOVERNOR_INDEX } from 'constants/governance' import { UNI } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import JSBI from 'jsbi' import styled from 'lib/styled-components' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' @@ -36,6 +35,7 @@ import { } from 'state/governance/hooks' import { ExternalLink, ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' const PageWrapper = styled(AutoColumn)` padding: 68px 8px 0px; @@ -284,13 +284,17 @@ ${bodyValue} - - - - + + ), + }} + /> diff --git a/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx b/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx index 3a705e4aa93..641115b6852 100644 --- a/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx +++ b/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx @@ -16,11 +16,11 @@ import { SupportedInterfaceChainId, chainIdToBackendChain, useChainFromUrlParam import { useDailyProtocolTVL, useHistoricalProtocolVolume } from 'graphql/data/protocolStats' import { TimePeriod, getProtocolColor, getSupportedGraphQlChain } from 'graphql/data/util' import { useScreenSize } from 'hooks/screenSize' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ReactNode, useMemo, useState } from 'react' import { EllipsisStyle, ThemedText } from 'theme/components' import { HistoryDuration, PriceSource } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const EXPLORE_CHART_HEIGHT_PX = 368 diff --git a/apps/web/src/pages/Explore/index.tsx b/apps/web/src/pages/Explore/index.tsx index 68af37ae0c9..61c266bdde5 100644 --- a/apps/web/src/pages/Explore/index.tsx +++ b/apps/web/src/pages/Explore/index.tsx @@ -2,7 +2,7 @@ import { InterfaceElementName, InterfacePageName, SharedEventName } from '@unisw import { TopPoolTable } from 'components/Pools/PoolTable/PoolTable' import { AutoRow } from 'components/Row' import { TopTokensTable } from 'components/Tokens/TokenTable' -import NetworkFilter from 'components/Tokens/TokenTable/NetworkFilter' +import TableNetworkFilter from 'components/Tokens/TokenTable/NetworkFilter' import SearchBar from 'components/Tokens/TokenTable/SearchBar' import TimeSelector from 'components/Tokens/TokenTable/TimeSelector' import { MAX_WIDTH_MEDIA_BREAKPOINT } from 'components/Tokens/constants' @@ -10,7 +10,6 @@ import { useChainFromUrlParam } from 'constants/chains' import { manualChainOutageAtom } from 'featureFlags/flags/outageBanner' import { getTokenExploreURL, isBackendSupportedChain } from 'graphql/data/util' import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch' -import { Trans } from 'i18n' import { useResetAtom } from 'jotai/utils' import styled from 'lib/styled-components' import { ExploreChartsSection } from 'pages/Explore/charts/ExploreChartsSection' @@ -21,6 +20,7 @@ import { useNavigate } from 'react-router-dom' import { StyledInternalLink, ThemedText } from 'theme/components' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' const ExploreContainer = styled.div` @@ -38,7 +38,7 @@ const NavWrapper = styled.div` display: flex; max-width: ${MAX_WIDTH_MEDIA_BREAKPOINT}; margin: 0 auto; - margin-bottom: 16px; + margin-bottom: 4px; color: ${({ theme }) => theme.neutral3}; flex-direction: row; justify-content: space-between; @@ -182,7 +182,7 @@ const Explore = ({ initialTab }: { initialTab?: ExploreTab }) => { })} - + {currentKey === ExploreTab.Tokens && } {currentKey !== ExploreTab.Transactions && } diff --git a/apps/web/src/pages/Explore/tables/RecentTransactions.tsx b/apps/web/src/pages/Explore/tables/RecentTransactions.tsx index 33a6bad568e..a627621b77a 100644 --- a/apps/web/src/pages/Explore/tables/RecentTransactions.tsx +++ b/apps/web/src/pages/Explore/tables/RecentTransactions.tsx @@ -17,13 +17,13 @@ import { useUpdateManualOutage } from 'featureFlags/flags/outageBanner' import { BETypeToTransactionType, TransactionType, useAllTransactions } from 'graphql/data/useAllTransactions' import { OrderDirection, getSupportedGraphQlChain } from 'graphql/data/util' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' -import { Trans } from 'i18n' import { useMemo, useReducer, useState } from 'react' import { ThemedText } from 'theme/components' import { PoolTransaction, PoolTransactionType, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' import { useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -91,24 +91,43 @@ export default function RecentTransactions() { ), - cell: (transaction) => ( - - + cell: (transaction) => { + const amountWithSymbolA = ( + <> {BETypeToTransactionType[transaction.getValue?.().type]} - - {transaction.getValue?.().type === PoolTransactionType.Swap ? ( - - ) : ( - - )} - - - - - ), + + ) + const amountWithSymbolB = + + return ( + + + + {transaction.getValue?.().type === PoolTransactionType.Swap ? ( + + ) : ( + + )} + + + + ) + }, }), columnHelper.accessor((transaction) => transaction.usdValue.value, { id: 'fiat-value', diff --git a/apps/web/src/pages/Landing/components/cards/DocumentationCard.tsx b/apps/web/src/pages/Landing/components/cards/DocumentationCard.tsx index cbc3c8d6d1a..abd712fec7c 100644 --- a/apps/web/src/pages/Landing/components/cards/DocumentationCard.tsx +++ b/apps/web/src/pages/Landing/components/cards/DocumentationCard.tsx @@ -1,10 +1,10 @@ import { Alignment, Fit, Layout, useRive } from '@rive-app/react-canvas' import { useScreenSize } from 'hooks/screenSize' -import { t } from 'i18n' import styled from 'lib/styled-components' import { CodeBrackets } from 'pages/Landing/components/Icons' import { PillButton } from 'pages/Landing/components/cards/PillButton' import ValuePropCard from 'pages/Landing/components/cards/ValuePropCard' +import { t } from 'uniswap/src/i18n' const Contents = styled.div` width: 100%; diff --git a/apps/web/src/pages/Landing/components/cards/DownloadWalletCard.tsx b/apps/web/src/pages/Landing/components/cards/DownloadWalletCard.tsx index 821e6d994e7..8fca7c94dbb 100644 --- a/apps/web/src/pages/Landing/components/cards/DownloadWalletCard.tsx +++ b/apps/web/src/pages/Landing/components/cards/DownloadWalletCard.tsx @@ -1,10 +1,10 @@ import { Alignment, Fit, Layout, useRive } from '@rive-app/react-canvas' -import { t } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { Wallet } from 'pages/Landing/components/Icons' import { PillButton } from 'pages/Landing/components/cards/PillButton' import ValuePropCard from 'pages/Landing/components/cards/ValuePropCard' import { useIsDarkMode } from 'theme/components/ThemeToggle' +import { t } from 'uniswap/src/i18n' const Contents = styled.div` width: 100%; diff --git a/apps/web/src/pages/Landing/components/cards/LiquidityCard.tsx b/apps/web/src/pages/Landing/components/cards/LiquidityCard.tsx index a8c925a1427..56988af1e4c 100644 --- a/apps/web/src/pages/Landing/components/cards/LiquidityCard.tsx +++ b/apps/web/src/pages/Landing/components/cards/LiquidityCard.tsx @@ -1,10 +1,10 @@ import { Alignment, Fit, Layout, useRive } from '@rive-app/react-canvas' import { useScreenSize } from 'hooks/screenSize' -import { t } from 'i18n' import styled from 'lib/styled-components' import { Bars } from 'pages/Landing/components/Icons' import { PillButton } from 'pages/Landing/components/cards/PillButton' import ValuePropCard from 'pages/Landing/components/cards/ValuePropCard' +import { t } from 'uniswap/src/i18n' const Contents = styled.div` width: 100%; diff --git a/apps/web/src/pages/Landing/components/cards/WebappCard.tsx b/apps/web/src/pages/Landing/components/cards/WebappCard.tsx index 1efed739f8e..7639b36ca42 100644 --- a/apps/web/src/pages/Landing/components/cards/WebappCard.tsx +++ b/apps/web/src/pages/Landing/components/cards/WebappCard.tsx @@ -5,7 +5,6 @@ import { LDO, NATIVE_CHAIN_ID, UNI, USDC_BASE } from 'constants/tokens' import { getTokenDetailsURL } from 'graphql/data/util' import { useCurrency } from 'hooks/Tokens' import { useScreenSize } from 'hooks/screenSize' -import { t } from 'i18n' import styled from 'lib/styled-components' import { Box } from 'pages/Landing/components/Generics' import { Computer } from 'pages/Landing/components/Icons' @@ -14,6 +13,7 @@ import ValuePropCard from 'pages/Landing/components/cards/ValuePropCard' import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { useTokenPromoQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/pages/Landing/sections/DirectToDefi.tsx b/apps/web/src/pages/Landing/sections/DirectToDefi.tsx index 1771cde046a..b2b5d7352f9 100644 --- a/apps/web/src/pages/Landing/sections/DirectToDefi.tsx +++ b/apps/web/src/pages/Landing/sections/DirectToDefi.tsx @@ -1,10 +1,10 @@ -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Box, H2 } from 'pages/Landing/components/Generics' import { DocumentationCard } from 'pages/Landing/components/cards/DocumentationCard' import { DownloadWalletCard } from 'pages/Landing/components/cards/DownloadWalletCard' import { LiquidityCard } from 'pages/Landing/components/cards/LiquidityCard' import { WebappCard } from 'pages/Landing/components/cards/WebappCard' +import { Trans } from 'uniswap/src/i18n' const SectionLayout = styled.div` display: flex; diff --git a/apps/web/src/pages/Landing/sections/Footer.tsx b/apps/web/src/pages/Landing/sections/Footer.tsx index a505fb18e47..52ed7a57502 100644 --- a/apps/web/src/pages/Landing/sections/Footer.tsx +++ b/apps/web/src/pages/Landing/sections/Footer.tsx @@ -1,15 +1,17 @@ -import Row from 'components/Row' -import { useScreenSize } from 'hooks/screenSize' -import { Trans } from 'i18n' -import styled, { css } from 'lib/styled-components' -import { Body1, Box, H3 } from 'pages/Landing/components/Generics' +import { MenuItem, useMenuContent } from 'components/NavBar/CompanyMenu/Content' +import { MenuLink } from 'components/NavBar/CompanyMenu/MenuDropdown' +import { useTabsContent } from 'components/NavBar/Tabs/TabsContent' +import deprecatedStyled, { useTheme } from 'lib/styled-components' import { Discord, Github, Twitter } from 'pages/Landing/components/Icons' import { Wiggle } from 'pages/Landing/components/animations' -import { Link } from 'react-router-dom' +import { useMemo } from 'react' import { useTogglePrivacyPolicy } from 'state/application/hooks' -import { ExternalLink } from 'theme/components' +import { Anchor, Flex, Separator, Text, styled } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { useTranslation } from 'uniswap/src/i18n' -const SocialIcon = styled(Wiggle)` +const SOCIAL_ICONS_SIZE = `${iconSizes.icon32}px` +const SocialIcon = deprecatedStyled(Wiggle)` flex: 0; fill: ${(props) => props.theme.neutral1}; cursor: pointer; @@ -19,162 +21,116 @@ const SocialIcon = styled(Wiggle)` fill: ${(props) => props.$hoverColor}; } ` -const RowToCol = styled(Box)` - height: auto; - flex-shrink: 1; - @media (max-width: 768px) { - flex-direction: column; - } -` -const HideWhenSmall = styled(Box)` - @media (max-width: 768px) { - display: none; - } -` -const HideWhenLarge = styled(Box)` - @media (min-width: 768px) { - display: none; - } -` -const MenuItemStyles = css` - padding: 0; - margin: 0; - text-align: left; - font-family: Basel; - font-size: 16px; - font-style: normal; - font-weight: 500; - line-height: 24px; - color: ${({ theme }) => theme.neutral2}; - stroke: none; - transition: color 0.1s ease-in-out; - text-decoration: none; - &:hover { - color: ${({ theme }) => theme.neutral1}; - opacity: 1; - } -` -const StyledInternalLink = styled(Link)` - ${MenuItemStyles} -` -const StyledExternalLink = styled(ExternalLink)` - ${MenuItemStyles} -` -const DownloadLink = styled.a` - ${MenuItemStyles} -` -const ModalItem = styled.div` - ${MenuItemStyles} - cursor: pointer; - user-select: none; -` +const SocialLink = styled(Anchor, { + target: '_blank', + animateOnly: ['color'], + animation: '100ms', +}) +const PolicyLink = styled(Text, { + variant: 'body3', + animation: '100ms', + color: '$neutral2', + cursor: 'pointer', + hoverStyle: { color: '$neutral1' }, +}) + export function Socials({ iconSize }: { iconSize?: string }) { return ( - + - + - + - + - + - + - + - + + ) +} + +function FooterSection({ title, items }: { title: string; items: MenuItem[] }) { + const theme = useTheme() + return ( + + {title} + {items.map((item, index) => ( + + ))} + ) } export function Footer() { - const screenIsLarge = useScreenSize()['lg'] + const { t } = useTranslation() const togglePrivacyPolicy = useTogglePrivacyPolicy() + const tabsContent = useTabsContent({ includeNftsLink: true }) + const appSectionItems: MenuItem[] = useMemo(() => { + return tabsContent.map((tab) => ({ + label: tab.title, + href: tab.href, + internal: true, + })) + }, [tabsContent]) + const sections = useMenuContent() + const brandAssets = { + label: t('common.brandAssets'), + href: 'https://github.com/Uniswap/brand-assets/raw/main/Uniswap%20Brand%20Assets.zip', + internal: false, + } return ( - - - - - -

© 2024

-

Uniswap Labs

-
- - - -
- - - - App - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
+ + + + + + + + + + + + + + + + + + + + + + + + © 2024 - Uniswap Labs + + + {t('common.trademarkPolicy')} + + {t('common.privacyPolicy')} + + + ) } diff --git a/apps/web/src/pages/Landing/sections/Hero.tsx b/apps/web/src/pages/Landing/sections/Hero.tsx index 6d6e706cb4d..dae32110139 100644 --- a/apps/web/src/pages/Landing/sections/Hero.tsx +++ b/apps/web/src/pages/Landing/sections/Hero.tsx @@ -1,17 +1,16 @@ import { ColumnCenter } from 'components/Column' import { useCurrency } from 'hooks/Tokens' import { useScroll } from 'hooks/useScroll' -import { Trans } from 'i18n' import styled, { css, keyframes } from 'lib/styled-components' import { Box, H1 } from 'pages/Landing/components/Generics' import { TokenCloud } from 'pages/Landing/components/TokenCloud/index' import { Hover, RiseIn, RiseInText } from 'pages/Landing/components/animations' import { Swap } from 'pages/Swap' import { ChevronDown } from 'react-feather' -import { useTranslation } from 'react-i18next' import { BREAKPOINTS } from 'theme' import { Text } from 'ui/src' import { heightBreakpoints } from 'ui/src/theme' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' const Container = styled(Box)` diff --git a/apps/web/src/pages/Landing/sections/NewsletterEtc.tsx b/apps/web/src/pages/Landing/sections/NewsletterEtc.tsx index 14c1d8cbf53..417d542cbdf 100644 --- a/apps/web/src/pages/Landing/sections/NewsletterEtc.tsx +++ b/apps/web/src/pages/Landing/sections/NewsletterEtc.tsx @@ -1,10 +1,10 @@ import { motion } from 'framer-motion' -import { t, Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { PillButton } from 'pages/Landing/components/cards/PillButton' import { Box, H2, H3 } from 'pages/Landing/components/Generics' import { BookOpen, ChatBubbles, HelpCircle } from 'pages/Landing/components/Icons' import { useIsDarkMode } from 'theme/components/ThemeToggle' +import { t, Trans } from 'uniswap/src/i18n' const SectionLayout = styled.div` width: 100%; diff --git a/apps/web/src/pages/Landing/sections/Stats.tsx b/apps/web/src/pages/Landing/sections/Stats.tsx index 38ceb806e15..2044d769b43 100644 --- a/apps/web/src/pages/Landing/sections/Stats.tsx +++ b/apps/web/src/pages/Landing/sections/Stats.tsx @@ -1,5 +1,4 @@ import Row from 'components/Row' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { Body1, Box, H2 } from 'pages/Landing/components/Generics' import { StatCard } from 'pages/Landing/components/StatCard' @@ -11,6 +10,7 @@ import { ProtocolVersion, useDailyProtocolVolumeQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans, t } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' const Container = styled.div` @@ -145,7 +145,7 @@ function LearnMore() { return ( - + diff --git a/apps/web/src/pages/MigrateV2/MigrateV2Pair.tsx b/apps/web/src/pages/MigrateV2/MigrateV2Pair.tsx index abe65570061..570f8cbaadb 100644 --- a/apps/web/src/pages/MigrateV2/MigrateV2Pair.tsx +++ b/apps/web/src/pages/MigrateV2/MigrateV2Pair.tsx @@ -28,7 +28,6 @@ import { PoolState, usePool } from 'hooks/usePools' import { useTotalSupply } from 'hooks/useTotalSupply' import { useGetTransactionDeadline } from 'hooks/useTransactionDeadline' import { useV2LiquidityTokenPermit } from 'hooks/useV2LiquidityTokenPermit' -import { Trans, t } from 'i18n' import JSBI from 'jsbi' import { NEVER_RELOAD, useSingleCallResult } from 'lib/hooks/multicall' import { useTheme } from 'lib/styled-components' @@ -46,6 +45,7 @@ import { useUserSlippageToleranceWithDefault } from 'state/user/hooks' import { BackArrowLink, ExternalLink, ThemedText } from 'theme/components' import { Text } from 'ui/src' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { isAddress } from 'utilities/src/addresses' import { logger } from 'utilities/src/logger/logger' @@ -489,8 +489,8 @@ function V2PairMigration({ {' '} {invertPrice diff --git a/apps/web/src/pages/MigrateV2/index.tsx b/apps/web/src/pages/MigrateV2/index.tsx index f610b3ea881..3564184adbc 100644 --- a/apps/web/src/pages/MigrateV2/index.tsx +++ b/apps/web/src/pages/MigrateV2/index.tsx @@ -14,7 +14,6 @@ import { Dots } from 'components/swap/styled' import { useAccount } from 'hooks/useAccount' import { useNetworkSupportsV2 } from 'hooks/useNetworkSupportsV2' import { PairState, useV2Pairs } from 'hooks/useV2Pairs' -import { Trans } from 'i18n' import { useRpcTokenBalancesWithLoadingIndicator } from 'lib/hooks/useCurrencyBalance' import styled, { useTheme } from 'lib/styled-components' import { BodyWrapper } from 'pages/App/AppBody' @@ -22,6 +21,7 @@ import { ReactNode, useMemo } from 'react' import { Text } from 'rebass' import { toV2LiquidityToken, useTrackedTokenPairs } from 'state/user/hooks' import { BackArrowLink, StyledInternalLink, ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' export const MigrateHeader = styled(ThemedText.H1Small)` font-weight: 535; @@ -181,11 +181,12 @@ export default function MigrateV2() { - - - - - + , + }} + /> diff --git a/apps/web/src/pages/NotFound/index.tsx b/apps/web/src/pages/NotFound/index.tsx index 82c36af9dec..af935607249 100644 --- a/apps/web/src/pages/NotFound/index.tsx +++ b/apps/web/src/pages/NotFound/index.tsx @@ -3,12 +3,12 @@ import darkImage from 'assets/images/404-page-dark.png' import lightImage from 'assets/images/404-page-light.png' import { SmallButtonPrimary } from 'components/Button' import { useIsMobile } from 'hooks/screenSize' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { Link } from 'react-router-dom' import { ThemedText } from 'theme/components' import { useIsDarkMode } from 'theme/components/ThemeToggle' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' const Image = styled.img` max-width: 510px; diff --git a/apps/web/src/pages/Pool/CTACards.tsx b/apps/web/src/pages/Pool/CTACards.tsx index f3976828acf..2acbcefbfe7 100644 --- a/apps/web/src/pages/Pool/CTACards.tsx +++ b/apps/web/src/pages/Pool/CTACards.tsx @@ -1,10 +1,10 @@ import { AutoColumn } from 'components/Column' import { useSupportedChainId } from 'constants/chains' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { ExternalLink, StyledInternalLink, ThemedText } from 'theme/components' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' const CTASection = styled.section` diff --git a/apps/web/src/pages/Pool/PositionPage.tsx b/apps/web/src/pages/Pool/PositionPage.tsx index 9f70788e5c3..c816541c923 100644 --- a/apps/web/src/pages/Pool/PositionPage.tsx +++ b/apps/web/src/pages/Pool/PositionPage.tsx @@ -36,7 +36,6 @@ import { usePositionTokenURI } from 'hooks/usePositionTokenURI' import useStablecoinPrice from 'hooks/useStablecoinPrice' import { useV3PositionFees } from 'hooks/useV3PositionFees' import { useV3PositionFromTokenId } from 'hooks/useV3Positions' -import { Trans, t } from 'i18n' import { useSingleCallResult } from 'lib/hooks/multicall' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import styled, { useTheme } from 'lib/styled-components' @@ -51,6 +50,7 @@ import { ClickableStyle, ExternalLink, HideExtraSmall, HideSmall, StyledRouterLi import { Text } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans, t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -697,7 +697,7 @@ function PositionPageContent() { <> - {t(`Manage {{quoteSymbol}}/{{baseSymbol}} pool liquidity on Uniswap`, { + {t(`liquidityPool.positions.page.title`, { quoteSymbol: currencyQuote?.symbol, baseSymbol: currencyBase?.symbol, })} @@ -853,9 +853,7 @@ function PositionPageContent() { </ThemedText.DeprecatedMain> {typeof ratio === 'number' && !removed ? ( <Badge style={{ marginLeft: '10px' }}> - <BadgeText> - <Trans i18nKey="common.percentage" values={{ pct: inverted ? ratio : 100 - ratio }} /> - </BadgeText> + <BadgeText>{inverted ? ratio : 100 - ratio}%</BadgeText> </Badge> ) : null} </RowFixed> @@ -871,9 +869,7 @@ function PositionPageContent() { </ThemedText.DeprecatedMain> {typeof ratio === 'number' && !removed ? ( <Badge style={{ marginLeft: '10px' }}> - <BadgeText> - <Trans i18nKey="common.percentage" values={{ pct: inverted ? 100 - ratio : ratio }} /> - </BadgeText> + <BadgeText>{inverted ? 100 - ratio : ratio}%</BadgeText> </Badge> ) : null} </RowFixed> diff --git a/apps/web/src/pages/Pool/index.tsx b/apps/web/src/pages/Pool/index.tsx index 9425123fb2d..e03c54d72c4 100644 --- a/apps/web/src/pages/Pool/index.tsx +++ b/apps/web/src/pages/Pool/index.tsx @@ -11,7 +11,6 @@ import { useAccount } from 'hooks/useAccount' import { useFilterPossiblyMaliciousPositions } from 'hooks/useFilterPossiblyMaliciousPositions' import { useNetworkSupportsV2 } from 'hooks/useNetworkSupportsV2' import { useV3Positions } from 'hooks/useV3Positions' -import { Trans } from 'i18n' import styled, { css, useTheme } from 'lib/styled-components' import CTACards from 'pages/Pool/CTACards' import { PoolVersionMenu } from 'pages/Pool/shared' @@ -25,6 +24,7 @@ import { HideSmall, ThemedText } from 'theme/components' import { PositionDetails } from 'types/position' import { ProtocolVersion } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' const PageWrapper = styled(AutoColumn)` padding: 68px 8px 0px; diff --git a/apps/web/src/pages/Pool/shared.tsx b/apps/web/src/pages/Pool/shared.tsx index 6ff5de88ba5..3a0bc3e1b94 100644 --- a/apps/web/src/pages/Pool/shared.tsx +++ b/apps/web/src/pages/Pool/shared.tsx @@ -1,13 +1,13 @@ import { ButtonGray } from 'components/Button' import { Pool } from 'components/Icons/Pool' import { FlyoutAlignment, Menu } from 'components/Menu' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { ChevronDown } from 'react-feather' import { useModalIsOpen } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { ThemedText } from 'theme/components' import { ProtocolVersion } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Trans } from 'uniswap/src/i18n' const PoolVersionItem = styled.div` align-items: center; @@ -76,8 +76,8 @@ const menuItems = { } const titles = { - [ProtocolVersion.V3]: <Trans i18nKey="common.v3" />, - [ProtocolVersion.V2]: <Trans i18nKey="common.v2" />, + [ProtocolVersion.V3]: 'v3', + [ProtocolVersion.V2]: 'v2', } export function PoolVersionMenu({ protocolVersion }: { protocolVersion: ProtocolVersion }) { diff --git a/apps/web/src/pages/Pool/v2.tsx b/apps/web/src/pages/Pool/v2.tsx index fa13b249134..cd8b0e8a312 100644 --- a/apps/web/src/pages/Pool/v2.tsx +++ b/apps/web/src/pages/Pool/v2.tsx @@ -13,7 +13,6 @@ import { BIG_INT_ZERO } from 'constants/misc' import { useAccount } from 'hooks/useAccount' import { useNetworkSupportsV2 } from 'hooks/useNetworkSupportsV2' import { useV2Pairs } from 'hooks/useV2Pairs' -import { Trans } from 'i18n' import JSBI from 'jsbi' import { useRpcTokenBalancesWithLoadingIndicator } from 'lib/hooks/useCurrencyBalance' import styled, { useTheme } from 'lib/styled-components' @@ -27,6 +26,7 @@ import { toV2LiquidityToken, useTrackedTokenPairs } from 'state/user/hooks' import { ExternalLink, HideSmall, ThemedText } from 'theme/components' import { ProtocolVersion } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' const PageWrapper = styled(AutoColumn)` max-width: 640px; @@ -217,14 +217,6 @@ export default function Pool() { </EmptyProposals> ) : allV2PairsWithLiquidity?.length > 0 || stakingPairs?.length > 0 ? ( <> - <ButtonSecondary> - <RowBetween> - <ExternalLink href={'https://v2.info.uniswap.org/account/' + account}> - <Trans i18nKey="pool.account.analyticsFees" /> - </ExternalLink> - <span> ↗ </span> - </RowBetween> - </ButtonSecondary> {v2PairsWithoutStakedAmount.map((v2Pair) => ( <FullPositionCard key={v2Pair.liquidityToken.address} pair={v2Pair} /> ))} diff --git a/apps/web/src/pages/PoolDetails/index.tsx b/apps/web/src/pages/PoolDetails/index.tsx index d3661980989..e528dd7f415 100644 --- a/apps/web/src/pages/PoolDetails/index.tsx +++ b/apps/web/src/pages/PoolDetails/index.tsx @@ -11,7 +11,6 @@ import { useChainFromUrlParam } from 'constants/chains' import { PoolData, usePoolData } from 'graphql/data/pools/usePoolData' import { getSupportedGraphQlChain, gqlToCurrency, unwrapToken } from 'graphql/data/util' import { useColor } from 'hooks/useColor' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import NotFound from 'pages/NotFound' import { getPoolDetailPageTitle } from 'pages/PoolDetails/utils' @@ -22,6 +21,7 @@ import { useParams } from 'react-router-dom' import { Text } from 'rebass' import { BREAKPOINTS, ThemeProvider } from 'theme' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { isAddress } from 'utilities/src/addresses' const PageWrapper = styled(Row)` diff --git a/apps/web/src/pages/PoolDetails/utils.ts b/apps/web/src/pages/PoolDetails/utils.ts index 234204b94cf..218699b1dea 100644 --- a/apps/web/src/pages/PoolDetails/utils.ts +++ b/apps/web/src/pages/PoolDetails/utils.ts @@ -1,5 +1,5 @@ import { PoolData } from 'graphql/data/pools/usePoolData' -import { t } from 'i18n' +import { t } from 'uniswap/src/i18n' export const getPoolDetailPageTitle = (poolData?: PoolData) => { const token0Symbol = poolData?.token0.symbol diff --git a/apps/web/src/pages/PoolFinder/index.tsx b/apps/web/src/pages/PoolFinder/index.tsx index 84dd941fa7c..82dfbaf9531 100644 --- a/apps/web/src/pages/PoolFinder/index.tsx +++ b/apps/web/src/pages/PoolFinder/index.tsx @@ -7,15 +7,14 @@ import CurrencyLogo from 'components/Logo/CurrencyLogo' import { FindPoolTabs } from 'components/NavigationTabs' import { MinimalPositionCard } from 'components/PositionCard' import Row from 'components/Row' -import { CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' import CurrencySearchModal from 'components/SearchModal/CurrencySearchModal' +import { CurrencySearchFilters } from 'components/SearchModal/DeprecatedCurrencySearch' import { SwitchLocaleLink } from 'components/SwitchLocaleLink' import { V2Unsupported } from 'components/V2Unsupported' import { nativeOnChain } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' import { useNetworkSupportsV2 } from 'hooks/useNetworkSupportsV2' import { PairState, useV2Pair } from 'hooks/useV2Pairs' -import { Trans } from 'i18n' import JSBI from 'jsbi' import AppBody from 'pages/App/AppBody' import { Dots } from 'pages/Pool/styled' @@ -27,6 +26,7 @@ import { useTokenBalance } from 'state/connection/hooks' import { usePairAdder } from 'state/user/hooks' import { StyledInternalLink, ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { currencyId } from 'utils/currencyId' enum Fields { diff --git a/apps/web/src/pages/RemoveLiquidity/V3.tsx b/apps/web/src/pages/RemoveLiquidity/V3.tsx index 369873d6bc7..44de7f0d0a9 100644 --- a/apps/web/src/pages/RemoveLiquidity/V3.tsx +++ b/apps/web/src/pages/RemoveLiquidity/V3.tsx @@ -25,7 +25,6 @@ import { useEthersSigner } from 'hooks/useEthersSigner' import { PoolCache } from 'hooks/usePools' import { useGetTransactionDeadline } from 'hooks/useTransactionDeadline' import { useV3PositionFromTokenId } from 'hooks/useV3Positions' -import { Trans } from 'i18n' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import AppBody from 'pages/App/AppBody' import { PositionPageUnsupportedContent } from 'pages/Pool/PositionPage' @@ -39,6 +38,7 @@ import { useUserSlippageToleranceWithDefault } from 'state/user/hooks' import { ThemedText } from 'theme/components' import { Text } from 'ui/src' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -361,23 +361,16 @@ function Remove({ tokenId }: { tokenId: BigNumber }) { <Trans i18nKey="common.amount.label" /> </ThemedText.DeprecatedMain> <RowBetween> - <ResponsiveHeaderText> - <Trans - i18nKey="common.percentage" - values={{ - pct: percentForSlider, - }} - /> - </ResponsiveHeaderText> + <ResponsiveHeaderText>{percentForSlider}%</ResponsiveHeaderText> <AutoRow gap="4px" justify="flex-end"> <SmallMaxButton onClick={() => onPercentSelect(25)} width="20%"> - <Trans i18nKey="common.percentage" values={{ pct: '25' }} /> + 25% </SmallMaxButton> <SmallMaxButton onClick={() => onPercentSelect(50)} width="20%"> - <Trans i18nKey="common.percentage" values={{ pct: '50' }} /> + 50% </SmallMaxButton> <SmallMaxButton onClick={() => onPercentSelect(75)} width="20%"> - <Trans i18nKey="common.percentage" values={{ pct: '75' }} /> + 75% </SmallMaxButton> <SmallMaxButton onClick={() => onPercentSelect(100)} width="20%"> <Trans i18nKey="common.max" /> diff --git a/apps/web/src/pages/RemoveLiquidity/index.tsx b/apps/web/src/pages/RemoveLiquidity/index.tsx index 4c8ef5e3497..327aa7b47c8 100644 --- a/apps/web/src/pages/RemoveLiquidity/index.tsx +++ b/apps/web/src/pages/RemoveLiquidity/index.tsx @@ -34,7 +34,6 @@ import { useEthersSigner } from 'hooks/useEthersSigner' import { useNetworkSupportsV2 } from 'hooks/useNetworkSupportsV2' import { useGetTransactionDeadline } from 'hooks/useTransactionDeadline' import { useV2LiquidityTokenPermit } from 'hooks/useV2LiquidityTokenPermit' -import { Trans } from 'i18n' import { useTheme } from 'lib/styled-components' import AppBody from 'pages/App/AppBody' import { PositionPageUnsupportedContent } from 'pages/Pool/PositionPage' @@ -51,6 +50,7 @@ import { StyledInternalLink, ThemedText } from 'theme/components' import { Text } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -558,13 +558,13 @@ function RemoveLiquidity() { <Slider value={innerLiquidityPercentage} onChange={setInnerLiquidityPercentage} /> <RowBetween> <MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '25')} width="20%"> - <Trans i18nKey="common.percentage" values={{ pct: '25' }} /> + 25% </MaxButton> <MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '50')} width="20%"> - <Trans i18nKey="common.percentage" values={{ pct: '50' }} /> + 50% </MaxButton> <MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '75')} width="20%"> - <Trans i18nKey="common.percentage" values={{ pct: '75' }} /> + 75% </MaxButton> <MaxButton onClick={() => onUserInput(Field.LIQUIDITY_PERCENT, '100')} width="20%"> <Trans i18nKey="common.max" /> diff --git a/apps/web/src/pages/RouteDefinitions.tsx b/apps/web/src/pages/RouteDefinitions.tsx index 78b00997544..831def4eda4 100644 --- a/apps/web/src/pages/RouteDefinitions.tsx +++ b/apps/web/src/pages/RouteDefinitions.tsx @@ -1,4 +1,3 @@ -import { t } from 'i18n' import { useAtom } from 'jotai' import { getExploreDescription, getExploreTitle } from 'pages/getExploreTitle' import { getAddLiquidityPageTitle, getPositionPageDescription, getPositionPageTitle } from 'pages/getPositionPageTitle' @@ -6,6 +5,7 @@ import { ReactNode, Suspense, lazy, useMemo } from 'react' import { Navigate, matchPath, useLocation } from 'react-router-dom' import { shouldDisableNFTRoutesAtom } from 'state/application/atoms' import { SpinnerSVG } from 'theme/components' +import { t } from 'uniswap/src/i18n' import { isBrowserRouterEnabled } from 'utils/env' // High-traffic pages (index and /swap) should not be lazy-loaded. import Landing from 'pages/Landing' diff --git a/apps/web/src/pages/Swap/Buy/BuyForm.tsx b/apps/web/src/pages/Swap/Buy/BuyForm.tsx index 1b8de719fce..78e8a448f78 100644 --- a/apps/web/src/pages/Swap/Buy/BuyForm.tsx +++ b/apps/web/src/pages/Swap/Buy/BuyForm.tsx @@ -16,7 +16,6 @@ import { useWidthAdjustedDisplayValue, } from 'pages/Swap/common/shared' import { useEffect } from 'react' -import { Trans } from 'react-i18next' import { Text } from 'ui/src/components/text/Text' import { FiatOnRampCountryPicker } from 'uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker' import { SelectTokenButton } from 'uniswap/src/features/fiatOnRamp/SelectTokenButton' @@ -24,6 +23,7 @@ import { useFiatOnRampAggregatorGetCountryQuery } from 'uniswap/src/features/fia import Trace from 'uniswap/src/features/telemetry/Trace' import { FiatOnRampEventName, InterfacePageNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' import useResizeObserver from 'use-resize-observer' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -111,6 +111,7 @@ function BuyFormInner({ disabled }: BuyFormProps) { placeholder="0" $width={inputAmount && hiddenObserver.width ? hiddenObserver.width + 1 : undefined} maxDecimals={6} + testId="buy-form-amount-input" /> <NumericalInputMimic ref={hiddenObserver.ref}>{inputAmount}</NumericalInputMimic> </NumericalInputWrapper> diff --git a/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx b/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx index d2e671c5a1a..6a0bbcb020a 100644 --- a/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx +++ b/apps/web/src/pages/Swap/Buy/BuyFormButton.tsx @@ -1,10 +1,9 @@ import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' -import { ButtonLight, ButtonPrimary, LoadingButtonSpinner } from 'components/Button' +import { ButtonLight, LoadingButtonSpinner } from 'components/Button' import { useTheme } from 'lib/styled-components' import { useBuyFormContext } from 'pages/Swap/Buy/BuyFormContext' -import { useMemo } from 'react' -import { Trans } from 'react-i18next' -import { Flex } from 'ui/src' +import { AnimatePresence, Button, Flex, Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' import { useAccount } from 'wagmi' interface BuyFormButtonProps { @@ -20,62 +19,61 @@ export function BuyFormButton({ forceDisabled }: BuyFormButtonProps) { const { inputAmount } = buyFormState const { notAvailableInThisRegion, quotes, fetchingQuotes, error } = derivedBuyFormInfo - const buyButtonState = useMemo(() => { - if (!account.isConnected) { - return { - label: <Trans i18nKey="common.connectWallet.button" />, - disabled: false, - onClick: accountDrawer.open, - Component: ButtonLight, - } - } - if (!inputAmount || forceDisabled) { - return { - label: <Trans i18nKey="common.noAmount.error" />, - disabled: true, - onClick: undefined, - Component: ButtonPrimary, - } - } + if (!account.isConnected) { + return ( + <ButtonLight onClick={accountDrawer.open}> + <Trans i18nKey="common.connectWallet.button" /> + </ButtonLight> + ) + } - if (notAvailableInThisRegion) { - return { - label: <Trans i18nKey="common.notAvailableInRegion.error" />, - disabled: true, - onClick: undefined, - Component: ButtonPrimary, - } - } - - return { - label: ( - <Flex row alignItems="center" gap="$spacing12"> - {fetchingQuotes && <LoadingButtonSpinner fill={theme.neutral2} />} - <Trans i18nKey="common.continue.button" /> - </Flex> - ), - disabled: Boolean(fetchingQuotes || !quotes || !quotes.quotes || quotes.quotes.length === 0 || error), - Component: ButtonPrimary, - onClick: () => { - setBuyFormState((prev) => ({ ...prev, providerModalOpen: true })) - }, - } - }, [ - account.isConnected, - inputAmount, - forceDisabled, - notAvailableInThisRegion, - theme.neutral2, - fetchingQuotes, - quotes, - error, - accountDrawer.open, - setBuyFormState, - ]) + if (!inputAmount || forceDisabled || notAvailableInThisRegion) { + return ( + <Button + key="BuyFormButton" + disabled + size="large" + disabledStyle={{ + backgroundColor: '$surface3', + }} + > + <Text variant="buttonLabel1"> + {notAvailableInThisRegion ? ( + <Trans i18nKey="common.notAvailableInRegion.error" /> + ) : ( + <Trans i18nKey="common.noAmount.error" /> + )} + </Text> + </Button> + ) + } return ( - <buyButtonState.Component fontWeight={535} disabled={buyButtonState.disabled} onClick={buyButtonState.onClick}> - {buyButtonState.label} - </buyButtonState.Component> + <Button + key="BuyFormButton-animation" + size="large" + animation="fastHeavy" + disabled={Boolean(fetchingQuotes || !quotes || !quotes.quotes || quotes.quotes.length === 0 || error)} + onPress={() => { + setBuyFormState((prev) => ({ ...prev, providerModalOpen: true })) + }} + > + <Flex row alignItems="center" gap="$spacing12"> + <LoadingButtonSpinner opacity={fetchingQuotes ? 1 : 0} fill={theme.neutral1} /> + <AnimatePresence> + <Flex + animation="fastHeavy" + enterStyle={{ + opacity: 0, + x: -20, + }} + > + <Text variant="buttonLabel1" color="$white" animation="fastHeavy" x={fetchingQuotes ? 0 : -20}> + <Trans i18nKey="common.button.continue" /> + </Text> + </Flex> + </AnimatePresence> + </Flex> + </Button> ) } diff --git a/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx b/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx index c907120c1e5..1e45f6938dc 100644 --- a/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx +++ b/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx @@ -2,7 +2,6 @@ import { skipToken } from '@reduxjs/toolkit/query/react' import { buildCurrencyInfo } from 'constants/routing' import { nativeOnChain } from 'constants/tokens' import { useUSDTokenUpdater } from 'hooks/useUSDTokenUpdater' -import { t } from 'i18next' import { useFiatOnRampSupportedTokens, useMeldFiatCurrencyInfo } from 'pages/Swap/Buy/hooks' import { formatFiatOnRampFiatAmount } from 'pages/Swap/Buy/shared' import { Dispatch, PropsWithChildren, SetStateAction, createContext, useContext, useMemo, useState } from 'react' @@ -23,6 +22,7 @@ import { isInvalidRequestAmountTooHigh, isInvalidRequestAmountTooLow, } from 'uniswap/src/features/fiatOnRamp/utils' +import { t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { useAccount } from 'wagmi' diff --git a/apps/web/src/pages/Swap/Buy/ChooseProviderModal.tsx b/apps/web/src/pages/Swap/Buy/ChooseProviderModal.tsx index 36d63096233..4a48751aeeb 100644 --- a/apps/web/src/pages/Swap/Buy/ChooseProviderModal.tsx +++ b/apps/web/src/pages/Swap/Buy/ChooseProviderModal.tsx @@ -9,12 +9,13 @@ import { ProviderConnectionError } from 'pages/Swap/Buy/ProviderConnectionError' import { ProviderOption } from 'pages/Swap/Buy/ProviderOption' import { ContentWrapper } from 'pages/Swap/Buy/shared' import { useMemo, useState } from 'react' -import { Trans } from 'react-i18next' import { AdaptiveWebModalSheet, Flex, Separator, Text } from 'ui/src' import { TimePast } from 'ui/src/components/icons' import { uniswapUrls } from 'uniswap/src/constants/urls' import { FORQuote, FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' +import { useInterval } from 'utilities/src/time/timing' const ProviderListPaddedColumn = styled(AutoColumn)` position: relative; @@ -55,6 +56,13 @@ function ChooseProviderModalContent({ closeModal }: ChooseProviderModal) { }, ms('500ms')) } + // close modal after 5 minutes because some provider link have expirations and we don't want to keep generating these if the user is not active + useInterval(() => { + if (!errorProvider && !connectedProvider) { + onClose() + } + }, ms('5m')) + const quoteCurrencyCode = quoteCurrency.meldCurrencyCode const recipientAddress = account.address if (!selectedCountry || !quoteCurrencyCode || !meldSupportedFiatCurrency || !recipientAddress) { @@ -78,7 +86,7 @@ function ChooseProviderModalContent({ closeModal }: ChooseProviderModal) { } return ( - <ProviderListPaddedColumn gap="24px"> + <ProviderListPaddedColumn gap="24px" id="ChooseProviderModal"> <GetHelpHeader title={<Trans i18nKey="fiatOnRamp.checkoutWith" />} link={uniswapUrls.helpArticleUrls.fiatOnRampHelp} diff --git a/apps/web/src/pages/Swap/Buy/CountryListModal.tsx b/apps/web/src/pages/Swap/Buy/CountryListModal.tsx index 3adaabc444b..8360e4d2467 100644 --- a/apps/web/src/pages/Swap/Buy/CountryListModal.tsx +++ b/apps/web/src/pages/Swap/Buy/CountryListModal.tsx @@ -2,16 +2,15 @@ import Modal from 'components/Modal' import { RowBetween } from 'components/Row' import { scrollbarStyle } from 'components/SearchModal/CurrencyList/index.css' import { PaddedColumn, SearchInput } from 'components/SearchModal/styled' -import { t } from 'i18next' import { CountryListRow } from 'pages/Swap/Buy/CountryListRow' import { ContentWrapper } from 'pages/Swap/Buy/shared' import { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react' -import { Trans } from 'react-i18next' import AutoSizer from 'react-virtualized-auto-sizer' import { FixedSizeList } from 'react-window' import { CloseIcon } from 'theme/components' import { Text } from 'ui/src/components/text/Text' import { FORCountry } from 'uniswap/src/features/fiatOnRamp/types' +import { Trans, t } from 'uniswap/src/i18n' import { bubbleToTop } from 'utilities/src/primitives/array' const ROW_ITEM_SIZE = 56 @@ -68,7 +67,7 @@ export function CountryListModal({ type="text" id="for-country-search-input" data-testid="for-country-search-input" - placeholder={t`Search by country or region`} + placeholder={t`swap.buy.countryModal.placeholder`} autoComplete="off" value={searchQuery} onChange={handleInput} diff --git a/apps/web/src/pages/Swap/Buy/FiatOnRampCurrencyModal.tsx b/apps/web/src/pages/Swap/Buy/FiatOnRampCurrencyModal.tsx index 015134a44e1..6272f8a3fee 100644 --- a/apps/web/src/pages/Swap/Buy/FiatOnRampCurrencyModal.tsx +++ b/apps/web/src/pages/Swap/Buy/FiatOnRampCurrencyModal.tsx @@ -5,12 +5,12 @@ import { scrollbarStyle } from 'components/SearchModal/CurrencyList/index.css' import { PaddedColumn } from 'components/SearchModal/styled' import { ContentWrapper } from 'pages/Swap/Buy/shared' import { CSSProperties } from 'react' -import { Trans } from 'react-i18next' import AutoSizer from 'react-virtualized-auto-sizer' import { FixedSizeList } from 'react-window' import { CloseIcon } from 'theme/components' import { Text } from 'ui/src/components/text/Text' import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { Trans } from 'uniswap/src/i18n' const ROW_ITEM_SIZE = 56 diff --git a/apps/web/src/pages/Swap/Buy/ProviderConnectedView.tsx b/apps/web/src/pages/Swap/Buy/ProviderConnectedView.tsx index 976dce96acc..42d61fc444d 100644 --- a/apps/web/src/pages/Swap/Buy/ProviderConnectedView.tsx +++ b/apps/web/src/pages/Swap/Buy/ProviderConnectedView.tsx @@ -1,12 +1,11 @@ -import { t } from 'i18next' import styled, { useTheme } from 'lib/styled-components' import { ConnectingViewWrapper } from 'pages/Swap/Buy/shared' -import { Trans } from 'react-i18next' import { ExternalLink } from 'theme/components' import { Flex, Text, useIsDarkMode } from 'ui/src' import { ServiceProviderLogoStyles } from 'uniswap/src/features/fiatOnRamp/constants' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' +import { Trans, t } from 'uniswap/src/i18n' const StyledLink = styled(ExternalLink)` font-weight: 535; diff --git a/apps/web/src/pages/Swap/Buy/ProviderConnectionError.tsx b/apps/web/src/pages/Swap/Buy/ProviderConnectionError.tsx index 9cf150470b1..96a35cd851b 100644 --- a/apps/web/src/pages/Swap/Buy/ProviderConnectionError.tsx +++ b/apps/web/src/pages/Swap/Buy/ProviderConnectionError.tsx @@ -1,11 +1,11 @@ import { ConnectingViewWrapper } from 'pages/Swap/Buy/shared' -import { Trans } from 'react-i18next' import { Button, Flex, Image, Text, useIsDarkMode } from 'ui/src' import { UNISWAP_LOGO_LARGE } from 'ui/src/assets' import { iconSizes } from 'ui/src/theme' import { ServiceProviderLogoStyles } from 'uniswap/src/features/fiatOnRamp/constants' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' +import { Trans } from 'uniswap/src/i18n' interface ProviderConnectionErrorProps { onBack: () => void diff --git a/apps/web/src/pages/Swap/Buy/__snapshots__/CountryListRow.test.tsx.snap b/apps/web/src/pages/Swap/Buy/__snapshots__/CountryListRow.test.tsx.snap index 9e496135c69..31e32c54985 100644 --- a/apps/web/src/pages/Swap/Buy/__snapshots__/CountryListRow.test.tsx.snap +++ b/apps/web/src/pages/Swap/Buy/__snapshots__/CountryListRow.test.tsx.snap @@ -80,7 +80,7 @@ exports[`CountryListRow should render 1`] = ` width="32" /> <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-400" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441189 _lineHeight-222976542 _fontWeight-233016202" data-disable-theme="true" > United States @@ -171,7 +171,7 @@ exports[`CountryListRow should render selected country 1`] = ` width="32" /> <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-400" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441189 _lineHeight-222976542 _fontWeight-233016202" data-disable-theme="true" > United States diff --git a/apps/web/src/pages/Swap/Buy/__snapshots__/PredefinedAmount.test.tsx.snap b/apps/web/src/pages/Swap/Buy/__snapshots__/PredefinedAmount.test.tsx.snap index 8026c5cf9ca..ed13fd50946 100644 --- a/apps/web/src/pages/Swap/Buy/__snapshots__/PredefinedAmount.test.tsx.snap +++ b/apps/web/src/pages/Swap/Buy/__snapshots__/PredefinedAmount.test.tsx.snap @@ -22,11 +22,11 @@ exports[`PredefinedAmount renders correctly with amount= 100 , currentAmount= "1 class=" t_light _dsp_contents is_Theme" > <div - class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558276 _justifyContent-center _pr-1481558214 _pl-1481558214 _pt-1481558276 _pb-1481558276 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" + class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558245 _justifyContent-center _pr-1481558183 _pl-1481558183 _pt-1481558245 _pb-1481558245 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" style="border-color: #2222220d;" > <span - class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-500" + class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016109" data-disable-theme="true" style="color: rgb(125, 125, 125); padding-top: 1px;" > @@ -54,11 +54,11 @@ exports[`PredefinedAmount renders correctly with amount= 300 , currentAmount= "1 > <div aria-disabled="true" - class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558276 _justifyContent-center _pr-1481558214 _pl-1481558214 _pt-1481558276 _pb-1481558276 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" + class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558245 _justifyContent-center _pr-1481558183 _pl-1481558183 _pt-1481558245 _pb-1481558245 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" style="border-color: #2222220d;" > <span - class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-500" + class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016109" data-disable-theme="true" style="color: rgb(206, 206, 206); padding-top: 1px;" > @@ -91,11 +91,11 @@ exports[`PredefinedAmount renders correctly with amount= 1000 , currentAmount= " class=" t_light _dsp_contents is_Theme" > <div - class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558276 _justifyContent-center _pr-1481558214 _pl-1481558214 _pt-1481558276 _pb-1481558276 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" + class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558245 _justifyContent-center _pr-1481558183 _pl-1481558183 _pt-1481558245 _pb-1481558245 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" style="border-color: #2222220d;" > <span - class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-500" + class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016109" data-disable-theme="true" style="color: rgb(34, 34, 34); padding-top: 1px;" > @@ -123,11 +123,11 @@ exports[`PredefinedAmount renders correctly with amount= 1000 , currentAmount= " > <div aria-disabled="true" - class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558276 _justifyContent-center _pr-1481558214 _pl-1481558214 _pt-1481558276 _pb-1481558276 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" + class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _borderTopLeftRadius-1307610122 _borderTopRightRadius-1307610122 _borderBottomRightRadius-1307610122 _borderBottomLeftRadius-1307610122 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _flexDirection-row _gap-1481558245 _justifyContent-center _pr-1481558183 _pl-1481558183 _pt-1481558245 _pb-1481558245 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid c0" style="border-color: #2222220d;" > <span - class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-500" + class="font_button _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016109" data-disable-theme="true" style="color: rgb(206, 206, 206); padding-top: 1px;" > diff --git a/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectedView.test.tsx.snap b/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectedView.test.tsx.snap index 451a4238066..106b1bdea7f 100644 --- a/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectedView.test.tsx.snap +++ b/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectedView.test.tsx.snap @@ -165,10 +165,10 @@ exports[`ProviderConnectedView should render the component and call callbacks 1` class="c8" > <div - class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1316330207" + class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1316330238" > <div - class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1481558152" + class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1316330114" > <img height="120" @@ -177,16 +177,16 @@ exports[`ProviderConnectedView should render the component and call callbacks 1` width="120" /> <div - class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1481558276" + class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1481558245" > <span - class="font_subHeading _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-18px _lineHeight-24px _fontWeight-400" + class="font_subHeading _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016140" data-disable-theme="true" > Complete transaction with Test Provider </span> <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-16px _lineHeight-24px _fontWeight-400 _textAlign-center" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441189 _lineHeight-222976542 _fontWeight-233016202 _textAlign-center" data-disable-theme="true" > Go to the Test Provider tab to continue. It’s safe to close this modal now. @@ -194,7 +194,7 @@ exports[`ProviderConnectedView should render the component and call callbacks 1` </div> </div> <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135036 _fontSize-12px _lineHeight-16px _fontWeight-400 _textAlign-center" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135036 _fontSize-229441127 _lineHeight-222976480 _fontWeight-233016202 _textAlign-center" data-disable-theme="true" > By continuing, you acknowledge that you’ll be subject to the diff --git a/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectionError.test.tsx.snap b/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectionError.test.tsx.snap index 2cb6b60f9c2..dd85f8636e1 100644 --- a/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectionError.test.tsx.snap +++ b/apps/web/src/pages/Swap/Buy/__snapshots__/ProviderConnectionError.test.tsx.snap @@ -158,10 +158,10 @@ exports[`ProviderConnectionError should render the component and call callbacks class="c9" > <div - class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1316330176" + class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _gap-1316330207" > <div - class="_display-flex _alignItems-stretch _flexDirection-row _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _gap-1481558214" + class="_display-flex _alignItems-stretch _flexDirection-row _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _gap-1481558183" > <div class="_display-flex _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _alignItems-center _justifyContent-center" @@ -195,16 +195,16 @@ exports[`ProviderConnectionError should render the component and call callbacks /> </div> <div - class="_display-flex _alignItems-center _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _justifyContent-center _gap-1481558276" + class="_display-flex _alignItems-center _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _justifyContent-center _gap-1481558245" > <span - class="font_subHeading _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-1640242377 _fontSize-18px _lineHeight-24px _fontWeight-400" + class="font_subHeading _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-1640242377 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016140" data-disable-theme="true" > Connection failed </span> <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-16px _lineHeight-24px _fontWeight-400 _textAlign-center" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441189 _lineHeight-222976542 _fontWeight-233016202 _textAlign-center" data-disable-theme="true" > Something went wrong connecting with Test Provider. @@ -214,7 +214,7 @@ exports[`ProviderConnectionError should render the component and call callbacks class="t_sub_theme t_primary_Button _dsp_contents is_Theme" > <button - class="is_Button _backgroundColor-0active-744986709 _opacity-0active-0d0t746 _display-flex _alignItems-center _flexDirection-row _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _justifyContent-center _borderTopColor-2122800589 _borderRightColor-2122800589 _borderBottomColor-2122800589 _borderLeftColor-2122800589 _cursor-pointer _height-auto _pt-1481558245 _pr-1481558245 _pb-1481558245 _pl-1481558245 _borderTopLeftRadius-1307609998 _borderTopRightRadius-1307609998 _borderBottomRightRadius-1307609998 _borderBottomLeftRadius-1307609998 _gap-1481558276 _width-10037 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid" + class="is_Button _backgroundColor-0active-744986709 _opacity-0active-0d0t746 _display-flex _alignItems-center _flexDirection-row _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _borderTopWidth-1px _borderRightWidth-1px _borderBottomWidth-1px _borderLeftWidth-1px _justifyContent-center _borderTopColor-2122800589 _borderRightColor-2122800589 _borderBottomColor-2122800589 _borderLeftColor-2122800589 _cursor-pointer _height-auto _pt-1481558214 _pr-1481558214 _pb-1481558214 _pl-1481558214 _borderTopLeftRadius-1307609998 _borderTopRightRadius-1307609998 _borderBottomRightRadius-1307609998 _borderBottomLeftRadius-1307609998 _gap-1481558245 _width-10037 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid" data-disable-theme="true" > Try again diff --git a/apps/web/src/pages/Swap/Buy/hooks.ts b/apps/web/src/pages/Swap/Buy/hooks.ts index b5c167a9605..4230ed59fc9 100644 --- a/apps/web/src/pages/Swap/Buy/hooks.ts +++ b/apps/web/src/pages/Swap/Buy/hooks.ts @@ -2,12 +2,12 @@ import { meldSupportedCurrencyToCurrencyInfo } from 'graphql/data/types' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useActiveLocale } from 'hooks/useActiveLocale' import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' import { useFiatOnRampAggregatorSupportedFiatCurrenciesQuery, useFiatOnRampAggregatorSupportedTokensQuery, } from 'uniswap/src/features/fiatOnRamp/api' import { FORCountry, FiatCurrencyInfo, FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { useTranslation } from 'uniswap/src/i18n' import { getFiatCurrencyComponents } from 'utilities/src/format/localeBased' import { getFiatCurrencyName } from 'utils/fiatCurrency' diff --git a/apps/web/src/pages/Swap/Limit/LimitExpirySection.tsx b/apps/web/src/pages/Swap/Limit/LimitExpirySection.tsx index 1b903e7fb94..8490f41aeb4 100644 --- a/apps/web/src/pages/Swap/Limit/LimitExpirySection.tsx +++ b/apps/web/src/pages/Swap/Limit/LimitExpirySection.tsx @@ -1,10 +1,10 @@ import Row from 'components/Row' -import { Trans, t } from 'i18n' import styled from 'lib/styled-components' import { useLimitContext } from 'state/limit/LimitContext' import { ClickableStyle, ThemedText } from 'theme/components' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans, t } from 'uniswap/src/i18n' import { LimitsExpiry } from 'uniswap/src/types/limits' const ExpirySection = styled(Row)` diff --git a/apps/web/src/pages/Swap/Limit/LimitForm.tsx b/apps/web/src/pages/Swap/Limit/LimitForm.tsx index db1c21a80ee..dae2a3e4f52 100644 --- a/apps/web/src/pages/Swap/Limit/LimitForm.tsx +++ b/apps/web/src/pages/Swap/Limit/LimitForm.tsx @@ -14,17 +14,15 @@ import { } from 'components/CurrencyInputPanel/LimitPriceInputPanel/useCurrentPriceAdjustment' import SwapCurrencyInputPanel from 'components/CurrencyInputPanel/SwapCurrencyInputPanel' import Row from 'components/Row' -import { CurrencySearchFilters } from 'components/SearchModal/CurrencySearch' +import { CurrencySearchFilters } from 'components/SearchModal/DeprecatedCurrencySearch' import { Field } from 'components/swap/constants' import { ArrowContainer, ArrowWrapper, SwapSection } from 'components/swap/styled' import { getChain, isUniswapXSupportedChain, useIsSupportedChainId } from 'constants/chains' import { ZERO_PERCENT } from 'constants/misc' import { useAccount } from 'hooks/useAccount' import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance' -import useSelectChain from 'hooks/useSelectChain' import { SwapResult, useSwapCallback } from 'hooks/useSwapCallback' import { useUSDPrice } from 'hooks/useUSDPrice' -import { Trans } from 'i18n' import { useAtom } from 'jotai' import styled, { useTheme } from 'lib/styled-components' import { LimitExpirySection } from 'pages/Swap/Limit/LimitExpirySection' @@ -36,13 +34,16 @@ import { LimitContextProvider, useLimitContext } from 'state/limit/LimitContext' import { getDefaultPriceInverted } from 'state/limit/hooks' import { LimitState } from 'state/limit/types' import { LimitOrderTrade, TradeFillType } from 'state/routing/types' -import { useSwapActionHandlers, useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapActionHandlers } from 'state/swap/hooks' import { CurrencyState } from 'state/swap/types' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { ExternalLink, ThemedText } from 'theme/components' import { AlertTriangle } from 'ui/src/components/icons' import { uniswapUrls } from 'uniswap/src/constants/urls' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName, InterfacePageNameLocal } from 'uniswap/src/features/telemetry/constants' +import { Trans } from 'uniswap/src/i18n' +import { CurrencyField } from 'uniswap/src/types/currency' import { NumberType, formatCurrencyAmount as formatCurrencyAmountWithoutUserLocale, @@ -86,7 +87,6 @@ type LimitFormProps = { function LimitForm({ onCurrencyChange }: LimitFormProps) { const account = useAccount() - const selectChain = useSelectChain() const { chainId, currencyState: { inputCurrency, outputCurrency }, @@ -310,6 +310,7 @@ function LimitForm({ onCurrencyChange }: LimitFormProps) { value={formattedAmounts[Field.INPUT]} showMaxButton={showMaxButton} currency={inputCurrency ?? null} + currencyField={CurrencyField.INPUT} onUserInput={onTypeInput('inputAmount')} onCurrencySelect={(currency) => onSelectCurrency('inputCurrency', currency)} otherCurrency={outputCurrency} @@ -337,6 +338,7 @@ function LimitForm({ onCurrencyChange }: LimitFormProps) { value={formattedAmounts[Field.OUTPUT]} showMaxButton={false} currency={outputCurrency ?? null} + currencyField={CurrencyField.OUTPUT} onUserInput={onTypeInput('outputAmount')} onCurrencySelect={(currency) => onSelectCurrency('outputCurrency', currency)} otherCurrency={inputCurrency} @@ -348,20 +350,14 @@ function LimitForm({ onCurrencyChange }: LimitFormProps) { {parsedLimitPrice && <LimitExpirySection />} <SubmitOrderButton inputCurrency={inputCurrency} - handleContinueToReview={async () => { - if (chainId && chainId !== account.chainId) { - const didSwitchChain = await selectChain(chainId) - if (!didSwitchChain) { - return - } - } + handleContinueToReview={() => { setShowConfirm(true) }} trade={limitOrderTrade} hasInsufficientFunds={hasInsufficientFunds} limitPriceError={priceError} /> - {!!priceError && inputCurrency && outputCurrency && limitOrderTrade && ( + {isUniswapXSupportedChain(chainId) && !!priceError && inputCurrency && outputCurrency && limitOrderTrade && ( <LimitPriceError priceError={priceError} priceAdjustmentPercentage={currentPriceAdjustment} @@ -370,6 +366,22 @@ function LimitForm({ onCurrencyChange }: LimitFormProps) { priceInverted={limitState.limitPriceInverted} /> )} + <LimitDisclaimerContainer> + <StyledAlertIcon size={20} color={!isUniswapXSupportedChain(chainId) ? theme.critical : theme.neutral2} /> + <DisclaimerText> + {!isUniswapXSupportedChain(chainId) ? ( + <Trans + i18nKey="limits.form.disclaimer.mainnet" + components={{ link: <ExternalLink href={uniswapUrls.helpArticleUrls.limitsNetworkSupport} /> }} + /> + ) : ( + <Trans + i18nKey="limits.form.disclaimer.uniswapx" + components={{ link: <ExternalLink href={uniswapUrls.helpArticleUrls.limitsFailure} /> }} + /> + )} + </DisclaimerText> + </LimitDisclaimerContainer> {account.address && ( <OpenLimitOrdersButton account={account.address} @@ -379,24 +391,6 @@ function LimitForm({ onCurrencyChange }: LimitFormProps) { }} /> )} - <LimitDisclaimerContainer> - <StyledAlertIcon size={20} color={!isUniswapXSupportedChain(chainId) ? theme.critical : theme.neutral2} /> - <DisclaimerText> - {!isUniswapXSupportedChain(chainId) ? ( - <Trans i18nKey="limits.onlyMainnet"> - <ExternalLink href={uniswapUrls.helpArticleUrls.limitsNetworkSupport}> - <Trans i18nKey="common.learnMore.link" /> - </ExternalLink> - </Trans> - ) : ( - <Trans i18nKey="limits.priceWarning"> - <ExternalLink href={uniswapUrls.helpArticleUrls.limitsFailure}> - <Trans i18nKey="common.learnMore.link" /> - </ExternalLink> - </Trans> - )} - </DisclaimerText> - </LimitDisclaimerContainer> {limitOrderTrade && showConfirm && ( <ConfirmSwapModal allowance={allowance} diff --git a/apps/web/src/pages/Swap/Limit/LimitPriceError.tsx b/apps/web/src/pages/Swap/Limit/LimitPriceError.tsx index 84104bafecc..ab51cc2773a 100644 --- a/apps/web/src/pages/Swap/Limit/LimitPriceError.tsx +++ b/apps/web/src/pages/Swap/Limit/LimitPriceError.tsx @@ -2,12 +2,12 @@ import { Currency } from '@uniswap/sdk-core' import Column from 'components/Column' import { LimitPriceErrorType } from 'components/CurrencyInputPanel/LimitPriceInputPanel/useCurrentPriceAdjustment' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { ReactNode } from 'react' import { AlertTriangle } from 'react-feather' import { ThemedText } from 'theme/components' import { FadePresence, FadePresenceAnimationType } from 'theme/components/FadePresence' +import { Trans } from 'uniswap/src/i18n' const Container = styled(Row)` padding: 12px; diff --git a/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.tsx b/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.tsx index 987eb3857a3..31f95bae1dc 100644 --- a/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.tsx +++ b/apps/web/src/pages/Swap/Send/NewAddressSpeedBump.tsx @@ -3,10 +3,10 @@ import { Dialog } from 'components/Dialog/Dialog' import { UserIcon } from 'components/Icons/UserIcon' import Identicon, { IdenticonType, useIdenticonType } from 'components/Identicon' import Row from 'components/Row' -import { Trans } from 'i18n' import styled, { useTheme } from 'lib/styled-components' import { useSendContext } from 'state/send/SendContext' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const StyledUserIcon = styled(UserIcon)` width: 28px; @@ -51,11 +51,11 @@ export const NewAddressSpeedBumpModal = ({ onCancel, onConfirm }: { onCancel: () onCancel={onCancel} buttonsConfig={{ left: { - title: <Trans i18nKey="common.cancel.button" />, + title: <Trans i18nKey="common.button.cancel" />, onClick: onCancel, }, right: { - title: <Trans i18nKey="common.continue.button" />, + title: <Trans i18nKey="common.button.continue" />, onClick: onConfirm, }, }} diff --git a/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.test.tsx b/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.test.tsx index c0baf071c9c..c2ace2fdf9d 100644 --- a/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.test.tsx +++ b/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.test.tsx @@ -18,9 +18,11 @@ const mockSwapAndLimitContextValue = { prefilledState: {}, setSelectedChainId: jest.fn(), setCurrencyState: jest.fn(), + setIsUserSelectedChainId: jest.fn(), currentTab: SwapTab.Limit, setCurrentTab: jest.fn(), isSwapAndLimitContext: true, + isUserSelectedChainId: false, } const mockedSendContextDefault: SendContextType = { diff --git a/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.tsx b/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.tsx index 90954e77ce7..87626e38cf6 100644 --- a/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.tsx +++ b/apps/web/src/pages/Swap/Send/SendCurrencyInputForm.tsx @@ -13,7 +13,6 @@ import { PrefetchBalancesWrapper } from 'graphql/data/apollo/TokenBalancesProvid import { useAccount } from 'hooks/useAccount' import { useActiveLocalCurrency, useActiveLocalCurrencyComponents } from 'hooks/useActiveLocalCurrency' import { useUSDPrice } from 'hooks/useUSDPrice' -import { Trans } from 'i18n' import styled, { css } from 'lib/styled-components' import { NumericalInputMimic, @@ -25,10 +24,11 @@ import { import { useCallback, useMemo, useState } from 'react' import { useSendContext } from 'state/send/SendContext' import { SendInputError } from 'state/send/hooks' -import { useSwapAndLimitContext } from 'state/swap/hooks' import { CurrencyState } from 'state/swap/types' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { ClickableStyle, ThemedText } from 'theme/components' import { Text } from 'ui/src' +import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import useResizeObserver from 'use-resize-observer' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/pages/Swap/Send/SendForm.tsx b/apps/web/src/pages/Swap/Send/SendForm.tsx index b1c0895d2b6..c47e9f41cbd 100644 --- a/apps/web/src/pages/Swap/Send/SendForm.tsx +++ b/apps/web/src/pages/Swap/Send/SendForm.tsx @@ -7,7 +7,6 @@ import { useAccount } from 'hooks/useAccount' import { useGroupedRecentTransfers } from 'hooks/useGroupedRecentTransfers' import useSelectChain from 'hooks/useSelectChain' import { useSendCallback } from 'hooks/useSendCallback' -import { Trans } from 'i18n' import { NewAddressSpeedBumpModal } from 'pages/Swap/Send/NewAddressSpeedBump' import SendCurrencyInputForm from 'pages/Swap/Send/SendCurrencyInputForm' import { SendRecipientForm } from 'pages/Swap/Send/SendRecipientForm' @@ -15,11 +14,12 @@ import { SendReviewModal } from 'pages/Swap/Send/SendReviewModal' import { SmartContractSpeedBumpModal } from 'pages/Swap/Send/SmartContractSpeedBump' import { useCallback, useEffect, useMemo, useState } from 'react' import { SendContextProvider, useSendContext } from 'state/send/SendContext' -import { useSwapAndLimitContext } from 'state/swap/hooks' import { CurrencyState } from 'state/swap/types' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import Trace from 'uniswap/src/features/telemetry/Trace' import { InterfacePageNameLocal } from 'uniswap/src/features/telemetry/constants' +import { Trans } from 'uniswap/src/i18n' import { useIsSmartContractAddress } from 'utils/transfer' type SendFormProps = { diff --git a/apps/web/src/pages/Swap/Send/SendRecipientForm.test.tsx b/apps/web/src/pages/Swap/Send/SendRecipientForm.test.tsx index 3b733ab4506..4a920c9c703 100644 --- a/apps/web/src/pages/Swap/Send/SendRecipientForm.test.tsx +++ b/apps/web/src/pages/Swap/Send/SendRecipientForm.test.tsx @@ -14,9 +14,11 @@ const mockSwapAndLimitContextValue = { prefilledState: {}, setSelectedChainId: jest.fn(), setCurrencyState: jest.fn(), + setIsUserSelectedChainId: jest.fn(), currentTab: SwapTab.Limit, setCurrentTab: jest.fn(), isSwapAndLimitContext: true, + isUserSelectedChainId: false, } const mockedSendContextDefault: SendContextType = { diff --git a/apps/web/src/pages/Swap/Send/SendRecipientForm.tsx b/apps/web/src/pages/Swap/Send/SendRecipientForm.tsx index a61275c2ec6..e1d627c57fd 100644 --- a/apps/web/src/pages/Swap/Send/SendRecipientForm.tsx +++ b/apps/web/src/pages/Swap/Send/SendRecipientForm.tsx @@ -7,7 +7,6 @@ import useENSName from 'hooks/useENSName' import { useGroupedRecentTransfers } from 'hooks/useGroupedRecentTransfers' import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useUnmountingAnimation } from 'hooks/useUnmountingAnimation' -import { Plural, Trans, t } from 'i18n' import styled, { css, keyframes } from 'lib/styled-components' import { ChangeEvent, ForwardedRef, KeyboardEvent, forwardRef, useCallback, useRef, useState } from 'react' import { X } from 'react-feather' @@ -15,9 +14,11 @@ import { useSendContext } from 'state/send/SendContext' import { RecipientData } from 'state/send/hooks' import { ClickableStyle, ThemedText } from 'theme/components' import { AnimationType } from 'theme/components/FadePresence' +import { capitalize } from 'tsafe' import { Text } from 'ui/src' import { Unitag } from 'ui/src/components/icons' import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' +import { Plural, Trans, t, useTranslation } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' const StyledConfirmedRecipientRow = styled(Row)` @@ -219,6 +220,7 @@ const AutocompleteFlyout = forwardRef((props: AutocompleteFlyoutProps, ref: Forw AutocompleteFlyout.displayName = 'AutocompleteFlyout' export function SendRecipientForm({ disabled }: { disabled?: boolean }) { + const { t } = useTranslation() const account = useAccount() const { sendState, setSendState, derivedSendInfo } = useSendContext() const { recipient } = sendState @@ -306,7 +308,7 @@ export function SendRecipientForm({ disabled }: { disabled?: boolean }) { {showInputField ? ( <> <Text variant="body3" userSelect="none" color="$neutral2"> - <Trans i18nKey="common.to.caps" /> + {capitalize(t('common.to'))} </Text> <StyledRecipientInputRow justify="space-between"> <Row ref={inputWrapperNode}> diff --git a/apps/web/src/pages/Swap/Send/SendReviewModal.test.tsx b/apps/web/src/pages/Swap/Send/SendReviewModal.test.tsx index 61d92ef5b2f..042e7dfb04b 100644 --- a/apps/web/src/pages/Swap/Send/SendReviewModal.test.tsx +++ b/apps/web/src/pages/Swap/Send/SendReviewModal.test.tsx @@ -17,9 +17,11 @@ const mockSwapAndLimitContextValue = { prefilledState: {}, setSelectedChainId: jest.fn(), setCurrencyState: jest.fn(), + setIsUserSelectedChainId: jest.fn(), currentTab: SwapTab.Limit, setCurrentTab: jest.fn(), isSwapAndLimitContext: true, + isUserSelectedChainId: false, } const mockedSendContextFiatInput: SendContextType = { diff --git a/apps/web/src/pages/Swap/Send/SendReviewModal.tsx b/apps/web/src/pages/Swap/Send/SendReviewModal.tsx index 45ac05f343f..f5cab0cf6d8 100644 --- a/apps/web/src/pages/Swap/Send/SendReviewModal.tsx +++ b/apps/web/src/pages/Swap/Send/SendReviewModal.tsx @@ -7,13 +7,14 @@ import Modal from 'components/Modal' import { GetHelpHeader } from 'components/Modal/GetHelpHeader' import Row from 'components/Row' import { useStablecoinValue } from 'hooks/useStablecoinPrice' -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ReactNode } from 'react' import { useSendContext } from 'state/send/SendContext' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { Separator, ThemedText } from 'theme/components' +import { capitalize } from 'tsafe' import { Unitag } from 'ui/src/components/icons' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { shortenAddress } from 'utilities/src/addresses' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -64,6 +65,7 @@ const SendModalHeader = ({ } export function SendReviewModal({ onConfirm, onDismiss }: { onConfirm: () => void; onDismiss: () => void }) { + const { t } = useTranslation() const { chainId } = useSwapAndLimitContext() const { sendState: { inputCurrency, inputInFiat, exactAmountFiat }, @@ -107,7 +109,7 @@ export function SendReviewModal({ onConfirm, onDismiss }: { onConfirm: () => voi } /> <SendModalHeader - label={<Trans i18nKey="common.to.caps" />} + label={capitalize(t('common.to'))} header={ recipientData?.unitag || recipientData?.ensName ? ( <Row gap="xs"> diff --git a/apps/web/src/pages/Swap/Send/SmartContractSpeedBump.tsx b/apps/web/src/pages/Swap/Send/SmartContractSpeedBump.tsx index f8865128f72..2f2edcda4ca 100644 --- a/apps/web/src/pages/Swap/Send/SmartContractSpeedBump.tsx +++ b/apps/web/src/pages/Swap/Send/SmartContractSpeedBump.tsx @@ -1,7 +1,7 @@ import { Dialog } from 'components/Dialog/Dialog' import AlertTriangleFilled from 'components/Icons/AlertTriangleFilled' -import { Trans } from 'i18n' import styled from 'lib/styled-components' +import { Trans } from 'uniswap/src/i18n' const StyledAlertIcon = styled(AlertTriangleFilled)` path { @@ -25,11 +25,11 @@ export const SmartContractSpeedBumpModal = ({ onCancel={onCancel} buttonsConfig={{ left: { - title: <Trans i18nKey="common.cancel.button" />, + title: <Trans i18nKey="common.button.cancel" />, onClick: onCancel, }, right: { - title: <Trans i18nKey="common.continue.button" />, + title: <Trans i18nKey="common.button.continue" />, onClick: onConfirm, }, }} diff --git a/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap b/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap index 877512c2da3..465b80e00f8 100644 --- a/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap +++ b/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap @@ -428,7 +428,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` class="c3" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-14px _lineHeight-20px _fontWeight-400 _userSelect-none" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202 _userSelect-none" data-disable-theme="true" > You’re sending @@ -505,19 +505,23 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` class="c22" > <div - class="c23" - size="36" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _position-relative" > - <img - class="c24" - size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> - <img - class="c24" + <div + class="c23" size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> + > + <img + class="c24" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + <img + class="c24" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + </div> </div> </div> <div @@ -972,7 +976,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` class="c3" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-14px _lineHeight-20px _fontWeight-400 _userSelect-none" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202 _userSelect-none" data-disable-theme="true" > You’re sending @@ -1044,19 +1048,23 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` class="c21" > <div - class="c22" - size="36" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _position-relative" > - <img - class="c23" - size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> - <img - class="c23" + <div + class="c22" size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> + > + <img + class="c23" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + <img + class="c23" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + </div> </div> </div> <div @@ -1523,7 +1531,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` class="c3" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-14px _lineHeight-20px _fontWeight-400 _userSelect-none" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202 _userSelect-none" data-disable-theme="true" > You’re sending @@ -1598,19 +1606,23 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` class="c22" > <div - class="c23" - size="36" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _position-relative" > - <img - class="c24" - size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> - <img - class="c24" + <div + class="c23" size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> + > + <img + class="c24" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + <img + class="c24" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + </div> </div> </div> <div diff --git a/apps/web/src/pages/Swap/Send/__snapshots__/SendRecipientForm.test.tsx.snap b/apps/web/src/pages/Swap/Send/__snapshots__/SendRecipientForm.test.tsx.snap index 0fffc6ccb12..6121cfa4dc6 100644 --- a/apps/web/src/pages/Swap/Send/__snapshots__/SendRecipientForm.test.tsx.snap +++ b/apps/web/src/pages/Swap/Send/__snapshots__/SendRecipientForm.test.tsx.snap @@ -106,7 +106,7 @@ exports[`SendCurrencyInputform should render correctly with no verified recipien class="c0 c1" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-14px _lineHeight-20px _fontWeight-400 _userSelect-none" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202 _userSelect-none" data-disable-theme="true" > To @@ -740,7 +740,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` class="c0 c1" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-14px _lineHeight-20px _fontWeight-400 _userSelect-none" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202 _userSelect-none" data-disable-theme="true" > To diff --git a/apps/web/src/pages/Swap/Send/__snapshots__/SendReviewModal.test.tsx.snap b/apps/web/src/pages/Swap/Send/__snapshots__/SendReviewModal.test.tsx.snap index 8edd037f274..54ba991992e 100644 --- a/apps/web/src/pages/Swap/Send/__snapshots__/SendReviewModal.test.tsx.snap +++ b/apps/web/src/pages/Swap/Send/__snapshots__/SendReviewModal.test.tsx.snap @@ -433,7 +433,7 @@ exports[`SendCurrencyInputform should render input in fiat correctly 1`] = ` class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-400" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441189 _lineHeight-222976542 _fontWeight-233016202" data-disable-theme="true" > Review send @@ -528,15 +528,19 @@ exports[`SendCurrencyInputform should render input in fiat correctly 1`] = ` class="c16" > <div - class="c17" - size="36" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _position-relative" > - - <img - class="c18" + <div + class="c17" size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> + > + + <img + class="c18" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + </div> </div> </div> </div> @@ -1068,7 +1072,7 @@ exports[`SendCurrencyInputform should render input in token amount correctly 1`] class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0" > <span - class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-16px _lineHeight-24px _fontWeight-400" + class="font_body _fontFamily-299667014 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843134974 _fontSize-229441189 _lineHeight-222976542 _fontWeight-233016202" data-disable-theme="true" > Review send @@ -1163,15 +1167,19 @@ exports[`SendCurrencyInputform should render input in token amount correctly 1`] class="c16" > <div - class="c17" - size="36" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _position-relative" > - - <img - class="c18" + <div + class="c17" size="36" - src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" - /> + > + + <img + class="c18" + size="36" + src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png" + /> + </div> </div> </div> </div> diff --git a/apps/web/src/pages/Swap/SwapForm.tsx b/apps/web/src/pages/Swap/SwapForm.tsx index ebda98bc21a..d8da1cd0cb8 100644 --- a/apps/web/src/pages/Swap/SwapForm.tsx +++ b/apps/web/src/pages/Swap/SwapForm.tsx @@ -32,7 +32,6 @@ import useSelectChain from 'hooks/useSelectChain' import { SwapResult, useSwapCallback } from 'hooks/useSwapCallback' import { useUSDPrice } from 'hooks/useUSDPrice' import useWrapCallback, { WrapErrorText } from 'hooks/useWrapCallback' -import { Trans } from 'i18n' import JSBI from 'jsbi' import { useTheme } from 'lib/styled-components' import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics' @@ -45,8 +44,9 @@ import { Text } from 'rebass' import { useAppSelector } from 'state/hooks' import { InterfaceTrade, RouterPreference, TradeState } from 'state/routing/types' import { isClassicTrade } from 'state/routing/utils' -import { useSwapActionHandlers, useSwapAndLimitContext, useSwapContext } from 'state/swap/hooks' +import { useSwapActionHandlers } from 'state/swap/hooks' import { CurrencyState } from 'state/swap/types' +import { useSwapAndLimitContext, useSwapContext } from 'state/swap/useSwapContext' import { ExternalLink, ThemedText } from 'theme/components' import { maybeLogFirstSwapAction } from 'tracing/swapFlowLoggers' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' @@ -54,6 +54,8 @@ import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generat import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { Trans } from 'uniswap/src/i18n' +import { CurrencyField } from 'uniswap/src/types/currency' import { WrapType } from 'uniswap/src/types/wrap' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' @@ -70,10 +72,15 @@ const SWAP_FORM_CURRENCY_SEARCH_FILTERS = { interface SwapFormProps { disableTokenInputs?: boolean + initialCurrencyLoading?: boolean onCurrencyChange?: (selected: CurrencyState) => void } -export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapFormProps) { +export function SwapForm({ + disableTokenInputs = false, + initialCurrencyLoading = false, + onCurrencyChange, +}: SwapFormProps) { const { isDisconnected, chainId: connectedChainId } = useAccount() const trace = useTrace() @@ -536,6 +543,7 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF value={formattedAmounts[Field.INPUT]} showMaxButton={showMaxButton} currency={currencies[Field.INPUT] ?? null} + currencyField={CurrencyField.INPUT} onUserInput={handleTypeInput} onMax={handleMaxInput} fiatValue={showFiatValueInput ? fiatValueInput : undefined} @@ -544,6 +552,7 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF currencySearchFilters={SWAP_FORM_CURRENCY_SEARCH_FILTERS} id={InterfaceSectionName.CURRENCY_INPUT_PANEL} loading={independentField === Field.OUTPUT && routeIsSyncing} + initialCurrencyLoading={initialCurrencyLoading} ref={inputCurrencyNumericalInputRef} /> </Trace> @@ -587,6 +596,7 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF fiatValue={showFiatValueOutput ? fiatValueOutput : undefined} priceImpact={stablecoinPriceImpact} currency={currencies[Field.OUTPUT] ?? null} + currencyField={CurrencyField.OUTPUT} onCurrencySelect={handleOutputSelect} otherCurrency={currencies[Field.INPUT]} currencySearchFilters={SWAP_FORM_CURRENCY_SEARCH_FILTERS} @@ -640,7 +650,7 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF <ButtonPrimary $borderRadius="16px" onClick={async () => await selectChain(initialChainId)}> <Trans i18nKey="common.connectToChain.button" - values={{ chainName: supportedChainId ? UNIVERSE_CHAIN_INFO[initialChainId].label : '' }} + values={{ chainName: initialChainId ? UNIVERSE_CHAIN_INFO[initialChainId].label : '' }} /> </ButtonPrimary> ) : showWrap ? ( @@ -668,15 +678,8 @@ export function SwapForm({ disableTokenInputs = false, onCurrencyChange }: SwapF ) : ( <Trace logPress element={InterfaceElementName.SWAP_BUTTON}> <ButtonError - onClick={async () => { - const inputChainId = trade?.inputAmount?.currency?.chainId - let correctChain = true - if (inputChainId && inputChainId !== connectedChainId) { - correctChain = await selectChain(inputChainId) - } - if (correctChain) { - showPriceImpactWarning ? setShowPriceImpactModal(true) : handleContinueToReview() - } + onClick={() => { + showPriceImpactWarning ? setShowPriceImpactModal(true) : handleContinueToReview() }} id="swap-button" data-testid="swap-button" diff --git a/apps/web/src/pages/Swap/TaxTooltipBody.tsx b/apps/web/src/pages/Swap/TaxTooltipBody.tsx index b478d5f5718..0507562781f 100644 --- a/apps/web/src/pages/Swap/TaxTooltipBody.tsx +++ b/apps/web/src/pages/Swap/TaxTooltipBody.tsx @@ -1,6 +1,6 @@ -import { Trans } from 'i18n' import styled from 'lib/styled-components' import { ThemedText } from 'theme/components' +import { Trans } from 'uniswap/src/i18n' const Divider = styled.div` width: 100%; diff --git a/apps/web/src/pages/Swap/index.tsx b/apps/web/src/pages/Swap/index.tsx index c4c0f3896da..2054ac7d987 100644 --- a/apps/web/src/pages/Swap/index.tsx +++ b/apps/web/src/pages/Swap/index.tsx @@ -46,7 +46,8 @@ export default function SwapPage({ className }: { className?: string }) { const location = useLocation() const multichainUXEnabled = useFeatureFlag(FeatureFlags.MultichainUX) - const { initialInputCurrency, initialOutputCurrency, initialChainId } = useInitialCurrencyState() + const { initialInputCurrency, initialOutputCurrency, initialChainId, initialCurrencyLoading } = + useInitialCurrencyState() const isUnsupportedConnectedChain = useSupportedChainId(useAccount().chainId) === undefined const shouldDisableTokenInputs = multichainUXEnabled ? false : isUnsupportedConnectedChain @@ -60,6 +61,7 @@ export default function SwapPage({ className }: { className?: string }) { disableTokenInputs={shouldDisableTokenInputs} initialInputCurrency={initialInputCurrency} initialOutputCurrency={initialOutputCurrency} + initialCurrencyLoading={initialCurrencyLoading} syncTabToUrl={true} /> </PageWrapper> @@ -79,6 +81,7 @@ export function Swap({ className, initialInputCurrency, initialOutputCurrency, + initialCurrencyLoading = false, chainId, onCurrencyChange, multichainUXEnabled = false, @@ -92,13 +95,14 @@ export function Swap({ disableTokenInputs?: boolean initialInputCurrency?: Currency initialOutputCurrency?: Currency + initialCurrencyLoading?: boolean compact?: boolean syncTabToUrl: boolean multichainUXEnabled?: boolean }) { const isDark = useIsDarkMode() const screenSize = useScreenSize() - const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregatorWeb) + const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregator) return ( <SwapAndLimitContextProvider @@ -115,7 +119,11 @@ export function Swap({ <SwapWrapper isDark={isDark} className={className} id="swap-page"> <SwapHeader compact={compact || !screenSize.sm} syncTabToUrl={syncTabToUrl} /> {currentTab === SwapTab.Swap && ( - <SwapForm onCurrencyChange={onCurrencyChange} disableTokenInputs={disableTokenInputs} /> + <SwapForm + onCurrencyChange={onCurrencyChange} + initialCurrencyLoading={initialCurrencyLoading} + disableTokenInputs={disableTokenInputs} + /> )} {currentTab === SwapTab.Limit && <LimitFormWrapper onCurrencyChange={onCurrencyChange} />} {currentTab === SwapTab.Send && ( diff --git a/apps/web/src/pages/TokenDetails/utils.ts b/apps/web/src/pages/TokenDetails/utils.ts index 44a9c682f3f..1b4088a3fb1 100644 --- a/apps/web/src/pages/TokenDetails/utils.ts +++ b/apps/web/src/pages/TokenDetails/utils.ts @@ -1,7 +1,7 @@ import { Currency } from '@uniswap/sdk-core' import { SupportedInterfaceChainId } from 'constants/chains' -import { t } from 'i18n' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' export const getTokenPageTitle = (currency?: Currency, chainId?: SupportedInterfaceChainId) => { diff --git a/apps/web/src/pages/Vote/Landing.tsx b/apps/web/src/pages/Vote/Landing.tsx index 7994b9890b2..5ef047bf9eb 100644 --- a/apps/web/src/pages/Vote/Landing.tsx +++ b/apps/web/src/pages/Vote/Landing.tsx @@ -13,7 +13,6 @@ import ProposalEmptyState from 'components/vote/ProposalEmptyState' import { ZERO_ADDRESS } from 'constants/misc' import { UNI } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' -import { Trans } from 'i18n' import JSBI from 'jsbi' import styled, { useTheme } from 'lib/styled-components' import { ProposalStatus } from 'pages/Vote/styled' @@ -27,6 +26,7 @@ import { useTokenBalance } from 'state/connection/hooks' import { ProposalData, ProposalState, useAllProposalData, useUserDelegatee, useUserVotes } from 'state/governance/hooks' import { ExternalLink, ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans } from 'uniswap/src/i18n' import { shortenAddress } from 'utilities/src/addresses' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' diff --git a/apps/web/src/pages/Vote/VotePage.tsx b/apps/web/src/pages/Vote/VotePage.tsx index 02b537ed281..e31934150e1 100644 --- a/apps/web/src/pages/Vote/VotePage.tsx +++ b/apps/web/src/pages/Vote/VotePage.tsx @@ -22,7 +22,6 @@ import { UNI } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' import { useActiveLocale } from 'hooks/useActiveLocale' import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' -import { Trans } from 'i18n' import JSBI from 'jsbi' import useBlockNumber from 'lib/hooks/useBlockNumber' import styled from 'lib/styled-components' @@ -51,7 +50,9 @@ import { } from 'state/governance/hooks' import { VoteOption } from 'state/governance/types' import { ExternalLink, StyledInternalLink, ThemedText } from 'theme/components' +import { Flex } from 'ui/src' import Trace from 'uniswap/src/features/telemetry/Trace' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { isAddress } from 'utilities/src/addresses' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -162,6 +163,7 @@ function getDateFromBlock( } export default function VotePage() { + const { t } = useTranslation() // see https://github.com/remix-run/react-router/issues/8200#issuecomment-962520661 const { governorIndex, id } = useParams() as { governorIndex: string; id: string } const parsedGovernorIndex = Number.parseInt(governorIndex) @@ -292,12 +294,10 @@ export default function VotePage() { <ProposalInfo gap="lg" justify="start"> <RowBetween style={{ width: '100%' }}> <ArrowWrapper to="/vote"> - <Trans - i18nKey="vote.votePage.allProposals" - values={{ - arrow: <ArrowLeft size={20} />, - }} - /> + <Flex gap="$spacing4"> + <ArrowLeft size={20} /> + {t('vote.votePage.allProposals')} + </Flex> </ArrowWrapper> {proposalData && <ProposalStatus status={proposalData.status} />} </RowBetween> diff --git a/apps/web/src/pages/Vote/styled.tsx b/apps/web/src/pages/Vote/styled.tsx index 446509cb6a7..152c6ca7ad9 100644 --- a/apps/web/src/pages/Vote/styled.tsx +++ b/apps/web/src/pages/Vote/styled.tsx @@ -1,6 +1,6 @@ -import { Trans } from 'i18n' import styled, { DefaultTheme } from 'lib/styled-components' import { ProposalState } from 'state/governance/hooks' +import { Trans } from 'uniswap/src/i18n' const handleColorType = (status: ProposalState, theme: DefaultTheme) => { switch (status) { diff --git a/apps/web/src/pages/getExploreTitle.ts b/apps/web/src/pages/getExploreTitle.ts index 68f73f213b5..10546cf3d45 100644 --- a/apps/web/src/pages/getExploreTitle.ts +++ b/apps/web/src/pages/getExploreTitle.ts @@ -1,29 +1,46 @@ import { ChainSlug, isChainUrlParam } from 'constants/chains' -import { t } from 'i18n' import { ExploreTab } from 'pages/Explore' import { capitalize } from 'tsafe/capitalize' +import { t } from 'uniswap/src/i18n' +import { logger } from 'utilities/src/logger/logger' export const getExploreTitle = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const tabsToFind: string[] = [ExploreTab.Pools, ExploreTab.Tokens, ExploreTab.Transactions] const tab = parts?.find((part) => tabsToFind.includes(part)) ?? ExploreTab.Tokens - const network: ChainSlug = parts?.find(isChainUrlParam) ?? 'ethereum' + const networkPart: ChainSlug = parts?.find(isChainUrlParam) ?? 'ethereum' + const network = capitalize(networkPart) - return t(`Explore top {{tab}} on {{network}} on Uniswap`, { - tab, - network: capitalize(network), - }) + switch (tab) { + case ExploreTab.Pools: + return t(`web.explore.title.pools`, { + network, + }) + case ExploreTab.Tokens: + return t(`web.explore.title.tokens`, { + network, + }) + case ExploreTab.Transactions: + return t(`web.explore.title.transactions`, { + network, + }) + default: + logger.error(`Unavailable explore title for tab ${tab}`, { + tags: { + file: 'getExploreTitle', + function: 'getExploreTitle', + }, + }) + return '' + } } export const getExploreDescription = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const network: ChainSlug = parts?.find(isChainUrlParam) ?? 'ethereum' - return t( - `Discover and research tokens on {{network}}. Explore top pools. View real-time prices, trading volume, TVL, charts, and transaction data.`, - { - network: capitalize(network), - }, - ) + return t(`web.explore.description`, { + network: capitalize(network), + }) } diff --git a/apps/web/src/pages/getPositionPageTitle.ts b/apps/web/src/pages/getPositionPageTitle.ts index 188e3adbfab..b0621d47c1c 100644 --- a/apps/web/src/pages/getPositionPageTitle.ts +++ b/apps/web/src/pages/getPositionPageTitle.ts @@ -1,10 +1,10 @@ -import { t } from 'i18n' +import { t } from 'uniswap/src/i18n' export const getPositionPageTitle = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const isV2 = parts?.find((part) => part === 'v2') - return t(`Manage pool liquidity{{version}} on Uniswap`, { + return t(`liquidityPool.positions.page.version.title`, { version: isV2 ? ' (v2)' : '', }) } @@ -13,7 +13,7 @@ export const getPositionPageDescription = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const isV2 = parts?.find((part) => part === 'v2') - return t(`View your active {{version}} liquidity positions. Add new positions.`, { + return t(`liquidityPool.positions.page.version.description`, { version: isV2 ? 'v2' : 'v3', }) } @@ -22,7 +22,7 @@ export const getAddLiquidityPageTitle = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const isV2 = parts?.find((part) => part === 'v2') - return t(`Add liquidity to pools{{version}} on Uniswap`, { + return t('liquidityPool.page.title', { version: isV2 ? ' (v2)' : '', }) } diff --git a/apps/web/src/pages/metatags.ts b/apps/web/src/pages/metatags.ts index 229aced489a..89c9be4c55c 100644 --- a/apps/web/src/pages/metatags.ts +++ b/apps/web/src/pages/metatags.ts @@ -1,11 +1,11 @@ -import { t } from 'i18n' import { useEffect, useState } from 'react' import { useLocation } from 'react-router-dom' import { MetaTagInjectorInput } from 'shared-cloud/metatags' +import { t } from 'uniswap/src/i18n' const DEFAULT_METATAGS: MetaTagInjectorInput = { - title: t('common.uniInterface'), - description: t`Swap or provide liquidity on the Uniswap Protocol`, + title: t('interface.metatags.title'), + description: t`interface.metatags.description`, image: `https://app.uniswap.com/images/1200x630_Rich_Link_Preview_Image.png`, url: 'https://app.uniswap.com', } diff --git a/apps/web/src/setupTests.ts b/apps/web/src/setupTests.ts index 7784d52641c..09f764b041e 100644 --- a/apps/web/src/setupTests.ts +++ b/apps/web/src/setupTests.ts @@ -3,6 +3,7 @@ import '@testing-library/jest-dom' // jest custom assertions import '@vanilla-extract/css/disableRuntimeStyles' // https://vanilla-extract.style/documentation/test-environments/#disabling-runtime-styles import 'jest-styled-components' // adds style diffs to snapshot tests import 'polyfills' // add polyfills +import { setupi18n } from 'uniswap/src/i18n/i18n-setup-interface' import 'utilities/src/logger/mocks' import type { createPopper } from '@popperjs/core' @@ -16,6 +17,8 @@ import { mocked } from 'test-utils/mocked' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { TextDecoder, TextEncoder } from 'util' +setupi18n() + // Sets origin to the production origin, because some tests depend on this. // This prevents each test file from needing to set this manually. globalThis.origin = 'https://app.uniswap.org' @@ -51,6 +54,8 @@ globalThis.origin = 'https://app.uniswap.org' globalThis.React = React } +jest.mock('react-native-svg', () => require('@tamagui/react-native-svg')) + jest.mock('@popperjs/core', () => { const core = jest.requireActual('@popperjs/core') return { diff --git a/apps/web/src/sideEffects.ts b/apps/web/src/sideEffects.ts new file mode 100644 index 00000000000..2f06f7c3c2f --- /dev/null +++ b/apps/web/src/sideEffects.ts @@ -0,0 +1,14 @@ +// note the reason for the setupi18n function is to avoid webpack tree shaking the file out +// prettier-ignore +import { setupi18n } from 'uniswap/src/i18n/i18n-setup-interface' +// prettier-ignore +import '@reach/dialog/styles.css' +// prettier-ignore +import 'polyfills' +// prettier-ignore +import 'tracing' +// prettier-ignore +import 'setupRive' + +// adding this so webpack won't tree shake this away, sideEffects was giving trouble +setupi18n() diff --git a/apps/web/src/state/activity/polling/transactions.ts b/apps/web/src/state/activity/polling/transactions.ts index 6328a7fe82f..684f191ca64 100644 --- a/apps/web/src/state/activity/polling/transactions.ts +++ b/apps/web/src/state/activity/polling/transactions.ts @@ -1,9 +1,9 @@ import { NEVER_RELOAD } from '@uniswap/redux-multicall' import { useWeb3React } from '@web3-react/core' -import { SupportedInterfaceChainId, getChain, useSupportedChainId } from 'constants/chains' +import { SupportedInterfaceChainId, getChain } from 'constants/chains' import { useAccount } from 'hooks/useAccount' import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' -import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber' +import useBlockNumber from 'lib/hooks/useBlockNumber' import ms from 'ms' import { useCallback, useEffect, useMemo } from 'react' import { CanceledError, RetryableError, retry } from 'state/activity/polling/retry' @@ -79,22 +79,20 @@ export function usePollPendingTransactions(onActivityUpdate: OnActivityUpdate) { ? undefined : account.chainId, ) - const supportedChain = useSupportedChainId(account.chainId) const hasPending = pendingTransactions.length > 0 const blockTimestamp = useCurrentBlockTimestamp(hasPending ? undefined : NEVER_RELOAD) const lastBlockNumber = useBlockNumber() - const fastForwardBlockNumber = useFastForwardBlockNumber() const removeTransaction = useTransactionRemover() const dispatch = useAppDispatch() const getReceipt = useCallback( (tx: PendingTransactionDetails) => { - if (!provider || !supportedChain) { + if (!provider || !account.chainId) { throw new Error('No provider or chainId') } const retryOptions = - getChain({ chainId: supportedChain })?.pendingTransactionsRetryOptions ?? DEFAULT_RETRY_OPTIONS + getChain({ chainId: account.chainId })?.pendingTransactionsRetryOptions ?? DEFAULT_RETRY_OPTIONS return retry( () => provider.getTransactionReceipt(tx.hash).then(async (receipt) => { @@ -117,7 +115,7 @@ export function usePollPendingTransactions(onActivityUpdate: OnActivityUpdate) { retryOptions, ) }, - [account.isConnected, blockTimestamp, provider, removeTransaction, supportedChain], + [account.chainId, account.isConnected, blockTimestamp, provider, removeTransaction], ) useEffect(() => { @@ -134,7 +132,6 @@ export function usePollPendingTransactions(onActivityUpdate: OnActivityUpdate) { if (!account.chainId) { return } - fastForwardBlockNumber(receipt.blockNumber) onActivityUpdate({ type: 'transaction', chainId: account.chainId, @@ -163,7 +160,6 @@ export function usePollPendingTransactions(onActivityUpdate: OnActivityUpdate) { lastBlockNumber, getReceipt, pendingTransactions, - fastForwardBlockNumber, hasPending, dispatch, onActivityUpdate, diff --git a/apps/web/src/state/application/reducer.ts b/apps/web/src/state/application/reducer.ts index 3047c817c0a..b6e1799f0f3 100644 --- a/apps/web/src/state/application/reducer.ts +++ b/apps/web/src/state/application/reducer.ts @@ -1,11 +1,13 @@ import { createSlice, nanoid } from '@reduxjs/toolkit' import { DEFAULT_TXN_DISMISS_MS } from 'constants/misc' import { InterfaceChainId } from 'uniswap/src/types/chains' +import { SwapTab } from 'uniswap/src/types/screens/interface' export enum PopupType { Transaction = 'transaction', Order = 'order', FailedSwitchNetwork = 'failedSwitchNetwork', + SwitchNetwork = 'switchNetwork', } export type PopupContent = @@ -21,6 +23,11 @@ export type PopupContent = type: PopupType.FailedSwitchNetwork failedSwitchNetwork: InterfaceChainId } + | { + type: PopupType.SwitchNetwork + chainId: InterfaceChainId + action: SwapTab + } export enum ApplicationModal { ADDRESS_CLAIM, diff --git a/apps/web/src/state/burn/hooks.tsx b/apps/web/src/state/burn/hooks.tsx index 6f710e433ca..9ea96f611ca 100644 --- a/apps/web/src/state/burn/hooks.tsx +++ b/apps/web/src/state/burn/hooks.tsx @@ -3,7 +3,6 @@ import { Pair } from '@uniswap/v2-sdk' import { useAccount } from 'hooks/useAccount' import { useTotalSupply } from 'hooks/useTotalSupply' import { useV2Pair } from 'hooks/useV2Pairs' -import { Trans } from 'i18n' import JSBI from 'jsbi' import useCurrencyBalance from 'lib/hooks/useCurrencyBalance' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' @@ -11,6 +10,7 @@ import { ReactNode, useCallback } from 'react' import { Field, typeInput } from 'state/burn/actions' import { useAppDispatch, useAppSelector } from 'state/hooks' import { AppState } from 'state/reducer' +import { Trans } from 'uniswap/src/i18n' export function useBurnState(): AppState['burn'] { return useAppSelector((state) => state.burn) diff --git a/apps/web/src/state/burn/v3/hooks.tsx b/apps/web/src/state/burn/v3/hooks.tsx index 87cd626ec87..8b4744272dc 100644 --- a/apps/web/src/state/burn/v3/hooks.tsx +++ b/apps/web/src/state/burn/v3/hooks.tsx @@ -4,12 +4,12 @@ import { useToken } from 'hooks/Tokens' import { useAccount } from 'hooks/useAccount' import { usePool } from 'hooks/usePools' import { useV3PositionFees } from 'hooks/useV3PositionFees' -import { Trans } from 'i18n' import { ReactNode, useCallback, useMemo } from 'react' import { selectPercent } from 'state/burn/v3/actions' import { useAppDispatch, useAppSelector } from 'state/hooks' import { AppState } from 'state/reducer' import { PositionDetails } from 'types/position' +import { Trans } from 'uniswap/src/i18n' import { unwrappedToken } from 'utils/unwrappedToken' export function useBurnV3State(): AppState['burnV3'] { diff --git a/apps/web/src/state/claim/hooks.ts b/apps/web/src/state/claim/hooks.ts index dbd9f60ea74..69e5906166f 100644 --- a/apps/web/src/state/claim/hooks.ts +++ b/apps/web/src/state/claim/hooks.ts @@ -15,7 +15,12 @@ import { logger } from 'utilities/src/logger/logger' import { calculateGasMargin } from 'utils/calculateGasMargin' function useMerkleDistributorContract() { - return useContract(MERKLE_DISTRIBUTOR_ADDRESS, MerkleDistributorJSON.abi, true) + const account = useAccount() + return useContract( + account.chainId ? MERKLE_DISTRIBUTOR_ADDRESS[account.chainId] : undefined, + MerkleDistributorJSON.abi, + true, + ) } interface UserClaimData { diff --git a/apps/web/src/state/governance/hooks.ts b/apps/web/src/state/governance/hooks.ts index 369b5bccc94..20a8126793c 100644 --- a/apps/web/src/state/governance/hooks.ts +++ b/apps/web/src/state/governance/hooks.ts @@ -28,7 +28,6 @@ import { UNISWAP_GRANTS_PROPOSAL_DESCRIPTION } from 'constants/proposals/uniswap import { UNI } from 'constants/tokens' import { useAccount } from 'hooks/useAccount' import { useContract } from 'hooks/useContract' -import { t } from 'i18n' import { useSingleCallResult, useSingleContractMultipleData } from 'lib/hooks/multicall' import { useCallback, useMemo } from 'react' import { VoteOption } from 'state/governance/types' @@ -36,19 +35,35 @@ import { useLogs } from 'state/logs/hooks' import { useTransactionAdder } from 'state/transactions/hooks' import { TransactionType } from 'state/transactions/types' import GOVERNOR_BRAVO_ABI from 'uniswap/src/abis/governor-bravo.json' +import { t } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { calculateGasMargin } from 'utils/calculateGasMargin' function useGovernanceV0Contract(): Contract | null { - return useContract(GOVERNANCE_ALPHA_V0_ADDRESSES, GovernorAlphaJSON.abi, false) + const account = useAccount() + return useContract( + account.chainId ? GOVERNANCE_ALPHA_V0_ADDRESSES[account.chainId] : undefined, + GovernorAlphaJSON.abi, + false, + ) } function useGovernanceV1Contract(): Contract | null { - return useContract(GOVERNANCE_ALPHA_V1_ADDRESSES, GovernorAlphaJSON.abi, false) + const account = useAccount() + return useContract( + account.chainId ? GOVERNANCE_ALPHA_V1_ADDRESSES[account.chainId] : undefined, + GovernorAlphaJSON.abi, + false, + ) } function useGovernanceBravoContract(): Contract | null { - return useContract(GOVERNANCE_BRAVO_ADDRESSES, GOVERNOR_BRAVO_ABI, true) + const account = useAccount() + return useContract( + account.chainId ? GOVERNANCE_BRAVO_ADDRESSES[account.chainId] : undefined, + GOVERNOR_BRAVO_ABI, + true, + ) } const useLatestGovernanceContract = useGovernanceBravoContract diff --git a/apps/web/src/state/limit/hooks.ts b/apps/web/src/state/limit/hooks.ts index 4ef27665e18..71f5c2b2e3f 100644 --- a/apps/web/src/state/limit/hooks.ts +++ b/apps/web/src/state/limit/hooks.ts @@ -13,7 +13,7 @@ import { getWrapInfo } from 'state/routing/gas' import { LimitOrderTrade, RouterPreference, SubmittableTrade, SwapFeeInfo, WrapInfo } from 'state/routing/types' import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade' import { getUSDCostPerGas, isClassicTrade } from 'state/routing/utils' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' diff --git a/apps/web/src/state/logs/updater.ts b/apps/web/src/state/logs/updater.ts index 6b3990bd277..9fc2713d801 100644 --- a/apps/web/src/state/logs/updater.ts +++ b/apps/web/src/state/logs/updater.ts @@ -14,7 +14,7 @@ export default function Updater(): null { const { chainId } = useAccount() const { provider } = useWeb3React() - const blockNumber = useBlockNumber() + const blockNumber = useBlockNumber const filtersNeedFetch: Filter[] = useMemo(() => { if (!chainId || typeof blockNumber !== 'number') { diff --git a/apps/web/src/state/mint/hooks.tsx b/apps/web/src/state/mint/hooks.tsx index 62523e813de..d836c8f3144 100644 --- a/apps/web/src/state/mint/hooks.tsx +++ b/apps/web/src/state/mint/hooks.tsx @@ -3,7 +3,6 @@ import { Pair } from '@uniswap/v2-sdk' import { useAccount } from 'hooks/useAccount' import { useTotalSupply } from 'hooks/useTotalSupply' import { PairState, useV2Pair } from 'hooks/useV2Pairs' -import { Trans } from 'i18n' import JSBI from 'jsbi' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { ReactNode, useCallback, useMemo } from 'react' @@ -11,6 +10,7 @@ import { useCurrencyBalances } from 'state/connection/hooks' import { useAppDispatch, useAppSelector } from 'state/hooks' import { Field, typeInput } from 'state/mint/actions' import { AppState } from 'state/reducer' +import { Trans } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' const ZERO = JSBI.BigInt(0) diff --git a/apps/web/src/state/mint/v3/hooks.tsx b/apps/web/src/state/mint/v3/hooks.tsx index 6c89499e225..5c83120c9ce 100644 --- a/apps/web/src/state/mint/v3/hooks.tsx +++ b/apps/web/src/state/mint/v3/hooks.tsx @@ -14,7 +14,6 @@ import { BIG_INT_ZERO } from 'constants/misc' import { useAccount } from 'hooks/useAccount' import { PoolState, usePool } from 'hooks/usePools' import { useSwapTaxes } from 'hooks/useSwapTaxes' -import { Trans } from 'i18n' import JSBI from 'jsbi' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { ReactNode, useCallback, useMemo } from 'react' @@ -32,6 +31,7 @@ import { } from 'state/mint/v3/actions' import { tryParseTick } from 'state/mint/v3/utils' import { AppState } from 'state/reducer' +import { Trans } from 'uniswap/src/i18n' import { getTickToPrice } from 'utils/getTickToPrice' export function useV3MintState(): AppState['mintV3'] { diff --git a/apps/web/src/state/send/SendContext.tsx b/apps/web/src/state/send/SendContext.tsx index ad507fdfdf4..be9c1d5faed 100644 --- a/apps/web/src/state/send/SendContext.tsx +++ b/apps/web/src/state/send/SendContext.tsx @@ -10,7 +10,7 @@ import { useState, } from 'react' import { RecipientData, SendInfo, useDerivedSendInfo } from 'state/send/hooks' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' export type SendState = { readonly exactAmountToken?: string diff --git a/apps/web/src/state/send/hooks.tsx b/apps/web/src/state/send/hooks.tsx index 34e51ec5987..9db11d7dc35 100644 --- a/apps/web/src/state/send/hooks.tsx +++ b/apps/web/src/state/send/hooks.tsx @@ -12,7 +12,7 @@ import { useCurrencyBalances } from 'lib/hooks/useCurrencyBalance' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { useMemo } from 'react' import { SendState } from 'state/send/SendContext' -import { useSwapAndLimitContext } from 'state/swap/hooks' +import { useSwapAndLimitContext } from 'state/swap/useSwapContext' import { useUnitagByAddress, useUnitagByName } from 'uniswap/src/features/unitags/hooks' import { isAddress } from 'utilities/src/addresses' import { useCreateTransferTransaction } from 'utils/transfer' diff --git a/apps/web/src/state/swap/SwapContext.test.tsx b/apps/web/src/state/swap/SwapContext.test.tsx index 13160209203..8ef3597ca47 100644 --- a/apps/web/src/state/swap/SwapContext.test.tsx +++ b/apps/web/src/state/swap/SwapContext.test.tsx @@ -3,8 +3,8 @@ import { Field } from 'components/swap/constants' import { nativeOnChain } from 'constants/tokens' import { SwapForm } from 'pages/Swap/SwapForm' import { SwapAndLimitContextProvider, SwapContextProvider } from 'state/swap/SwapContext' -import { useSwapAndLimitContext, useSwapContext } from 'state/swap/hooks' import { SwapAndLimitContext, SwapInfo } from 'state/swap/types' +import { useSwapAndLimitContext, useSwapContext } from 'state/swap/useSwapContext' import { render, screen } from 'test-utils/render' import { UniverseChainId } from 'uniswap/src/types/chains' import { SwapTab } from 'uniswap/src/types/screens/interface' @@ -87,11 +87,13 @@ describe('SwapAndLimitContext', () => { multichainUXEnabled: undefined, setSelectedChainId: expect.any(Function), setCurrencyState: expect.any(Function), + setIsUserSelectedChainId: expect.any(Function), currentTab: SwapTab.Swap, setCurrentTab: expect.any(Function), chainId: 1, pageChainId: undefined, isSwapAndLimitContext: true, + isUserSelectedChainId: false, }) }) @@ -138,10 +140,12 @@ describe('Combined contexts', () => { }, setCurrencyState: expect.any(Function), setSelectedChainId: jest.fn(), + setIsUserSelectedChainId: jest.fn(), chainId: UniverseChainId.Mainnet, currentTab: SwapTab.Swap, setCurrentTab: expect.any(Function), isSwapAndLimitContext: true, + isUserSelectedChainId: false, }} > <SwapContextProvider> diff --git a/apps/web/src/state/swap/SwapContext.tsx b/apps/web/src/state/swap/SwapContext.tsx index 2019089aafc..a12de8093a5 100644 --- a/apps/web/src/state/swap/SwapContext.tsx +++ b/apps/web/src/state/swap/SwapContext.tsx @@ -1,6 +1,8 @@ import { Currency } from '@uniswap/sdk-core' import { useAccount } from 'hooks/useAccount' import usePrevious from 'hooks/usePrevious' +import { useUpdateAtom } from 'jotai/utils' +import { multicallUpdaterSwapChainIdAtom } from 'lib/hooks/useBlockNumber' import { PropsWithChildren, useEffect, useMemo, useState } from 'react' import { useDerivedSwapInfo } from 'state/swap/hooks' import { CurrencyState, SwapAndLimitContext, SwapContext, SwapState, initialSwapState } from 'state/swap/types' @@ -20,6 +22,7 @@ export function SwapAndLimitContextProvider({ multichainUXEnabled?: boolean }>) { const [selectedChainId, setSelectedChainId] = useState<InterfaceChainId | undefined | null>(initialChainId) + const [isUserSelectedChainId, setIsUserSelectedChainId] = useState<boolean>(false) const [currentTab, setCurrentTab] = useState<SwapTab>(SwapTab.Swap) const [currencyState, setCurrencyState] = useState<CurrencyState>({ @@ -93,6 +96,12 @@ export function SwapAndLimitContextProvider({ } }, [initialChainId, setSelectedChainId]) + const setMulticallUpdaterChainId = useUpdateAtom(multicallUpdaterSwapChainIdAtom) + useEffect(() => { + const chainId = (multichainUXEnabled ? selectedChainId : account.chainId) ?? undefined + setMulticallUpdaterChainId(chainId) + }, [account.chainId, multichainUXEnabled, selectedChainId, setMulticallUpdaterChainId]) + const value = useMemo(() => { return { currencyState, @@ -105,8 +114,19 @@ export function SwapAndLimitContextProvider({ chainId: (multichainUXEnabled ? selectedChainId : account.chainId) ?? undefined, multichainUXEnabled, isSwapAndLimitContext: true, + isUserSelectedChainId, + setIsUserSelectedChainId, } - }, [initialChainId, account.chainId, selectedChainId, currencyState, currentTab, prefilledState, multichainUXEnabled]) + }, [ + initialChainId, + account.chainId, + selectedChainId, + currencyState, + currentTab, + prefilledState, + multichainUXEnabled, + isUserSelectedChainId, + ]) return <SwapAndLimitContext.Provider value={value}>{children}</SwapAndLimitContext.Provider> } diff --git a/apps/web/src/state/swap/hooks.tsx b/apps/web/src/state/swap/hooks.tsx index 5445ffcab97..66c30de9021 100644 --- a/apps/web/src/state/swap/hooks.tsx +++ b/apps/web/src/state/swap/hooks.tsx @@ -11,47 +11,23 @@ import useParsedQueryString from 'hooks/useParsedQueryString' import { useSwapTaxes } from 'hooks/useSwapTaxes' import { useTokenBalances } from 'hooks/useTokenBalances' import { useUSDPrice } from 'hooks/useUSDPrice' -import { Trans } from 'i18n' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { ParsedQs } from 'qs' -import { ReactNode, useCallback, useContext, useMemo } from 'react' +import { ReactNode, useCallback, useMemo } from 'react' import { useCurrencyBalance, useCurrencyBalances } from 'state/connection/hooks' import { InterfaceTrade, RouterPreference, TradeState } from 'state/routing/types' import { isClassicTrade, isSubmittableTrade, isUniswapXTrade } from 'state/routing/utils' -import { - CurrencyState, - SerializedCurrencyState, - SwapAndLimitContext, - SwapContext, - SwapInfo, - SwapState, -} from 'state/swap/types' +import { CurrencyState, SerializedCurrencyState, SwapInfo, SwapState } from 'state/swap/types' +import { useSwapAndLimitContext, useSwapContext } from 'state/swap/useSwapContext' import { useUserSlippageToleranceWithDefault } from 'state/user/hooks' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { Trans } from 'uniswap/src/i18n' import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' import { isAddress } from 'utilities/src/addresses' import { getParsedChainId } from 'utils/chains' -export function useSwapContext() { - return useContext(SwapContext) -} - -export function useSwapAndLimitContext() { - const account = useAccount() - const context = useContext(SwapAndLimitContext) - - // Certain components are used both inside the swap and limit context, and outside of it. - // One example is the CurrencySearch component, which is used in the swap context, but also in - // the add/remove liquidity flows, nft flows, etc. In these cases, we want to use the chainId - // from the provider account (hooks/useAccount), instead of the swap context chainId. - return { - ...context, - chainId: context.isSwapAndLimitContext ? context.chainId : account.chainId, - } -} - export function useSwapActionHandlers(): { onCurrencySelection: (field: Field, currency: Currency) => void onSwitchTokens: (options: { newOutputHasTax: boolean; previouslyEstimatedOutput: string }) => void @@ -344,6 +320,7 @@ export function useInitialCurrencyState(): { initialInputCurrency?: Currency initialOutputCurrency?: Currency initialChainId: InterfaceChainId + initialCurrencyLoading: boolean } { const multichainUXEnabled = useFeatureFlag(FeatureFlags.MultichainUX) @@ -355,18 +332,29 @@ export function useInitialCurrencyState(): { const account = useAccount() const supportedChainId = useSupportedChainId(parsedCurrencyState.chainId ?? account.chainId) ?? UniverseChainId.Mainnet + const hasCurrencyQueryParams = + parsedCurrencyState.inputCurrencyId || parsedCurrencyState.outputCurrencyId || parsedCurrencyState.chainId - const { balanceList } = useTokenBalances({ cacheOnly: true }) + const { balanceList, loading: balanceListLoading } = useTokenBalances({ cacheOnly: true }) const { initialInputCurrencyAddress, initialChainId } = useMemo(() => { + // Default to ETH if multichain and balance list is not loaded and no query params + if (multichainUXEnabled && balanceListLoading && !hasCurrencyQueryParams) { + return { + initialInputCurrencyAddress: 'ETH', + initialChainId: UniverseChainId.Mainnet, + } + } // Handle query params or disconnected state if (parsedCurrencyState.inputCurrencyId) { return { initialInputCurrencyAddress: parsedCurrencyState.inputCurrencyId, initialChainId: supportedChainId, } + // If disconnected or balance list is not loaded, return ETH + // or parsedCurrencyState } else if ( - !multichainUXEnabled || + (!multichainUXEnabled && !balanceListLoading) || !account.isConnected || !balanceList || parsedCurrencyState.chainId || @@ -392,8 +380,11 @@ export function useInitialCurrencyState(): { highestBalanceChainId = supportedChainIdFromGQLChain(balance.token.chain) ?? UniverseChainId.Mainnet } }) + return { initialInputCurrencyAddress: highestBalanceNativeTokenAddress, initialChainId: highestBalanceChainId } }, [ + balanceListLoading, + hasCurrencyQueryParams, account.isConnected, balanceList, multichainUXEnabled, @@ -412,6 +403,13 @@ export function useInitialCurrencyState(): { ) const initialInputCurrency = useCurrency(initialInputCurrencyAddress, initialChainId) const initialOutputCurrency = useCurrency(initialOutputCurrencyAddress, initialChainId) + // We only care about loading if multichain UX is enabled + const initialCurrencyLoading = multichainUXEnabled && balanceListLoading && !hasCurrencyQueryParams - return { initialInputCurrency, initialOutputCurrency, initialChainId } + return { + initialInputCurrency, + initialOutputCurrency, + initialChainId, + initialCurrencyLoading, + } } diff --git a/apps/web/src/state/swap/types.ts b/apps/web/src/state/swap/types.ts index 48101fcb616..858aa01ecd1 100644 --- a/apps/web/src/state/swap/types.ts +++ b/apps/web/src/state/swap/types.ts @@ -77,6 +77,8 @@ type SwapAndLimitContextType = { outputCurrency?: Currency } setSelectedChainId: Dispatch<SetStateAction<InterfaceChainId | undefined | null>> + isUserSelectedChainId: boolean + setIsUserSelectedChainId: Dispatch<SetStateAction<boolean>> setCurrencyState: Dispatch<SetStateAction<CurrencyState>> currentTab: SwapTab setCurrentTab: Dispatch<SetStateAction<SwapTab>> @@ -99,6 +101,8 @@ export const SwapAndLimitContext = createContext<SwapAndLimitContextType>({ }, setCurrencyState: () => undefined, setSelectedChainId: () => undefined, + isUserSelectedChainId: false, + setIsUserSelectedChainId: () => undefined, prefilledState: { inputCurrency: undefined, outputCurrency: undefined, diff --git a/apps/web/src/state/swap/useSwapContext.tsx b/apps/web/src/state/swap/useSwapContext.tsx new file mode 100644 index 00000000000..7974de3d98d --- /dev/null +++ b/apps/web/src/state/swap/useSwapContext.tsx @@ -0,0 +1,21 @@ +import { useAccount } from 'hooks/useAccount' +import { useContext } from 'react' +import { SwapAndLimitContext, SwapContext } from 'state/swap/types' + +export function useSwapContext() { + return useContext(SwapContext) +} + +export function useSwapAndLimitContext() { + const account = useAccount() + const context = useContext(SwapAndLimitContext) + + // Certain components are used both inside the swap and limit context, and outside of it. + // One example is the CurrencySearch component, which is used in the swap context, but also in + // the add/remove liquidity flows, nft flows, etc. In these cases, we want to use the chainId + // from the provider account (hooks/useAccount), instead of the swap context chainId. + return { + ...context, + chainId: context.isSwapAndLimitContext ? context.chainId : account.chainId, + } +} diff --git a/apps/web/src/theme/components/ThemeToggle.tsx b/apps/web/src/theme/components/ThemeToggle.tsx index 14f1b076349..915462394a5 100644 --- a/apps/web/src/theme/components/ThemeToggle.tsx +++ b/apps/web/src/theme/components/ThemeToggle.tsx @@ -1,6 +1,5 @@ import Row from 'components/Row' import PillMultiToggle from 'components/Toggle/PillMultiToggle' -import { Trans } from 'i18n' import { atom, useAtom } from 'jotai' import { atomWithStorage, useAtomValue, useUpdateAtom } from 'jotai/utils' import styled, { useTheme } from 'lib/styled-components' @@ -9,6 +8,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { Moon, Sun } from 'react-feather' import { ThemedText } from 'theme/components/text' import { Moon as MoonFilled, Sun as SunFilled } from 'ui/src/components/icons' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { addMediaQueryListener, removeMediaQueryListener } from 'utils/matchMedia' const THEME_UPDATE_DELAY = ms(`0.1s`) @@ -100,62 +100,9 @@ const ThemePillMultiToggleContainer = styled.div` width: fit; ` -const compactOptions = [ - { - value: ThemeMode.AUTO, - display: ( - <CompactOptionPill data-testid="theme-auto"> - <Trans>Auto</Trans> - </CompactOptionPill> - ), - }, - { - value: ThemeMode.LIGHT, - display: ( - <CompactOptionPill data-testid="theme-light"> - <SunFilled size="$icon.20" /> - </CompactOptionPill> - ), - }, - { - value: ThemeMode.DARK, - display: ( - <CompactOptionPill data-testid="theme-dark"> - <MoonFilled size="$icon.20" /> - </CompactOptionPill> - ), - }, -] - -const defaultOptions = [ - { - value: ThemeMode.AUTO, - display: ( - <OptionPill data-testid="theme-auto"> - <Trans>Auto</Trans> - </OptionPill> - ), - }, - { - value: ThemeMode.LIGHT, - display: ( - <OptionPill data-testid="theme-light"> - <Sun size="20" /> - </OptionPill> - ), - }, - { - value: ThemeMode.DARK, - display: ( - <OptionPill data-testid="theme-dark"> - <Moon size="20" /> - </OptionPill> - ), - }, -] - export function ThemeSelector({ disabled, compact = false }: { disabled?: boolean; compact?: boolean }) { const theme = useTheme() + const { t } = useTranslation() const [mode, setMode] = useAtom(themeModeAtom) const switchMode = useCallback( (mode: string | number) => { @@ -165,6 +112,54 @@ export function ThemeSelector({ disabled, compact = false }: { disabled?: boolea [disabled, setMode], ) + const compactOptions = [ + { + value: ThemeMode.AUTO, + display: ( + <CompactOptionPill data-testid="theme-auto">{t('settings.setting.appearance.option.auto')}</CompactOptionPill> + ), + }, + { + value: ThemeMode.LIGHT, + display: ( + <CompactOptionPill data-testid="theme-light"> + <SunFilled size="$icon.20" /> + </CompactOptionPill> + ), + }, + { + value: ThemeMode.DARK, + display: ( + <CompactOptionPill data-testid="theme-dark"> + <MoonFilled size="$icon.20" /> + </CompactOptionPill> + ), + }, + ] + + const defaultOptions = [ + { + value: ThemeMode.AUTO, + display: <OptionPill data-testid="theme-auto">{t('settings.setting.appearance.option.auto')}</OptionPill>, + }, + { + value: ThemeMode.LIGHT, + display: ( + <OptionPill data-testid="theme-light"> + <Sun size="20" /> + </OptionPill> + ), + }, + { + value: ThemeMode.DARK, + display: ( + <OptionPill data-testid="theme-dark"> + <Moon size="20" /> + </OptionPill> + ), + }, + ] + return ( <ThemePillMultiToggleContainer> <PillMultiToggle diff --git a/apps/web/src/theme/components/index.tsx b/apps/web/src/theme/components/index.tsx index d01ffc73089..86ffe56c8ec 100644 --- a/apps/web/src/theme/components/index.tsx +++ b/apps/web/src/theme/components/index.tsx @@ -1,7 +1,6 @@ import { ReactComponent as TooltipTriangle } from 'assets/svg/tooltip_triangle.svg' import { outboundLink } from 'components/analytics' import useCopyClipboard from 'hooks/useCopyClipboard' -import { Trans } from 'i18n' import styled, { css, keyframes } from 'lib/styled-components' import React, { HTMLProps, @@ -16,6 +15,7 @@ import React, { import { AlertTriangle, ArrowLeft, CheckCircle, Copy, Icon, X } from 'react-feather' import { Link } from 'react-router-dom' import { Z_INDEX } from 'theme/zIndex' +import { Trans } from 'uniswap/src/i18n' import { anonymizeLink } from 'utils/anonymizeLink' // TODO: Break this file into a components folder @@ -34,8 +34,10 @@ export const ButtonText = styled.button` transition-timing-function: ease-in-out; transition-property: opacity, color, background-color; - :hover { - opacity: ${({ theme }) => theme.opacity.hover}; + @media (hover: hover) and (pointer: fine) { + :hover { + opacity: ${({ theme }) => theme.opacity.hover}; + } } :focus { diff --git a/apps/web/src/utils/errors.ts b/apps/web/src/utils/errors.ts index 9d67f7e374d..b2d71a566a1 100644 --- a/apps/web/src/utils/errors.ts +++ b/apps/web/src/utils/errors.ts @@ -1,4 +1,4 @@ -import { t } from 'i18n' +import { t } from 'uniswap/src/i18n' import { v4 as uuid } from 'uuid' // You may throw an instance of this class when the user rejects a request in their wallet. diff --git a/apps/web/src/utils/formatNumbers.test.ts b/apps/web/src/utils/formatNumbers.test.ts index 7de9fbf7e52..b79cea996d0 100644 --- a/apps/web/src/utils/formatNumbers.test.ts +++ b/apps/web/src/utils/formatNumbers.test.ts @@ -7,8 +7,6 @@ import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useActiveLocale } from 'hooks/useActiveLocale' import { mocked } from 'test-utils/mocked' import { Currency } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { NumberType, useFormatter } from 'utils/formatNumbers' jest.mock('hooks/useActiveLocale') @@ -18,7 +16,6 @@ jest.mock('graphql/data/ConversionRate') describe('formatNumber', () => { beforeEach(() => { mocked(useLocalCurrencyConversionRate).mockReturnValue({ data: 1.0, isLoading: false }) - mocked(useFeatureFlag).mockImplementation((f) => f === FeatureFlags.CurrencyConversion) }) it('formats token reference numbers correctly', () => { @@ -318,7 +315,6 @@ describe('formatNumber', () => { describe('formatUSDPrice', () => { beforeEach(() => { mocked(useLocalCurrencyConversionRate).mockReturnValue({ data: 1.0, isLoading: false }) - mocked(useFeatureFlag).mockImplementation((f) => f === FeatureFlags.CurrencyConversion) }) it('format fiat price correctly', () => { @@ -359,7 +355,6 @@ describe('formatUSDPrice', () => { describe('formatPercent', () => { beforeEach(() => { mocked(useLocalCurrencyConversionRate).mockReturnValue({ data: 1.0, isLoading: false }) - mocked(useFeatureFlag).mockImplementation((f) => f === FeatureFlags.CurrencyConversion) }) it('should correctly format undefined', () => { @@ -393,7 +388,6 @@ describe('formatPercent', () => { describe('formatReviewSwapCurrencyAmount', () => { beforeEach(() => { mocked(useLocalCurrencyConversionRate).mockReturnValue({ data: 1.0, isLoading: false }) - mocked(useFeatureFlag).mockImplementation((f) => f === FeatureFlags.CurrencyConversion) }) it('should use TokenTx formatting under a default length', () => { @@ -430,7 +424,6 @@ describe('formatReviewSwapCurrencyAmount', () => { describe('formatDelta', () => { beforeEach(() => { mocked(useLocalCurrencyConversionRate).mockReturnValue({ data: 1.0, isLoading: false }) - mocked(useFeatureFlag).mockImplementation((f) => f === FeatureFlags.CurrencyConversion) }) it.each([[null], [undefined], [Infinity], [NaN]])('should correctly format %p', (value) => { @@ -462,10 +455,6 @@ describe('formatDelta', () => { }) describe('formatToFiatAmount', () => { - beforeEach(() => { - mocked(useFeatureFlag).mockImplementation((f) => f === FeatureFlags.CurrencyConversion) - }) - it('should return default values when undefined', () => { mocked(useLocalCurrencyConversionRate).mockReturnValue({ data: 1, isLoading: false }) const { convertToFiatAmount } = renderHook(() => useFormatter()).result.current diff --git a/apps/web/src/utils/formatNumbers.ts b/apps/web/src/utils/formatNumbers.ts index 37fadec4e9a..41e64e1f813 100644 --- a/apps/web/src/utils/formatNumbers.ts +++ b/apps/web/src/utils/formatNumbers.ts @@ -13,8 +13,6 @@ import usePrevious from 'hooks/usePrevious' import { useCallback, useMemo } from 'react' import { Bound } from 'state/mint/v3/actions' import { Currency as GqlCurrency } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' type Nullish<T> = T | null | undefined type NumberFormatOptions = Intl.NumberFormatOptions @@ -810,20 +808,12 @@ export function useFormatterLocales(): { formatterLocale: SupportedLocale formatterLocalCurrency: SupportedLocalCurrency } { - const currencyConversionEnabled = useFeatureFlag(FeatureFlags.CurrencyConversion) const activeLocale = useActiveLocale() const activeLocalCurrency = useActiveLocalCurrency() - if (currencyConversionEnabled) { - return { - formatterLocale: activeLocale, - formatterLocalCurrency: activeLocalCurrency, - } - } - return { - formatterLocale: DEFAULT_LOCALE, - formatterLocalCurrency: DEFAULT_LOCAL_CURRENCY, + formatterLocale: activeLocale, + formatterLocalCurrency: activeLocalCurrency, } } diff --git a/apps/web/src/utils/swapErrorToUserReadableMessage.tsx b/apps/web/src/utils/swapErrorToUserReadableMessage.tsx index 83575bc3396..5ef3ef25515 100644 --- a/apps/web/src/utils/swapErrorToUserReadableMessage.tsx +++ b/apps/web/src/utils/swapErrorToUserReadableMessage.tsx @@ -1,4 +1,4 @@ -import { t } from 'i18n' +import { t } from 'uniswap/src/i18n' import { logger } from 'utilities/src/logger/logger' import { UserRejectedRequestError } from 'utils/errors' @@ -43,7 +43,7 @@ export function didUserReject(error: any): boolean { */ export function swapErrorToUserReadableMessage(error: any): string { if (didUserReject(error)) { - return t`Transaction rejected` + return t('swap.error.rejected') } let reason = getReason(error) @@ -53,22 +53,22 @@ export function swapErrorToUserReadableMessage(error: any): string { switch (reason) { case 'UniswapV2Router: EXPIRED': - return t`This transaction could not be sent because the deadline has passed. Please check that your transaction deadline is not too low.` + return t('swap.error.v2.expired') case 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT': case 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT': - return t`This transaction will not succeed either due to price movement or fee on transfer. Try increasing your slippage tolerance.` + return t('swap.error.v2.slippage') case 'TransferHelper: TRANSFER_FROM_FAILED': - return t`The input token cannot be transferred. There may be an issue with the input token.` + return t('swap.error.v2.transferInput') case 'UniswapV2: TRANSFER_FAILED': - return t`The output token cannot be transferred. There may be an issue with the output token.` + return t('swap.error.v2.transferOutput') case 'UniswapV2: K': - return t`The Uniswap invariant x*y=k was not satisfied by the swap. This usually means one of the tokens you are swapping incorporates custom behavior on transfer.` + return t('swap.error.v2.k') case 'Too little received': case 'Too much requested': case 'STF': - return t`This transaction will not succeed due to price movement. Try increasing your slippage tolerance. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3.` + return t('swap.error.v3.slippage') case 'TF': - return t`The output token cannot be transferred. There may be an issue with the output token. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3.` + return t('swap.error.v3.transferOutput') default: if (reason?.indexOf('undefined is not an object') !== -1) { logger.warn( @@ -77,13 +77,8 @@ export function swapErrorToUserReadableMessage(error: any): string { 'Undefined object error', reason, ) - return t`An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If that does not work, there may be an incompatibility with the token you are trading. Note: fee-on-transfer and rebase tokens are incompatible with Uniswap V3.` + return t('swap.error.undefinedObject') } - return t( - `{{reason}} You may need to increase your slippage tolerance. Note: fee-on-transfer and rebase tokens are incompatible with Uniswap V3.`, - { - reason: reason ? reason : 'Unknown error.', - }, - ) + return `${reason ?? t('swap.error.unknown')} ${t('swap.error.default')}` } } diff --git a/config/jest-presets/jest/globals.js b/config/jest-presets/jest/globals.js index 9002282bb2e..e520a13571b 100644 --- a/config/jest-presets/jest/globals.js +++ b/config/jest-presets/jest/globals.js @@ -6,14 +6,22 @@ module.exports = { AMPLITUDE_EXPERIMENTS_DEPLOYMENT_KEY: 'key', APPSFLYER_API_KEY: 'key', APPSFLYER_APP_ID: 123, + DATADOG_CLIENT_TOKEN: 'key', + DATADOG_PROJECT_ID: 123, INFURA_KEY: 'key', - INFURA_PROJECT_ID: 123, ONESIGNAL_APP_ID: 123, + OPENAI_API_KEY: 'key', QUICKNODE_ARBITRUM_RPC_URL: 'https://api.uniswap.org', QUICKNODE_BNB_RPC_URL: 'https://api.uniswap.org', QUICKNODE_MAINNET_RPC_URL: 'https://api.uniswap.org', QUICKNODE_ZORA_RPC_URL: 'https://api.uniswap.org', QUICKNODE_ZKSYNC_RPC_URL: 'https://api.uniswap.org', + QUICKNODE_BLAST_RPC_URL: 'https://api.uniswap.org', + QUICKNODE_AVAX_RPC_URL: 'https://api.uniswap.org', + QUICKNODE_BASE_RPC_URL: 'https://api.uniswap.org', + QUICKNODE_CELO_RPC_URL: 'https://api.uniswap.org', + QUICKNODE_OP_RPC_URL: 'https://api.uniswap.org', + QUICKNODE_POLYGON_RPC_URL: 'https://api.uniswap.org', SENTRY_DSN: 'http://sentry.com', SHAKE_CLIENT_ID: 123, SHAKE_CLIENT_SECRET: 123, diff --git a/dangerfile.ts b/dangerfile.ts index af2e84c798b..70735e984e6 100644 --- a/dangerfile.ts +++ b/dangerfile.ts @@ -105,11 +105,18 @@ async function processAddChanges() { "You've added a new call to `createSelector()`. This is Ok, but please make sure you're using it correctly and you're not creating a new selector on every render. See PR #5172 for details.", ) } - if (/(useAppSelector|appSelect|select)\(\s*makeSelect/.test(concatenatedAddedLines)) { + if (/(useSelector|appSelect|select)\(\s*makeSelect/.test(concatenatedAddedLines)) { fail( `It appears you may be creating a new selector on every render. See PR #5172 for details on how to fix this.`, ) } + + // Check for discouraged usage of usePortfolioValueModifiers + if (concatenatedAddedLines.includes('usePortfolioValueModifiers')) { + warn( + "Use the wrapper hooks `usePortfolioTotalValue`, `useAccountList` or `usePortfolioBalances` instead of `usePortfolioValueModifiers` directly. If you're using usePortfolioValueModifiers inside these hooks you can ignore this warning.", + ) + } }) } diff --git a/apps/web/i18next-parser.config.js b/i18next-parser-web.config.js similarity index 91% rename from apps/web/i18next-parser.config.js rename to i18next-parser-web.config.js index 8b39cf8a4c3..d1af80dcb75 100644 --- a/apps/web/i18next-parser.config.js +++ b/i18next-parser-web.config.js @@ -11,9 +11,7 @@ module.exports = { // Default value to give to empty keys // You may also specify a function accepting the locale, namespace, and key as arguments - defaultValue: (_lang, _namespace, key) => { - return key - }, + defaultValue: '', // Indentation of the catalog files indentation: 2, @@ -54,7 +52,7 @@ module.exports = { // Supports $LOCALE and $NAMESPACE injection // Supports JSON (.json) and YAML (.yml) file formats // Where to write the locale files relative to process.cwd() - output: 'src/i18n/locales/source/en-US.json', + output: 'packages/uniswap/src/i18n/locales/web-source/en-US.json', // Plural separator used in your translation keys // If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys. @@ -62,7 +60,12 @@ module.exports = { // An array of globs that describe where to look for source files // relative to the location of the configuration file - input: ['src/**/*.tsx', 'src/**/*.ts'], + input: [ + 'apps/web/src/**/*.tsx', + 'apps/web/src/**/*.ts', + 'packages/uniswap/src/**/*.ts', + 'packages/uniswap/src/**/*.tsx', + ], // Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters) sort: true, diff --git a/package.json b/package.json index 335c2507ce1..4483d0b8663 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "husky": "^8.0.3", "i18next": "23.10.0", "i18next-parser": "8.6.0", + "moti": "0.29.0", "prettier": "3.3.2", "prettier-plugin-organize-imports": "3.2.4", "syncpack": "^8.5.14", @@ -122,10 +123,14 @@ "i18n:upload": "dotenv -e .env.defaults -c -- yarn run i18n:_upload", "i18n:_download": "crowdin download --token=$CROWDIN_ACCESS_TOKEN", "i18n:download": "dotenv -e .env.defaults -c -- yarn run i18n:_download", + "i18n:web:upload": "./scripts/crowdin-web.sh upload", + "i18n:web:download": "./scripts/crowdin-web.sh download", + "i18n:web:download:if-missing": "ONLY_IF_MISSING=1 ./scripts/crowdin-web.sh download", "lfg": "yarn g:prepare && yarn mobile env:local:download && yarn extension env:local:download && yarn g:build && yarn mobile pod && yarn mobile ios", "mobile": "yarn workspace @uniswap/mobile", "local:check": "./scripts/local-version-check.sh", "postinstall": "husky install && yarn g:prepare", + "tradingapi": "yarn workspace uniswap tradingapi:schema && yarn workspace uniswap tradingapi:generate", "ui": "yarn workspace ui", "upgrade:tamagui": "yarn up '*tamagui*' '@tamagui/*'", "upgrade:tamagui:canary": "yarn up '*tamagui*'@canary '@tamagui/*'@canary", diff --git a/packages/eslint-config/native.js b/packages/eslint-config/native.js index 0bcb7acf5b8..512cecb92c5 100644 --- a/packages/eslint-config/native.js +++ b/packages/eslint-config/native.js @@ -170,12 +170,13 @@ module.exports = { importNames: ['useAccountListQuery'], message: 'Use `useAccountList` instead.', }, - { - name: 'wallet/src/features/dataApi/balances', - importNames: ['usePortfolioValueModifiers'], - message: - 'Use the wrapper hooks `usePortfolioTotalValue`, `useAccountList` or `usePortfolioBalances` instead of `usePortfolioValueModifiers` directly.', - }, + // TODO(WALL-3643): Re-enable this rule once valueModifiers are shared via redux + // { + // name: 'wallet/src/features/dataApi/balances', + // importNames: ['usePortfolioValueModifiers'], + // message: + // 'Use the wrapper hooks `usePortfolioTotalValue`, `useAccountList` or `usePortfolioBalances` instead of `usePortfolioValueModifiers` directly.', + // }, { name: '@gorhom/bottom-sheet', importNames: ['BottomSheetTextInput'], diff --git a/packages/ui/package.json b/packages/ui/package.json index 5da25eafb5d..763ce34d154 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "react": "18.2.0", "react-native": "0.73.6", "react-native-fast-image": "8.6.3", + "react-native-gesture-handler": "2.15.0", "react-native-image-colors": "1.5.2", "react-native-reanimated": "3.8.1", "react-native-safe-area-context": "4.9.0", diff --git a/packages/ui/src/animations/animationPresets.ts b/packages/ui/src/animations/animationPresets.ts index 780f7ec5eb6..c146ab16156 100644 --- a/packages/ui/src/animations/animationPresets.ts +++ b/packages/ui/src/animations/animationPresets.ts @@ -1,10 +1,8 @@ -// TODO(TAM-42): remove import after upgrading tamagui -// eslint-disable-next-line no-restricted-imports -import { type ViewStyle } from '@tamagui/core' +import type { ViewProps } from 'tamagui' // for now only enter/exit though we can change this in the future to support // any type of animation, likely we'd want to split that into multiple files -type EnterExitStyles = Record<string, Pick<ViewStyle, 'enterStyle' | 'exitStyle'>> +type EnterExitStyles = Record<string, Pick<ViewProps, 'enterStyle' | 'exitStyle'>> export const animationsEnter = { fadeInDown: { diff --git a/packages/ui/src/assets/graphics/extension-promo-banner-dark.png b/packages/ui/src/assets/graphics/extension-promo-banner-dark.png deleted file mode 100644 index a2ac148377d..00000000000 Binary files a/packages/ui/src/assets/graphics/extension-promo-banner-dark.png and /dev/null differ diff --git a/packages/ui/src/assets/graphics/extension-promo-banner-light.png b/packages/ui/src/assets/graphics/extension-promo-banner-light.png deleted file mode 100644 index 9b7e3c157d7..00000000000 Binary files a/packages/ui/src/assets/graphics/extension-promo-banner-light.png and /dev/null differ diff --git a/packages/ui/src/assets/graphics/extension-promo-modal-dark-ga.png b/packages/ui/src/assets/graphics/extension-promo-modal-dark-ga.png deleted file mode 100644 index ad76b5c4d31..00000000000 Binary files a/packages/ui/src/assets/graphics/extension-promo-modal-dark-ga.png and /dev/null differ diff --git a/packages/ui/src/assets/graphics/extension-promo-modal-dark.png b/packages/ui/src/assets/graphics/extension-promo-modal-dark.png deleted file mode 100644 index c685e698129..00000000000 Binary files a/packages/ui/src/assets/graphics/extension-promo-modal-dark.png and /dev/null differ diff --git a/packages/ui/src/assets/graphics/extension-promo-modal-light-ga.png b/packages/ui/src/assets/graphics/extension-promo-modal-light-ga.png deleted file mode 100644 index 00937dbff84..00000000000 Binary files a/packages/ui/src/assets/graphics/extension-promo-modal-light-ga.png and /dev/null differ diff --git a/packages/ui/src/assets/graphics/extension-promo-modal-light.png b/packages/ui/src/assets/graphics/extension-promo-modal-light.png deleted file mode 100644 index f79d14d1115..00000000000 Binary files a/packages/ui/src/assets/graphics/extension-promo-modal-light.png and /dev/null differ diff --git a/packages/ui/src/assets/icons/angles-down-up.svg b/packages/ui/src/assets/icons/angles-down-up.svg new file mode 100644 index 00000000000..5532270be53 --- /dev/null +++ b/packages/ui/src/assets/icons/angles-down-up.svg @@ -0,0 +1,3 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.5201 11.6467C11.7154 11.842 11.7154 12.1587 11.5201 12.354C11.4227 12.4514 11.2947 12.5007 11.1667 12.5007C11.0387 12.5007 10.9107 12.452 10.8134 12.354L8.50004 10.0407L6.18672 12.354C5.99139 12.5494 5.6747 12.5494 5.47937 12.354C5.28403 12.1587 5.28403 11.842 5.47937 11.6467L8.14603 8.98C8.34137 8.78466 8.65806 8.78466 8.85339 8.98L11.5201 11.6467ZM8.14668 7.02003C8.24402 7.11737 8.37204 7.16668 8.50004 7.16668C8.62804 7.16668 8.75606 7.11803 8.85339 7.02003L11.5201 4.35337C11.7154 4.15804 11.7154 3.84135 11.5201 3.64601C11.3247 3.45068 11.008 3.45068 10.8127 3.64601L8.49939 5.95933L6.18607 3.64601C5.99074 3.45068 5.67405 3.45068 5.47871 3.64601C5.28338 3.84135 5.28338 4.15804 5.47871 4.35337L8.14668 7.02003Z" fill="#CECECE" stroke="#CECECE" stroke-width="0.5"/> +</svg> diff --git a/packages/ui/src/assets/icons/arrow-up-circle.svg b/packages/ui/src/assets/icons/arrow-up-circle.svg new file mode 100644 index 00000000000..7e8cc021d91 --- /dev/null +++ b/packages/ui/src/assets/icons/arrow-up-circle.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none"> + <path d="M14 2.33337C7.55654 2.33337 2.33337 7.55654 2.33337 14C2.33337 20.4435 7.55654 25.6667 14 25.6667C20.4435 25.6667 25.6667 20.4435 25.6667 14C25.6667 7.55654 20.4435 2.33337 14 2.33337ZM18.1184 13.4517C17.9481 13.6221 17.724 13.7084 17.5 13.7084C17.276 13.7084 17.052 13.6232 16.8817 13.4517L14.875 11.4451V18.6667C14.875 19.1497 14.483 19.5417 14 19.5417C13.517 19.5417 13.125 19.1497 13.125 18.6667V11.4462L11.1184 13.4529C10.7766 13.7947 10.2224 13.7947 9.88053 13.4529C9.5387 13.111 9.5387 12.5568 9.88053 12.215L13.3805 8.71501C13.461 8.63451 13.5577 8.5705 13.6651 8.52616C13.8786 8.4375 14.1201 8.4375 14.3336 8.52616C14.4409 8.5705 14.5379 8.63451 14.6184 8.71501L18.1184 12.215C18.4602 12.5568 18.4602 13.1099 18.1184 13.4517Z" fill="currentColor"/> +</svg> diff --git a/packages/ui/src/assets/icons/circle-spinner.svg b/packages/ui/src/assets/icons/circle-spinner.svg index 155045df435..35514dc9f18 100644 --- a/packages/ui/src/assets/icons/circle-spinner.svg +++ b/packages/ui/src/assets/icons/circle-spinner.svg @@ -1,2 +1,6 @@ -<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 12C21 7.02944 16.9706 3 12 3" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg> - +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3Z" + stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.1" /> + <path d="M21 12C21 7.02944 16.9706 3 12 3" stroke="currentColor" stroke-width="3" stroke-linecap="round" /> +</svg> diff --git a/packages/ui/src/assets/icons/comment-dots.svg b/packages/ui/src/assets/icons/comment-dots.svg new file mode 100644 index 00000000000..6f98d0e328d --- /dev/null +++ b/packages/ui/src/assets/icons/comment-dots.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M12 4C7.03 4 3 6.99998 3 12.001C3 14.121 3.72995 15.892 4.94995 17.212C4.79995 18.172 4.25992 19.272 3.16992 20.152C2.83992 20.432 3.02996 20.9819 3.45996 20.9919C4.87996 21.0419 7.06991 20.852 8.40991 19.422C9.50991 19.802 10.72 20.002 12 20.002C16.97 20.002 21 17.001 21 12.001C21 6.99998 16.97 4 12 4ZM8.02002 13C7.46802 13 7.01489 12.552 7.01489 12C7.01489 11.448 7.45801 11 8.01001 11H8.02002C8.57302 11 9.02002 11.448 9.02002 12C9.02002 12.552 8.57202 13 8.02002 13ZM12.02 13C11.468 13 11.0149 12.552 11.0149 12C11.0149 11.448 11.458 11 12.01 11H12.02C12.573 11 13.02 11.448 13.02 12C13.02 12.552 12.572 13 12.02 13ZM16.02 13C15.468 13 15.0149 12.552 15.0149 12C15.0149 11.448 15.458 11 16.01 11H16.02C16.573 11 17.02 11.448 17.02 12C17.02 12.552 16.572 13 16.02 13Z" fill="#25314C"/> +</svg> diff --git a/packages/ui/src/assets/icons/sort-vertical.svg b/packages/ui/src/assets/icons/sort-vertical.svg new file mode 100644 index 00000000000..2f7f86821fd --- /dev/null +++ b/packages/ui/src/assets/icons/sort-vertical.svg @@ -0,0 +1,3 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.638 8.86209C11.8987 9.12275 11.8987 9.54413 11.638 9.80479L8.97138 12.4715C8.84138 12.6015 8.67069 12.6668 8.50002 12.6668C8.32936 12.6668 8.15867 12.6015 8.02867 12.4715L5.362 9.80479C5.10134 9.54413 5.10134 9.12275 5.362 8.86209C5.62267 8.60142 6.04405 8.60142 6.30471 8.86209L8.50002 11.0574L10.6953 8.86209C10.956 8.60142 11.3774 8.60142 11.638 8.86209ZM6.30471 7.13813L8.50002 4.94281L10.6953 7.13813C10.8253 7.26813 10.996 7.33344 11.1667 7.33344C11.3374 7.33344 11.508 7.26813 11.638 7.13813C11.8987 6.87746 11.8987 6.45609 11.638 6.19542L8.97138 3.52875C8.71071 3.26809 8.28934 3.26809 8.02867 3.52875L5.362 6.19542C5.10134 6.45609 5.10134 6.87746 5.362 7.13813C5.62267 7.39879 6.04405 7.39879 6.30471 7.13813Z" fill="#CECECE"/> +</svg> diff --git a/packages/ui/src/assets/icons/swirly-arrow-down.svg b/packages/ui/src/assets/icons/swirly-arrow-down.svg new file mode 100644 index 00000000000..d354990a183 --- /dev/null +++ b/packages/ui/src/assets/icons/swirly-arrow-down.svg @@ -0,0 +1,3 @@ +<svg width="19" height="29" viewBox="0 0 19 29" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M13.8369 11.8437C17.3669 15.3394 16.226 20.3593 10.7931 25.2957C10.424 24.1975 12.0224 23.0596 10.6233 22.1123C10.3575 22.1341 10.0989 22.1118 10.065 22.1644C9.04818 23.7398 7.99884 25.3066 7.08591 26.9132C6.7769 27.457 7.18686 28.0537 8.0867 28.0564C9.9813 28.0621 11.9133 28.1108 14.0709 27.5067C13.2149 27.0402 12.6176 26.7147 12.1708 26.4713C13.5792 24.9485 15.1423 23.5279 16.2993 21.9535C19.0311 18.2361 19.2086 14.4683 16.0154 10.8066C15.5095 10.2265 15.6072 9.78028 15.8817 9.18797C17.0329 6.70361 16.6017 4.33271 14.4925 2.18988C13.7255 1.41071 12.8291 0.588504 10.783 0.797688C12.876 1.84113 13.9495 3.13069 14.4266 4.67159C14.894 6.18092 14.9339 7.66578 13.9098 9.0551C11.9656 8.8047 10.1475 8.40235 8.32135 8.38269C6.4931 8.36299 4.58674 8.56711 2.84355 8.96458C1.05391 9.37261 0.22909 10.5512 0.401856 11.6987C0.574216 12.844 1.74913 13.7074 3.66883 13.9551C6.68544 14.3444 9.48679 13.8606 12.0766 12.7149C12.6837 12.4464 13.2534 12.1342 13.8369 11.8437ZM12.3632 10.7671C9.91343 12.2712 7.39736 12.7447 4.5803 12.5547C3.66133 12.4926 2.7375 12.3089 2.68332 11.4925C2.63686 10.7921 3.41588 10.479 4.21174 10.3186C6.84447 9.78774 9.42795 9.88976 12.3632 10.7671Z" fill="#CECECE"/> +</svg> diff --git a/packages/ui/src/assets/index.ts b/packages/ui/src/assets/index.ts index 6aba2cc43c0..d2c90774a49 100644 --- a/packages/ui/src/assets/index.ts +++ b/packages/ui/src/assets/index.ts @@ -1,3 +1,4 @@ +export const ALL_NETWORKS_LOGO = require('./logos/png/all-networks-icon.png') export const ETHEREUM_LOGO = require('./logos/png/ethereum-logo.png') export const OPTIMISM_LOGO = require('./logos/png/optimism-logo.png') export const ARBITRUM_LOGO = require('./logos/png/arbitrum-logo.png') @@ -20,20 +21,13 @@ export const AVATARS_LIGHT = require('./misc/avatars-light.png') export const AVATARS_DARK = require('./misc/avatars-dark.png') export const APP_SCREENSHOT_LIGHT = require('./misc/app-screenshot-light.png') export const APP_SCREENSHOT_DARK = require('./misc/app-screenshot-dark.png') +export const CHROME_LOGO = require('./logos/png/chrome-logo.png') export const DOT_GRID = require('./misc/dot-grid.png') export const UNITAGS_BANNER_VERTICAL_LIGHT = require('./graphics/unitags-banner-v-light.png') export const UNITAGS_BANNER_VERTICAL_DARK = require('./graphics/unitags-banner-v-dark.png') export const UNITAGS_INTRO_BANNER_LIGHT = require('./graphics/unitags-intro-banner-light.png') export const UNITAGS_INTRO_BANNER_DARK = require('./graphics/unitags-intro-banner-dark.png') -export const EXTENSION_PROMO_BANNER_LIGHT = require('./graphics/extension-promo-banner-light.png') -export const EXTENSION_PROMO_BANNER_DARK = require('./graphics/extension-promo-banner-dark.png') -export const EXTENSION_PROMO_MODAL_LIGHT = require('./graphics/extension-promo-modal-light.png') -export const EXTENSION_PROMO_MODAL_DARK = require('./graphics/extension-promo-modal-dark.png') - -export const EXTENSION_PROMO_BANNER_LIGHT_GA = require('./graphics/extension-promo-modal-light-ga.png') -export const EXTENSION_PROMO_BANNER_DARK_GA = require('./graphics/extension-promo-modal-dark-ga.png') - export const DAI_LOGO = require('./logos/png/dai-logo.png') export const USDC_LOGO = require('./logos/png/usdc-logo.png') export const ETH_LOGO = require('./logos/png/eth-logo.png') diff --git a/packages/ui/src/assets/logos/png/all-networks-icon.png b/packages/ui/src/assets/logos/png/all-networks-icon.png new file mode 100644 index 00000000000..240ab90de2b Binary files /dev/null and b/packages/ui/src/assets/logos/png/all-networks-icon.png differ diff --git a/packages/ui/src/assets/logos/png/chrome-logo.png b/packages/ui/src/assets/logos/png/chrome-logo.png new file mode 100644 index 00000000000..e1ef3e9105e Binary files /dev/null and b/packages/ui/src/assets/logos/png/chrome-logo.png differ diff --git a/packages/ui/src/components/Unicon/index.native.tsx b/packages/ui/src/components/Unicon/index.native.tsx index f1ecae312c9..5da6b2ae474 100644 --- a/packages/ui/src/components/Unicon/index.native.tsx +++ b/packages/ui/src/components/Unicon/index.native.tsx @@ -2,9 +2,10 @@ import { Canvas, Circle, Group, Path } from '@shopify/react-native-skia' import { memo } from 'react' import { IconPaths, Icons } from 'ui/src/components/Unicon/UniconSVGs' import { UniconProps } from 'ui/src/components/Unicon/types' -import { getUniconColors, getUniconsDeterministicHash, isValidEthAddress } from 'ui/src/components/Unicon/utils' +import { getUniconColors, getUniconsDeterministicHash } from 'ui/src/components/Unicon/utils' import { Flex } from 'ui/src/components/layout' import { useIsDarkMode } from 'ui/src/hooks/useIsDarkMode' +import { isAddress } from 'utilities/src/addresses' // Notes: // Add 1 to effectively increase margin between svg and surrounding box, otherwise get a cropping issue @@ -15,7 +16,7 @@ export const Unicon = memo(_Unicon) export function _Unicon({ address, size = 32 }: UniconProps): JSX.Element | null { const isDarkMode = useIsDarkMode() - if (!address || !isValidEthAddress(address)) { + if (!address || !isAddress(address)) { return null } diff --git a/packages/ui/src/components/Unicon/index.web.tsx b/packages/ui/src/components/Unicon/index.web.tsx index 64b962e8537..b05311f9378 100644 --- a/packages/ui/src/components/Unicon/index.web.tsx +++ b/packages/ui/src/components/Unicon/index.web.tsx @@ -1,15 +1,16 @@ import React from 'react' import { IconPaths, Icons } from 'ui/src/components/Unicon/UniconSVGs' import { UniconProps } from 'ui/src/components/Unicon/types' -import { getUniconColors, getUniconsDeterministicHash, isValidEthAddress } from 'ui/src/components/Unicon/utils' +import { getUniconColors, getUniconsDeterministicHash } from 'ui/src/components/Unicon/utils' import { useIsDarkMode } from 'ui/src/hooks/useIsDarkMode' +import { isAddress } from 'utilities/src/addresses' const styles = { transformOrigin: 'center center' } export const Unicon: React.FC<UniconProps> = ({ address, size = 32 }) => { const isDarkMode = useIsDarkMode() - if (!address || !isValidEthAddress(address)) { + if (!address || !isAddress(address)) { return null } diff --git a/packages/ui/src/components/Unicon/utils.ts b/packages/ui/src/components/Unicon/utils.ts index 5945b2c7a54..ff2fda9882e 100644 --- a/packages/ui/src/components/Unicon/utils.ts +++ b/packages/ui/src/components/Unicon/utils.ts @@ -1,8 +1,9 @@ -import { isAddress, keccak256, toUtf8Bytes } from 'ethers/lib/utils' +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils' import { UNICON_COLORS } from 'ui/src/components/Unicon/Colors' +import { isAddress } from 'utilities/src/addresses' export const getUniconsDeterministicHash = (address: string): bigint => { - if (!isValidEthAddress(address)) { + if (!isAddress(address)) { throw new Error('Invalid Ethereum address') } const hash = keccak256(toUtf8Bytes(address)) @@ -10,12 +11,6 @@ export const getUniconsDeterministicHash = (address: string): bigint => { return hashNumber } -const ETH_ADDRESS_LENGTH = 42 // Ethereum addresses are 42 characters long including '0x' -// TODO: move to a shared location in utilities or wallet package -export const isValidEthAddress = (address: string): boolean => { - return Boolean(address.startsWith('0x') && isAddress(address.toLowerCase()) && address.length === ETH_ADDRESS_LENGTH) -} - export const getUniconColors = ( activeAddress: string, isDark: boolean, diff --git a/packages/ui/src/components/UniversalImage/UniversalImage.tsx b/packages/ui/src/components/UniversalImage/UniversalImage.tsx index a16ad9d4f13..c937167dd40 100644 --- a/packages/ui/src/components/UniversalImage/UniversalImage.tsx +++ b/packages/ui/src/components/UniversalImage/UniversalImage.tsx @@ -75,7 +75,7 @@ export function UniversalImage({ const errMsg = errored ? 'could not compute sizing information for uri' : 'Could not retrieve and format remote image for uri' - logger.warn('UniversalImage', 'UniversalImage', `${errMsg}: ${uri}`) + logger.warn('UniversalImage', 'UniversalImage', errMsg, { data: uri }) // Return fallback or null return fallback ?? null diff --git a/packages/ui/src/components/icons/AnglesDownUp.tsx b/packages/ui/src/components/icons/AnglesDownUp.tsx new file mode 100644 index 00000000000..b7e0190990d --- /dev/null +++ b/packages/ui/src/components/icons/AnglesDownUp.tsx @@ -0,0 +1,19 @@ +import { Path, Svg } from 'react-native-svg' + +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { createIcon } from '../factories/createIcon' + +export const [AnglesDownUp, AnimatedAnglesDownUp] = createIcon({ + name: 'AnglesDownUp', + getIcon: (props) => ( + <Svg viewBox="0 0 17 16" fill="none" {...props}> + <Path + d="M11.5201 11.6467C11.7154 11.842 11.7154 12.1587 11.5201 12.354C11.4227 12.4514 11.2947 12.5007 11.1667 12.5007C11.0387 12.5007 10.9107 12.452 10.8134 12.354L8.50004 10.0407L6.18672 12.354C5.99139 12.5494 5.6747 12.5494 5.47937 12.354C5.28403 12.1587 5.28403 11.842 5.47937 11.6467L8.14603 8.98C8.34137 8.78466 8.65806 8.78466 8.85339 8.98L11.5201 11.6467ZM8.14668 7.02003C8.24402 7.11737 8.37204 7.16668 8.50004 7.16668C8.62804 7.16668 8.75606 7.11803 8.85339 7.02003L11.5201 4.35337C11.7154 4.15804 11.7154 3.84135 11.5201 3.64601C11.3247 3.45068 11.008 3.45068 10.8127 3.64601L8.49939 5.95933L6.18607 3.64601C5.99074 3.45068 5.67405 3.45068 5.47871 3.64601C5.28338 3.84135 5.28338 4.15804 5.47871 4.35337L8.14668 7.02003Z" + fill={'currentColor' ?? '#CECECE'} + stroke="currentColor" + strokeWidth="0.5" + /> + </Svg> + ), + defaultFill: '#CECECE', +}) diff --git a/packages/ui/src/components/icons/ArrowUpCircle.tsx b/packages/ui/src/components/icons/ArrowUpCircle.tsx new file mode 100644 index 00000000000..ec898592f8a --- /dev/null +++ b/packages/ui/src/components/icons/ArrowUpCircle.tsx @@ -0,0 +1,16 @@ +import { Path, Svg } from 'react-native-svg' + +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { createIcon } from '../factories/createIcon' + +export const [ArrowUpCircle, AnimatedArrowUpCircle] = createIcon({ + name: 'ArrowUpCircle', + getIcon: (props) => ( + <Svg viewBox="0 0 28 28" fill="none" {...props}> + <Path + d="M14 2.33337C7.55654 2.33337 2.33337 7.55654 2.33337 14C2.33337 20.4435 7.55654 25.6667 14 25.6667C20.4435 25.6667 25.6667 20.4435 25.6667 14C25.6667 7.55654 20.4435 2.33337 14 2.33337ZM18.1184 13.4517C17.9481 13.6221 17.724 13.7084 17.5 13.7084C17.276 13.7084 17.052 13.6232 16.8817 13.4517L14.875 11.4451V18.6667C14.875 19.1497 14.483 19.5417 14 19.5417C13.517 19.5417 13.125 19.1497 13.125 18.6667V11.4462L11.1184 13.4529C10.7766 13.7947 10.2224 13.7947 9.88053 13.4529C9.5387 13.111 9.5387 12.5568 9.88053 12.215L13.3805 8.71501C13.461 8.63451 13.5577 8.5705 13.6651 8.52616C13.8786 8.4375 14.1201 8.4375 14.3336 8.52616C14.4409 8.5705 14.5379 8.63451 14.6184 8.71501L18.1184 12.215C18.4602 12.5568 18.4602 13.1099 18.1184 13.4517Z" + fill="currentColor" + /> + </Svg> + ), +}) diff --git a/packages/ui/src/components/icons/CircleSpinner.tsx b/packages/ui/src/components/icons/CircleSpinner.tsx index ab311d7be57..fbc48e1016f 100644 --- a/packages/ui/src/components/icons/CircleSpinner.tsx +++ b/packages/ui/src/components/icons/CircleSpinner.tsx @@ -7,6 +7,13 @@ export const [CircleSpinner, AnimatedCircleSpinner] = createIcon({ name: 'CircleSpinner', getIcon: (props) => ( <Svg viewBox="0 0 24 24" fill="none" {...props}> + <Path + d="M12 3C16.9706 3 21 7.02944 21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3Z" + stroke="currentColor" + opacity="0.1" + strokeWidth="3" + strokeLinecap="round" + /> <Path d="M21 12C21 7.02944 16.9706 3 12 3" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /> </Svg> ), diff --git a/packages/ui/src/components/icons/CommentDots.tsx b/packages/ui/src/components/icons/CommentDots.tsx new file mode 100644 index 00000000000..483b484f5b7 --- /dev/null +++ b/packages/ui/src/components/icons/CommentDots.tsx @@ -0,0 +1,17 @@ +import { Path, Svg } from 'react-native-svg' + +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { createIcon } from '../factories/createIcon' + +export const [CommentDots, AnimatedCommentDots] = createIcon({ + name: 'CommentDots', + getIcon: (props) => ( + <Svg viewBox="0 0 24 24" fill="none" {...props}> + <Path + d="M12 4C7.03 4 3 6.99998 3 12.001C3 14.121 3.72995 15.892 4.94995 17.212C4.79995 18.172 4.25992 19.272 3.16992 20.152C2.83992 20.432 3.02996 20.9819 3.45996 20.9919C4.87996 21.0419 7.06991 20.852 8.40991 19.422C9.50991 19.802 10.72 20.002 12 20.002C16.97 20.002 21 17.001 21 12.001C21 6.99998 16.97 4 12 4ZM8.02002 13C7.46802 13 7.01489 12.552 7.01489 12C7.01489 11.448 7.45801 11 8.01001 11H8.02002C8.57302 11 9.02002 11.448 9.02002 12C9.02002 12.552 8.57202 13 8.02002 13ZM12.02 13C11.468 13 11.0149 12.552 11.0149 12C11.0149 11.448 11.458 11 12.01 11H12.02C12.573 11 13.02 11.448 13.02 12C13.02 12.552 12.572 13 12.02 13ZM16.02 13C15.468 13 15.0149 12.552 15.0149 12C15.0149 11.448 15.458 11 16.01 11H16.02C16.573 11 17.02 11.448 17.02 12C17.02 12.552 16.572 13 16.02 13Z" + fill={'currentColor' ?? '#25314C'} + /> + </Svg> + ), + defaultFill: '#25314C', +}) diff --git a/packages/ui/src/components/icons/SortVertical.tsx b/packages/ui/src/components/icons/SortVertical.tsx new file mode 100644 index 00000000000..1e6dc353236 --- /dev/null +++ b/packages/ui/src/components/icons/SortVertical.tsx @@ -0,0 +1,17 @@ +import { Path, Svg } from 'react-native-svg' + +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { createIcon } from '../factories/createIcon' + +export const [SortVertical, AnimatedSortVertical] = createIcon({ + name: 'SortVertical', + getIcon: (props) => ( + <Svg viewBox="0 0 17 16" fill="none" {...props}> + <Path + d="M11.638 8.86209C11.8987 9.12275 11.8987 9.54413 11.638 9.80479L8.97138 12.4715C8.84138 12.6015 8.67069 12.6668 8.50002 12.6668C8.32936 12.6668 8.15867 12.6015 8.02867 12.4715L5.362 9.80479C5.10134 9.54413 5.10134 9.12275 5.362 8.86209C5.62267 8.60142 6.04405 8.60142 6.30471 8.86209L8.50002 11.0574L10.6953 8.86209C10.956 8.60142 11.3774 8.60142 11.638 8.86209ZM6.30471 7.13813L8.50002 4.94281L10.6953 7.13813C10.8253 7.26813 10.996 7.33344 11.1667 7.33344C11.3374 7.33344 11.508 7.26813 11.638 7.13813C11.8987 6.87746 11.8987 6.45609 11.638 6.19542L8.97138 3.52875C8.71071 3.26809 8.28934 3.26809 8.02867 3.52875L5.362 6.19542C5.10134 6.45609 5.10134 6.87746 5.362 7.13813C5.62267 7.39879 6.04405 7.39879 6.30471 7.13813Z" + fill={'currentColor' ?? '#CECECE'} + /> + </Svg> + ), + defaultFill: '#CECECE', +}) diff --git a/packages/ui/src/components/icons/SwirlyArrowDown.tsx b/packages/ui/src/components/icons/SwirlyArrowDown.tsx new file mode 100644 index 00000000000..9b3cdcb8e68 --- /dev/null +++ b/packages/ui/src/components/icons/SwirlyArrowDown.tsx @@ -0,0 +1,17 @@ +import { Path, Svg } from 'react-native-svg' + +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import { createIcon } from '../factories/createIcon' + +export const [SwirlyArrowDown, AnimatedSwirlyArrowDown] = createIcon({ + name: 'SwirlyArrowDown', + getIcon: (props) => ( + <Svg viewBox="0 0 19 29" fill="none" {...props}> + <Path + d="M13.8369 11.8437C17.3669 15.3394 16.226 20.3593 10.7931 25.2957C10.424 24.1975 12.0224 23.0596 10.6233 22.1123C10.3575 22.1341 10.0989 22.1118 10.065 22.1644C9.04818 23.7398 7.99884 25.3066 7.08591 26.9132C6.7769 27.457 7.18686 28.0537 8.0867 28.0564C9.9813 28.0621 11.9133 28.1108 14.0709 27.5067C13.2149 27.0402 12.6176 26.7147 12.1708 26.4713C13.5792 24.9485 15.1423 23.5279 16.2993 21.9535C19.0311 18.2361 19.2086 14.4683 16.0154 10.8066C15.5095 10.2265 15.6072 9.78028 15.8817 9.18797C17.0329 6.70361 16.6017 4.33271 14.4925 2.18988C13.7255 1.41071 12.8291 0.588504 10.783 0.797688C12.876 1.84113 13.9495 3.13069 14.4266 4.67159C14.894 6.18092 14.9339 7.66578 13.9098 9.0551C11.9656 8.8047 10.1475 8.40235 8.32135 8.38269C6.4931 8.36299 4.58674 8.56711 2.84355 8.96458C1.05391 9.37261 0.22909 10.5512 0.401856 11.6987C0.574216 12.844 1.74913 13.7074 3.66883 13.9551C6.68544 14.3444 9.48679 13.8606 12.0766 12.7149C12.6837 12.4464 13.2534 12.1342 13.8369 11.8437ZM12.3632 10.7671C9.91343 12.2712 7.39736 12.7447 4.5803 12.5547C3.66133 12.4926 2.7375 12.3089 2.68332 11.4925C2.63686 10.7921 3.41588 10.479 4.21174 10.3186C6.84447 9.78774 9.42795 9.88976 12.3632 10.7671Z" + fill={'currentColor' ?? '#CECECE'} + /> + </Svg> + ), + defaultFill: '#CECECE', +}) diff --git a/packages/ui/src/components/icons/exported.ts b/packages/ui/src/components/icons/exported.ts index fd8d3e35f2f..d2e8f12115f 100644 --- a/packages/ui/src/components/icons/exported.ts +++ b/packages/ui/src/components/icons/exported.ts @@ -3,6 +3,7 @@ export * from './AddButton' export * from './AlertCircle' export * from './AlertTriangle' export * from './AngleRightSmall' +export * from './AnglesDownUp' export * from './AnglesMaximize' export * from './AnglesMinimize' export * from './Approve' @@ -15,6 +16,7 @@ export * from './ArrowDownInCircle' export * from './ArrowRightToLine' export * from './ArrowRightwardsDown' export * from './ArrowTurnDownRight' +export * from './ArrowUpCircle' export * from './ArrowUpDown' export * from './ArrowUpInCircle' export * from './Bell' @@ -42,6 +44,7 @@ export * from './Coffee' export * from './Coin' export * from './CoinConvert' export * from './Coins' +export * from './CommentDots' export * from './ContractInteraction' export * from './Contrast' export * from './CopyAlt' @@ -161,6 +164,7 @@ export * from './ShieldCheck' export * from './ShieldQuestion' export * from './SlashCircle' export * from './Sort' +export * from './SortVertical' export * from './Sparkle' export * from './Stacked' export * from './Star' @@ -170,6 +174,7 @@ export * from './StickyNoteTextSquare' export * from './Sun' export * from './SwapActionButton' export * from './SwapArrow' +export * from './SwirlyArrowDown' export * from './Testnets' export * from './TextEdit' export * from './ThumbsUp' diff --git a/packages/ui/src/components/layout/Flex.tsx b/packages/ui/src/components/layout/Flex.tsx index 9341c7a589c..6132e669f95 100644 --- a/packages/ui/src/components/layout/Flex.tsx +++ b/packages/ui/src/components/layout/Flex.tsx @@ -1,5 +1,5 @@ import type { Insets } from 'react-native' -import { SizeTokens, View, ViewProps, styled } from 'tamagui' +import { GetProps, SizeTokens, View, styled } from 'tamagui' import { animationsEnter, animationsEnterExit, animationsExit } from 'ui/src/animations/animationPresets' export const flexStyles = { @@ -8,14 +8,6 @@ export const flexStyles = { shrink: { flexShrink: 1 }, } -export type FlexProps = ViewProps & { - row?: boolean - shrink?: boolean - grow?: boolean - fill?: boolean - centered?: boolean -} - type SizeOrNumber = number | SizeTokens type SizedInset = { @@ -74,3 +66,5 @@ export const Flex = styled(View, { animateEnterExit: animationsEnterExit, } as const, }) + +export type FlexProps = GetProps<typeof Flex> diff --git a/packages/ui/src/components/swipeablecards/SwipeableCard.native.tsx b/packages/ui/src/components/swipeablecards/SwipeableCard.native.tsx new file mode 100644 index 00000000000..00768838d98 --- /dev/null +++ b/packages/ui/src/components/swipeablecards/SwipeableCard.native.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import { Dimensions } from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { SwipeableCardProps } from 'ui/src/components/swipeablecards/props' + +const screenWidth = Dimensions.get('window').width +const panXOffsetThreshold = screenWidth / 4 + +export function SwipeableCard({ + children, + stackIndex, + cardHeight, + disableSwipe, + onSwiped, + onLayout, +}: SwipeableCardProps): JSX.Element { + const yOffset = useSharedValue(0) + const panOffset = useSharedValue(0) + + const [height, setHeight] = useState(0) + const [targetYOffset, setTargetYOffset] = useState(0) + + useEffect(() => { + onLayout({ height, yOffset: targetYOffset }) + }, [height, onLayout, targetYOffset]) + + useEffect(() => { + const nextYOffset = stackIndex * 10 + + setTargetYOffset(nextYOffset) + yOffset.value = withSpring(nextYOffset) + panOffset.value = 0 + }, [panOffset, stackIndex, yOffset]) + + const pan = Gesture.Pan() + .enabled(!disableSwipe) + .onChange((event) => { + panOffset.value = event.translationX + }) + .onFinalize((event) => { + const { translationX } = event + const shouldDismissCard = Math.abs(translationX) > panXOffsetThreshold + + if (shouldDismissCard) { + panOffset.value = withSpring( + (translationX < 0 ? -1 : 1) * screenWidth, + { restDisplacementThreshold: screenWidth / 10, restSpeedThreshold: 50 }, + () => runOnJS(onSwiped)(), + ) + } else { + panOffset.value = withSpring(0) + } + }) + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ translateX: panOffset.value }, { translateY: yOffset.value }], + } + }) + return ( + <GestureDetector gesture={pan}> + <AnimatedFlex + minHeight={cardHeight ? cardHeight : undefined} + style={animatedStyle} + onLayout={(event) => setHeight(event.nativeEvent.layout.height)} + > + {children} + </AnimatedFlex> + </GestureDetector> + ) +} diff --git a/packages/ui/src/components/swipeablecards/SwipeableCard.tsx b/packages/ui/src/components/swipeablecards/SwipeableCard.tsx new file mode 100644 index 00000000000..73cc07b199e --- /dev/null +++ b/packages/ui/src/components/swipeablecards/SwipeableCard.tsx @@ -0,0 +1,6 @@ +import { SwipeableCardProps } from 'ui/src/components/swipeablecards/props' +import { NotImplementedError } from 'utilities/src/errors' + +export function SwipeableCard(_props: SwipeableCardProps): JSX.Element { + throw new NotImplementedError('SwipeableCard') +} diff --git a/packages/ui/src/components/swipeablecards/SwipeableCardStack.native.tsx b/packages/ui/src/components/swipeablecards/SwipeableCardStack.native.tsx new file mode 100644 index 00000000000..985ffa02923 --- /dev/null +++ b/packages/ui/src/components/swipeablecards/SwipeableCardStack.native.tsx @@ -0,0 +1,50 @@ +import { useCallback, useState } from 'react' +import { GestureHandlerRootView } from 'react-native-gesture-handler' +import { Flex } from 'ui/src/components/layout' +import { SwipeableCard } from 'ui/src/components/swipeablecards/SwipeableCard' +import { SwipeableCardStackProps } from 'ui/src/components/swipeablecards/props' + +export function SwipeableCardStack<T>({ cards, renderCard, onSwiped }: SwipeableCardStackProps<T>): JSX.Element { + const [activeIndex, setActiveIndex] = useState(0) + const [containerHeight, setContainerHeight] = useState(0) + const [cardHeight, setCardHeight] = useState(0) + + const handleSwiped = useCallback(() => { + setActiveIndex((prev) => { + const next = prev === cards.length - 1 ? 0 : prev + 1 + onSwiped?.(next) + return next + }) + }, [cards.length, onSwiped]) + + const handleLayout = useCallback( + ({ height, yOffset }: { height: number; yOffset: number }) => { + setContainerHeight(Math.max(containerHeight, height + yOffset)) + setCardHeight(Math.max(cardHeight, height)) + }, + [cardHeight, containerHeight], + ) + + return ( + <GestureHandlerRootView> + <Flex position="relative" style={{ height: containerHeight }}> + {cards.map((card, index) => { + const stackIndex = (index - activeIndex + cards.length) % cards.length + return ( + <Flex key={index} position="absolute" width="100%" zIndex={cards.length - stackIndex}> + <SwipeableCard + cardHeight={cardHeight} + disableSwipe={cards.length <= 1} + stackIndex={stackIndex} + onLayout={handleLayout} + onSwiped={handleSwiped} + > + {renderCard(card, stackIndex)} + </SwipeableCard> + </Flex> + ) + })} + </Flex> + </GestureHandlerRootView> + ) +} diff --git a/packages/ui/src/components/swipeablecards/SwipeableCardStack.tsx b/packages/ui/src/components/swipeablecards/SwipeableCardStack.tsx new file mode 100644 index 00000000000..897f9586c13 --- /dev/null +++ b/packages/ui/src/components/swipeablecards/SwipeableCardStack.tsx @@ -0,0 +1,6 @@ +import { SwipeableCardStackProps } from 'ui/src/components/swipeablecards/props' +import { NotImplementedError } from 'utilities/src/errors' + +export function SwipeableCardStack<T>(_props: SwipeableCardStackProps<T>): JSX.Element { + throw new NotImplementedError('SwipeableCardStack') +} diff --git a/packages/ui/src/components/swipeablecards/props.ts b/packages/ui/src/components/swipeablecards/props.ts new file mode 100644 index 00000000000..e1ade635476 --- /dev/null +++ b/packages/ui/src/components/swipeablecards/props.ts @@ -0,0 +1,15 @@ +import { PropsWithChildren } from 'react' + +export type SwipeableCardProps = PropsWithChildren<{ + stackIndex: number + cardHeight: number + disableSwipe: boolean + onSwiped: () => void + onLayout: ({ height, yOffset }: { height: number; yOffset: number }) => void +}> + +export type SwipeableCardStackProps<T> = { + cards: T[] + renderCard: (card: T, index: number) => JSX.Element + onSwiped?: (index: number) => void +} diff --git a/packages/ui/src/components/text/Text.tsx b/packages/ui/src/components/text/Text.tsx index 0446b4d9007..ec13f6a15b8 100644 --- a/packages/ui/src/components/text/Text.tsx +++ b/packages/ui/src/components/text/Text.tsx @@ -11,106 +11,103 @@ export const TextFrame = styled(TamaguiText, { wordWrap: 'break-word', variants: { - // TODO: leverage font tokens instead - // https://tamagui.dev/docs/core/configuration#font-tokens - // https://tamagui.dev/docs/core/font-language variant: { heading1: { fontFamily: '$heading', - fontSize: fonts.heading1.fontSize, - lineHeight: fonts.heading1.lineHeight, - fontWeight: fonts.heading1.fontWeight, + fontSize: '$large', + lineHeight: '$large', + fontWeight: '$book', maxFontSizeMultiplier: fonts.heading1.maxFontSizeMultiplier, }, heading2: { fontFamily: '$heading', - fontSize: fonts.heading2.fontSize, - lineHeight: fonts.heading2.lineHeight, - fontWeight: fonts.heading2.fontWeight, + fontSize: '$medium', + lineHeight: '$medium', + fontWeight: '$book', maxFontSizeMultiplier: fonts.heading2.maxFontSizeMultiplier, }, heading3: { fontFamily: '$heading', - fontSize: fonts.heading3.fontSize, - lineHeight: fonts.heading3.lineHeight, - fontWeight: fonts.heading3.fontWeight, + fontSize: '$small', + lineHeight: '$small', + fontWeight: '$book', maxFontSizeMultiplier: fonts.heading3.maxFontSizeMultiplier, }, subheading1: { fontFamily: '$subHeading', - fontSize: fonts.subheading1.fontSize, - lineHeight: fonts.subheading1.lineHeight, - fontWeight: fonts.subheading1.fontWeight, + fontSize: '$large', + lineHeight: '$large', + fontWeight: '$book', maxFontSizeMultiplier: fonts.subheading1.maxFontSizeMultiplier, }, subheading2: { fontFamily: '$subHeading', - fontSize: fonts.subheading2.fontSize, - lineHeight: fonts.subheading2.lineHeight, - fontWeight: fonts.subheading2.fontWeight, + fontSize: '$small', + lineHeight: '$small', + fontWeight: '$book', maxFontSizeMultiplier: fonts.subheading2.maxFontSizeMultiplier, }, body1: { fontFamily: '$body', - fontSize: fonts.body1.fontSize, - lineHeight: fonts.body1.lineHeight, - fontWeight: fonts.body1.fontWeight, + fontSize: '$large', + lineHeight: '$large', + fontWeight: '$book', maxFontSizeMultiplier: fonts.body1.maxFontSizeMultiplier, }, body2: { fontFamily: '$body', - fontSize: fonts.body2.fontSize, - lineHeight: fonts.body2.lineHeight, - fontWeight: fonts.body2.fontWeight, + fontSize: '$medium', + lineHeight: '$medium', + fontWeight: '$book', maxFontSizeMultiplier: fonts.body2.maxFontSizeMultiplier, }, body3: { fontFamily: '$body', - fontSize: fonts.body3.fontSize, - lineHeight: fonts.body3.lineHeight, - fontWeight: fonts.body3.fontWeight, + fontSize: '$small', + lineHeight: '$small', + fontWeight: '$book', maxFontSizeMultiplier: fonts.body3.maxFontSizeMultiplier, }, body4: { fontFamily: '$body', - fontSize: fonts.body4.fontSize, - lineHeight: fonts.body4.lineHeight, - fontWeight: fonts.body4.fontWeight, + fontSize: '$micro', + lineHeight: '$micro', + fontWeight: '$book', maxFontSizeMultiplier: fonts.body4.maxFontSizeMultiplier, }, buttonLabel1: { fontFamily: '$button', - fontSize: fonts.buttonLabel1.fontSize, - lineHeight: fonts.buttonLabel1.lineHeight, - fontWeight: fonts.buttonLabel1.fontWeight, + fontSize: '$large', + lineHeight: '$large', + fontWeight: '$medium', maxFontSizeMultiplier: fonts.buttonLabel1.maxFontSizeMultiplier, }, buttonLabel2: { fontFamily: '$button', - fontSize: fonts.buttonLabel2.fontSize, - lineHeight: fonts.buttonLabel2.lineHeight, - fontWeight: fonts.buttonLabel2.fontWeight, + fontSize: '$medium', + lineHeight: '$medium', + fontWeight: '$medium', maxFontSizeMultiplier: fonts.buttonLabel2.maxFontSizeMultiplier, }, buttonLabel3: { fontFamily: '$button', - fontSize: fonts.buttonLabel3.fontSize, - lineHeight: fonts.buttonLabel3.lineHeight, - fontWeight: fonts.buttonLabel3.fontWeight, + fontSize: '$small', + lineHeight: '$small', + fontWeight: '$medium', maxFontSizeMultiplier: fonts.buttonLabel3.maxFontSizeMultiplier, }, buttonLabel4: { fontFamily: '$button', - fontSize: fonts.buttonLabel4.fontSize, - lineHeight: fonts.buttonLabel4.lineHeight, - fontWeight: fonts.buttonLabel4.fontWeight, + fontSize: '$micro', + lineHeight: '$micro', + fontWeight: '$medium', maxFontSizeMultiplier: fonts.buttonLabel4.maxFontSizeMultiplier, }, monospace: { fontFamily: '$body', fontSize: fonts.body2.fontSize, lineHeight: fonts.body2.lineHeight, - fontWeight: fonts.body2.fontWeight, + fontWeight: '$book', maxFontSizeMultiplier: fonts.body2.maxFontSizeMultiplier, }, }, diff --git a/packages/ui/src/components/toast/ToastSimple.tsx b/packages/ui/src/components/toast/ToastSimple.tsx new file mode 100644 index 00000000000..00ea988e263 --- /dev/null +++ b/packages/ui/src/components/toast/ToastSimple.tsx @@ -0,0 +1,40 @@ +import { PropsWithChildren } from 'react' +import { X } from 'ui/src/components/icons/X' +import { Flex } from 'ui/src/components/layout' +import { TouchableArea } from 'ui/src/components/touchable' + +const MAX_WIDTH = 348 + +export function ToastSimple({ children, onDismiss }: PropsWithChildren<{ onDismiss?: () => void }>): JSX.Element { + return ( + <Flex + row + alignItems="center" + animation="fastHeavy" + backgroundColor="$surface1" + borderColor="$surface3" + borderRadius={16} + borderWidth={1} + enterStyle={{ + left: MAX_WIDTH + 20, + }} + exitStyle={{ + left: MAX_WIDTH + 20, + }} + justifyContent="space-between" + left={0} + p={16} + position="relative" + width={MAX_WIDTH} + > + <Flex row alignItems="center" gap={12}> + {children} + </Flex> + {onDismiss ? ( + <TouchableArea onPress={onDismiss}> + <X color="$neutral2" size={16} /> + </TouchableArea> + ) : null} + </Flex> + ) +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a6b5122c59e..6788370289b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -22,6 +22,7 @@ export { isWeb, styled, useComposedRefs, + useIsTouchDevice, useMedia, usePropsAndStyle, useWindowDimensions, @@ -58,6 +59,7 @@ export { MenuContent } from './components/menu/MenuContent' export type { MenuContentItem } from './components/menu/types' export { AdaptiveWebModalSheet } from './components/modal/AdaptiveWebModalSheet' export * from './components/text' +export { ToastSimple } from './components/toast/ToastSimple' export { Tooltip } from './components/tooltip/Tooltip' export * from './components/touchable' export { useDeviceInsets } from './hooks/useDeviceInsets' diff --git a/packages/ui/src/loading/SpinningLoader.tsx b/packages/ui/src/loading/SpinningLoader.tsx index ec53e768d2f..c3759534263 100644 --- a/packages/ui/src/loading/SpinningLoader.tsx +++ b/packages/ui/src/loading/SpinningLoader.tsx @@ -5,52 +5,52 @@ import { SpinningLoaderProps } from 'ui/src/loading/types' const rotateCSS = ` @keyframes rotate360 { from { - transform: rotate(0deg); + transform: rotate(45deg); } to { - transform: rotate(360deg); + transform: rotate(405deg); } } .RotateElement { - animation: rotate360 3s linear infinite; + animation: rotate360 1s cubic-bezier(0.83, 0, 0.17, 1) infinite; transform-origin: center center; } ` -export function SpinningLoader({ size = 20, disabled, color }: SpinningLoaderProps): JSX.Element { +export function SpinningLoader({ size = 20, width = 3, disabled, color }: SpinningLoaderProps): JSX.Element { if (disabled) { return <EmptySpinner color="$neutral3" size={size} /> } return ( <> <style>{rotateCSS}</style> - <Flex - alignItems="center" - className="RotateElement" - height={16} - justifyContent="center" - marginEnd={2} - marginStart={2} - width={16} - > - <Flex borderRadius="$roundedFull" height={8} minHeight={8} minWidth={8} position="relative" width={8}> + <Flex alignItems="center" height={size} justifyContent="center" marginEnd={2} marginStart={2} width={size}> + <Flex height={size} minHeight={8} minWidth={8} p={1.66667} position="relative" width={size}> + <Flex + backgroundColor="transparent" + borderColor={color ?? '$neutral1'} + borderRadius="$roundedFull" + borderWidth={width} + height={size} + opacity={0.1} + position="absolute" + width={size} + /> <Flex backgroundColor="transparent" borderBottomColor="transparent" - borderBottomWidth={1} - borderLeftColor={color ?? '$neutral1'} - borderLeftWidth={2} + borderBottomWidth={width} + borderLeftColor="transparent" + borderLeftWidth={width} borderRadius="$roundedFull" borderRightColor="transparent" - borderRightWidth={1} - borderTopColor="transparent" - borderTopWidth={1} + borderRightWidth={width} + borderTopColor={color ?? '$neutral1'} + borderTopWidth={width} className="RotateElement" height={size} - left={-6} - position="relative" - top={-6} + position="absolute" width={size} /> </Flex> diff --git a/packages/ui/src/loading/types.ts b/packages/ui/src/loading/types.ts index 25050cc817b..13caf77aa9a 100644 --- a/packages/ui/src/loading/types.ts +++ b/packages/ui/src/loading/types.ts @@ -2,6 +2,7 @@ import { ColorTokens } from 'tamagui' export type SpinningLoaderProps = { size?: number + width?: number disabled?: boolean color?: ColorTokens } diff --git a/packages/ui/src/theme/color/colors.ts b/packages/ui/src/theme/color/colors.ts index ced23f37d86..307f6d9c0a0 100644 --- a/packages/ui/src/theme/color/colors.ts +++ b/packages/ui/src/theme/color/colors.ts @@ -227,9 +227,9 @@ const sporeLight = { neutral3: '#CECECE', accent1: '#FC72FF', - accent1Hovered: '#C70A92', - accent2: '#FFEFFF', - accent2Hovered: '#FEEBFC', + accent1Hovered: '#FA5BEC', + accent2: '#FEF4FF', + accent2Hovered: '#FFE6FA', accent3: '#4C82FB', accent3Hovered: '#F5F5F5', @@ -239,6 +239,9 @@ const sporeLight = { statusSuccess: '#40B66B', statusSuccess2: '#EEFBF1', statusCritical: '#FF5F52', + statusCriticalHovered: '#FF3931', + statusCritical2: '#FFF2F1', + statusCritical2Hovered: '#FFD5D4', } const sporeDark = { @@ -272,6 +275,9 @@ const sporeDark = { statusSuccess: '#40B66B', statusSuccess2: '#0F2C1A', statusCritical: '#FF5F52', + statusCriticalHovered: '#FF3931', + statusCritical2: '#220D0C', + statusCritical2Hovered: '#470402', } export const colorsLight = { @@ -306,6 +312,9 @@ export const colorsLight = { statusSuccess: sporeLight.statusSuccess, statusSuccess2: sporeLight.statusSuccess2, statusCritical: sporeLight.statusCritical, + statusCriticalHovered: sporeLight.statusCriticalHovered, + statusCritical2: sporeLight.statusCritical2, + statusCritical2Hovered: sporeLight.statusCritical2Hovered, DEP_backgroundBranded: '#FCF7FF', DEP_backgroundActionButton: colors.magenta50, @@ -379,6 +388,9 @@ export const colorsDark = { statusSuccess: sporeDark.statusSuccess, statusSuccess2: sporeDark.statusSuccess2, statusCritical: sporeDark.statusCritical, + statusCriticalHovered: sporeDark.statusCriticalHovered, + statusCritical2: sporeDark.statusCritical2, + statusCritical2Hovered: sporeDark.statusCritical2Hovered, DEP_backgroundBranded: '#100D1C', DEP_backgroundActionButton: opacify(12, colors.magentaVibrant), diff --git a/packages/ui/src/theme/fonts.ts b/packages/ui/src/theme/fonts.ts index 8c6844ca5b0..e9c33fb7cc9 100644 --- a/packages/ui/src/theme/fonts.ts +++ b/packages/ui/src/theme/fonts.ts @@ -2,6 +2,7 @@ // eslint-disable-next-line no-restricted-imports import { createFont, isWeb } from '@tamagui/core' import { needsSmallFont } from 'ui/src/utils/needs-small-font' +import { isInterface } from 'utilities/src/platform' // TODO(EXT-148): remove this type and use Tamagui's FontTokens export type TextVariantTokens = keyof typeof fonts @@ -34,8 +35,23 @@ const platformFontFamily = (family: SansSerifFontFamilyKey): SansSerifFontFamily return fontFamily.sansSerif[family] } +// NOTE: these may not match the actual font weights in the figma files, +// but they are approved by design. If you need to change them, please +// consult with the design team. + +// default for non-button fonts const BOOK_WEIGHT = '400' +const BOOK_WEIGHT_WEB = '485' + +// used for buttons const MEDIUM_WEIGHT = '500' +const MEDIUM_WEIGHT_WEB = '535' + +const defaultWeights = { + book: isInterface ? BOOK_WEIGHT_WEB : BOOK_WEIGHT, + true: isInterface ? BOOK_WEIGHT_WEB : BOOK_WEIGHT, + medium: isInterface ? MEDIUM_WEIGHT_WEB : MEDIUM_WEIGHT, +} export const fonts = { heading1: { @@ -103,36 +119,36 @@ export const fonts = { }, buttonLabel1: { family: platformFontFamily('medium'), - fontSize: adjustedSize(20), + fontSize: adjustedSize(18), lineHeight: 24, fontWeight: MEDIUM_WEIGHT, maxFontSizeMultiplier: 1.2, }, buttonLabel2: { family: platformFontFamily('medium'), - fontSize: adjustedSize(18), + fontSize: adjustedSize(16), lineHeight: 24, fontWeight: MEDIUM_WEIGHT, maxFontSizeMultiplier: 1.2, }, buttonLabel3: { family: platformFontFamily('medium'), - fontSize: adjustedSize(16), - lineHeight: 24, + fontSize: adjustedSize(14), + lineHeight: 20, fontWeight: MEDIUM_WEIGHT, maxFontSizeMultiplier: 1.2, }, buttonLabel4: { family: platformFontFamily('medium'), - fontSize: adjustedSize(14), + fontSize: adjustedSize(12), lineHeight: 16, fontWeight: MEDIUM_WEIGHT, maxFontSizeMultiplier: 1.2, }, monospace: { family: platformFontFamily('monospace'), - fontSize: adjustedSize(14), - lineHeight: 20, + fontSize: adjustedSize(12), + lineHeight: 16, maxFontSizeMultiplier: 1.2, }, } as const @@ -154,11 +170,7 @@ export const headingFont = createFont({ true: fonts.heading2.fontSize, large: fonts.heading1.fontSize, }, - weight: { - book: '400', - medium: '500', - true: fonts.heading1.fontWeight, - }, + weight: defaultWeights, lineHeight: { small: fonts.heading3.lineHeight, medium: fonts.heading2.lineHeight, @@ -175,11 +187,7 @@ export const subHeadingFont = createFont({ large: fonts.subheading1.fontSize, true: fonts.subheading1.fontSize, }, - weight: { - book: '400', - medium: '500', - true: fonts.subheading1.fontWeight, - }, + weight: defaultWeights, lineHeight: { small: fonts.subheading2.lineHeight, large: fonts.subheading1.lineHeight, @@ -194,23 +202,19 @@ export const bodyFont = createFont({ family: baselBook, face: {}, size: { - micro: fonts.body3.fontSize, - small: fonts.body2.fontSize, + micro: fonts.body4.fontSize, + small: fonts.body3.fontSize, medium: fonts.body2.fontSize, - large: fonts.body1.fontSize, true: fonts.body2.fontSize, + large: fonts.body1.fontSize, }, - weight: { - book: '400', - medium: '500', - true: fonts.body1.fontWeight, - }, + weight: defaultWeights, lineHeight: { - micro: fonts.body3.lineHeight, - small: fonts.body2.lineHeight, + micro: fonts.body4.lineHeight, + small: fonts.body3.lineHeight, medium: fonts.body2.lineHeight, - large: fonts.body1.lineHeight, true: fonts.body2.lineHeight, + large: fonts.body1.lineHeight, }, }) @@ -224,9 +228,8 @@ export const buttonFont = createFont({ true: fonts.buttonLabel2.fontSize, }, weight: { - book: '400', - medium: '500', - true: fonts.buttonLabel1.fontWeight, + ...defaultWeights, + true: MEDIUM_WEIGHT, }, lineHeight: { micro: fonts.buttonLabel4.lineHeight, @@ -237,8 +240,6 @@ export const buttonFont = createFont({ }, }) -// TODO mono font - export const allFonts = { heading: headingFont, subHeading: subHeadingFont, diff --git a/packages/ui/src/theme/spacing.ts b/packages/ui/src/theme/spacing.ts index d299f4456d5..99c1cfbd560 100644 --- a/packages/ui/src/theme/spacing.ts +++ b/packages/ui/src/theme/spacing.ts @@ -3,6 +3,7 @@ export const spacing = { spacing1: 1, spacing2: 2, spacing4: 4, + spacing6: 6, spacing8: 8, spacing12: 12, spacing16: 16, diff --git a/packages/ui/src/theme/zIndices.ts b/packages/ui/src/theme/zIndices.ts index 0d11f563e64..2734e1855ff 100644 --- a/packages/ui/src/theme/zIndices.ts +++ b/packages/ui/src/theme/zIndices.ts @@ -3,6 +3,7 @@ export const zIndices = { negative: -1, background: 0, default: 1, + mask: 10, dropdown: 1000, sticky: 1020, fixed: 1030, diff --git a/packages/uniswap/.gitignore b/packages/uniswap/.gitignore index a28e8ba5164..663f5c6e7e7 100644 --- a/packages/uniswap/.gitignore +++ b/packages/uniswap/.gitignore @@ -8,3 +8,5 @@ src/abis/types # generated graphql files src/**/__generated__ + +src/i18n/locales/web-translations/*.json diff --git a/packages/wallet/openapitools.json b/packages/uniswap/openapitools.json similarity index 100% rename from packages/wallet/openapitools.json rename to packages/uniswap/openapitools.json diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index 7cd9c839062..a51c69051f0 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -14,6 +14,8 @@ "lint": "eslint . --ext ts,tsx --max-warnings=0", "lint:fix": "eslint . --ext ts,tsx --fix", "test": "jest --passWithNoTests", + "tradingapi:schema": "curl https://api.uniswap.org/v2/trade/api.json -o ./src/data/tradingApi/api.json", + "tradingapi:generate": "openapi --input ./src/data/tradingApi/api.json --output ./src/data/tradingApi/__generated__ --client axios --useOptions --exportServices true --exportModels true", "snapshots": "jest -u", "typecheck": "tsc -b" }, @@ -30,13 +32,15 @@ "@uniswap/router-sdk": "1.9.2", "@uniswap/sdk-core": "5.3.0", "apollo-link-rest": "0.9.0", + "es-toolkit": "1.10.0", "ethers": "5.7.2", "expo-blur": "12.9.2", "expo-clipboard": "5.0.1", "expo-web-browser": "12.8.2", "fuse.js": "6.5.3", "i18next": "23.10.0", - "lodash": "4.17.21", + "i18next-resources-to-backend": "^1.2.0", + "openapi-typescript-codegen": "0.27.0", "react": "18.2.0", "react-i18next": "14.1.0", "react-native": "0.73.6", diff --git a/packages/uniswap/src/components/CurrencyLogo/NetworkLogo.tsx b/packages/uniswap/src/components/CurrencyLogo/NetworkLogo.tsx index da2b78166a4..563954b8a9f 100644 --- a/packages/uniswap/src/components/CurrencyLogo/NetworkLogo.tsx +++ b/packages/uniswap/src/components/CurrencyLogo/NetworkLogo.tsx @@ -1,11 +1,12 @@ import React from 'react' import { Flex, FlexProps, Image, useSporeColors } from 'ui/src' +import { ALL_NETWORKS_LOGO } from 'ui/src/assets' import { iconSizes } from 'ui/src/theme' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { UniverseChainId } from 'uniswap/src/types/chains' type NetworkLogoProps = FlexProps & { - chainId: UniverseChainId + chainId: UniverseChainId | null // null signifies this is the AllNetworks logo size?: number shape?: 'circle' | 'square' } @@ -28,9 +29,17 @@ export function TransactionSummaryNetworkLogo({ } function _NetworkLogo({ chainId, shape, size = iconSizes.icon20 }: NetworkLogoProps): JSX.Element | null { - const logo = UNIVERSE_CHAIN_INFO[chainId].logo const colors = useSporeColors() const borderRadius = shape === 'circle' ? size / 2 : SQUARE_BORDER_RADIUS + + if (chainId === null) { + return ( + <Flex style={{ borderColor: colors.surface1.get(), borderRadius, overflow: 'hidden' }} testID="all-networks-logo"> + <Image resizeMode="contain" source={ALL_NETWORKS_LOGO} style={{ width: size, height: size }} /> + </Flex> + ) + } + const logo = UNIVERSE_CHAIN_INFO[chainId].logo return logo ? ( <Flex style={{ borderColor: colors.surface1.get(), borderRadius, overflow: 'hidden' }} testID="network-logo"> <Image resizeMode="contain" source={logo} style={{ width: size, height: size }} /> diff --git a/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx b/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx index 442804be6a8..76ddb79ab5a 100644 --- a/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx +++ b/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx @@ -30,8 +30,9 @@ function _SuggestedToken({ onPress={onPress} > <Pill - backgroundColor="$surface3" + borderColor="$surface3" borderRadius="$roundedFull" + borderWidth={1} foregroundColor={colors.neutral1.val} icon={ <TokenLogo @@ -45,7 +46,7 @@ function _SuggestedToken({ pl="$spacing4" pr="$spacing12" py="$spacing4" - textVariant={isWeb ? 'buttonLabel4' : 'body1'} + textVariant="buttonLabel1" /> </TouchableArea> ) diff --git a/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx b/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx index 77b89c74872..9d27dce7161 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx @@ -4,7 +4,6 @@ import { iconSizes } from 'ui/src/theme' import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' import { TokenOption } from 'uniswap/src/components/TokenSelector/types' import WarningIcon from 'uniswap/src/components/icons/WarningIcon' -import { InlineNetworkPill } from 'uniswap/src/components/network/NetworkPill' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import TokenWarningModal from 'uniswap/src/features/tokens/TokenWarningModal' import { shortenAddress } from 'uniswap/src/utils/addresses' @@ -12,7 +11,6 @@ import { getSymbolDisplayText } from 'uniswap/src/utils/currency' interface OptionProps { option: TokenOption - showNetworkPill: boolean showWarnings: boolean onDismiss?: () => void onPress: () => void @@ -28,7 +26,6 @@ interface OptionProps { function _TokenOptionItem({ option, - showNetworkPill, showWarnings, onDismiss, onPress, @@ -84,18 +81,18 @@ function _TokenOptionItem({ symbol={currency.symbol} url={currencyInfo.logoUrl ?? undefined} /> - <Flex shrink alignItems="flex-start"> - <Flex centered row gap="$spacing8"> - <Flex shrink> - <Text color="$neutral1" numberOfLines={1} variant={isWeb ? 'body2' : 'body1'}> - {currency.name} - </Text> - </Flex> + <Flex shrink> + <Flex row alignItems="center" gap="$spacing8"> + <Text color="$neutral1" numberOfLines={1} variant={isWeb ? 'body2' : 'body1'}> + {currency.name} + </Text> {(safetyLevel === SafetyLevel.Blocked || safetyLevel === SafetyLevel.StrongWarning) && ( - <WarningIcon safetyLevel={safetyLevel} size="$icon.16" strokeColorOverride="neutral3" /> + <Flex> + <WarningIcon safetyLevel={safetyLevel} size="$icon.16" strokeColorOverride="neutral3" /> + </Flex> )} </Flex> - <Flex centered row gap="$spacing8"> + <Flex row alignItems="center" gap="$spacing8"> <Text color="$neutral2" numberOfLines={1} variant="body3"> {getSymbolDisplayText(currency.symbol)} </Text> @@ -106,32 +103,29 @@ function _TokenOptionItem({ </Text> </Flex> )} - {showNetworkPill && <InlineNetworkPill chainId={currency.chainId} />} </Flex> </Flex> </Flex> {quantity && quantity !== 0 ? ( <Flex alignItems="flex-end"> - <Text variant={isWeb ? 'body2' : 'body1'}>{quantityFormatted}</Text> + <Text variant={isWeb ? 'body2' : 'body1'}>{balance}</Text> <Text color="$neutral2" variant={isWeb ? 'body3' : 'subheading2'}> - {balance} + {quantityFormatted} </Text> </Flex> ) : null} </Flex> </TouchableArea> - {showWarningModal ? ( - <TokenWarningModal - isVisible - currencyId={currencyId} - safetyLevel={safetyLevel} - tokenLogoUrl={logoUrl} - onAccept={onAcceptTokenWarning} - onClose={(): void => setShowWarningModal(false)} - /> - ) : null} + <TokenWarningModal + currencyId={currencyId} + isVisible={showWarningModal} + safetyLevel={safetyLevel} + tokenLogoUrl={logoUrl} + onAccept={onAcceptTokenWarning} + onClose={(): void => setShowWarningModal(false)} + /> </> ) } diff --git a/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx b/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx index d85abfcf464..4bc30d35823 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx @@ -1,4 +1,4 @@ -import { isEqual } from 'lodash' +import { isEqual } from 'es-toolkit' import React, { CSSProperties, Key, useCallback, useEffect, useMemo, useRef, useState } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' import { VariableSizeList as List } from 'react-window' @@ -11,6 +11,7 @@ import { } from 'uniswap/src/components/TokenSelector/TokenSectionBaseList' const ITEM_ROW_HEIGHT = 72 +const ITEM_SECTION_HEADER_ROW_HEIGHT = 40 type BaseListRowInfo = { key: Key | undefined @@ -103,7 +104,8 @@ export function TokenSectionBaseList({ } else if (measuredHeight) { return measuredHeight } - return ITEM_ROW_HEIGHT + + return isSectionHeader(item) ? ITEM_SECTION_HEADER_ROW_HEIGHT : ITEM_ROW_HEIGHT }, [items], ) @@ -200,10 +202,14 @@ function _Row({ index, itemData, style, windowWidth, updateRowHeight }: RowProps const rowRef = useRef<HTMLElement>(null) useEffect(() => { - const height = rowRef.current?.getBoundingClientRect().height - if (height && updateRowHeight) { + // We need to run this in the next tick to get the correct height. + setTimeout(() => { + const height = rowRef.current?.getBoundingClientRect().height + if (!height || !updateRowHeight) { + return + } updateRowHeight(index, height) - } + }, 0) }, [updateRowHeight, index, windowWidth]) return ( diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx index d8eb8ce2fc1..6e3a442484c 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx @@ -23,17 +23,20 @@ import PasteButton from 'uniswap/src/components/buttons/PasteButton' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' import { NetworkFilter } from 'uniswap/src/components/network/NetworkFilter' +import { PortfolioValueModifier } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' import { SearchContext } from 'uniswap/src/features/search/SearchContext' +import { TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName, ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' -import { WalletChainId } from 'uniswap/src/types/chains' +import { UniverseChainId } from 'uniswap/src/types/chains' import { getClipboard } from 'uniswap/src/utils/clipboard' +import { isInterface } from 'utilities/src/platform' import { useDebounce } from 'utilities/src/time/timing' export enum TokenSelectorVariation { @@ -51,11 +54,14 @@ export interface TokenSelectorProps { currencyField: CurrencyField flow: TokenSelectorFlow activeAccountAddress: string - chainId?: WalletChainId + chainId?: UniverseChainId + valueModifiers?: PortfolioValueModifier[] + searchHistory?: TokenSearchResult[] isSurfaceReady?: boolean onClose: () => void onDismiss: () => void onPressAnimation: () => void + onSelectChain?: (chainId: UniverseChainId | null) => void onSelectCurrency: (currency: Currency, currencyField: CurrencyField, context: SearchContext) => void variation: TokenSelectorVariation addToSearchHistoryCallback: (currencyInfo: CurrencyInfo) => void @@ -75,13 +81,16 @@ export interface TokenSelectorProps { function TokenSelectorContent({ currencyField, flow, + searchHistory, onSelectCurrency, chainId, + valueModifiers, onClose, variation, isSurfaceReady = true, activeAccountAddress, onDismiss, + onSelectChain, onPressAnimation, addToSearchHistoryCallback, convertFiatAmountFormattedCallback, @@ -108,6 +117,12 @@ function TokenSelectorContent({ setHasClipboardString(result) } + // Browser doesn't have permissions to access clipboard by default + // so it will prompt the user to allow clipboard access which is + // quite jarring and unnecessary. + if (isInterface) { + return + } checkClipboard().catch(() => undefined) }, []) @@ -175,6 +190,7 @@ function TokenSelectorContent({ if (searchFilter) { return ( <TokenSelectorSearchResultsList + activeAccountAddress={activeAccountAddress} addToSearchHistoryCallback={addToSearchHistoryCallback} chainFilter={chainFilter} convertFiatAmountFormattedCallback={convertFiatAmountFormattedCallback} @@ -184,6 +200,7 @@ function TokenSelectorContent({ searchFilter={searchFilter} useTokenSectionsForSearchResultsHook={useTokenSectionsForSearchResultsHook} useTokenWarningDismissedHook={useTokenWarningDismissedHook} + valueModifiers={valueModifiers} onDismiss={onDismiss} onSelectCurrency={onSelectCurrencyCallback} /> @@ -198,8 +215,10 @@ function TokenSelectorContent({ chainFilter={chainFilter} convertFiatAmountFormattedCallback={convertFiatAmountFormattedCallback} formatNumberOrStringCallback={formatNumberOrStringCallback} + searchHistory={searchHistory} usePortfolioTokenOptionsHook={usePortfolioTokenOptionsHook} useTokenWarningDismissedHook={useTokenWarningDismissedHook} + valueModifiers={valueModifiers} onDismiss={onDismiss} onEmptyActionPress={onSendEmptyActionPress} onSelectCurrency={onSelectCurrencyCallback} @@ -212,9 +231,12 @@ function TokenSelectorContent({ chainFilter={chainFilter} convertFiatAmountFormattedCallback={convertFiatAmountFormattedCallback} formatNumberOrStringCallback={formatNumberOrStringCallback} + searchHistory={searchHistory} + useFavoriteTokensOptionsHook={useFavoriteTokensOptionsHook} usePopularTokensOptionsHook={usePopularTokensOptionsHook} usePortfolioTokenOptionsHook={usePortfolioTokenOptionsHook} useTokenWarningDismissedHook={useTokenWarningDismissedHook} + valueModifiers={valueModifiers} onDismiss={onDismiss} onSelectCurrency={onSelectCurrencyCallback} /> @@ -226,10 +248,13 @@ function TokenSelectorContent({ chainFilter={chainFilter} convertFiatAmountFormattedCallback={convertFiatAmountFormattedCallback} formatNumberOrStringCallback={formatNumberOrStringCallback} + searchHistory={searchHistory} useCommonTokensOptionsHook={useCommonTokensOptionsHook} useFavoriteTokensOptionsHook={useFavoriteTokensOptionsHook} usePopularTokensOptionsHook={usePopularTokensOptionsHook} + usePortfolioTokenOptionsHook={usePortfolioTokenOptionsHook} useTokenWarningDismissedHook={useTokenWarningDismissedHook} + valueModifiers={valueModifiers} onDismiss={onDismiss} onSelectCurrency={onSelectCurrencyCallback} /> @@ -238,10 +263,12 @@ function TokenSelectorContent({ }, [ searchInFocus, searchFilter, + searchHistory, variation, activeAccountAddress, chainFilter, debouncedSearchFilter, + valueModifiers, onDismiss, addToSearchHistoryCallback, convertFiatAmountFormattedCallback, @@ -259,11 +286,11 @@ function TokenSelectorContent({ return ( <Trace logImpression element={currencyFieldName} section={SectionName.TokenSelector}> - <Flex grow gap={isWeb ? '$spacing4' : '$spacing16'} px="$spacing16"> + <Flex grow gap="$spacing4" px="$spacing16"> <Flex borderBottomColor={isWeb ? '$surface3' : undefined} borderBottomWidth={isWeb ? '$spacing1' : undefined} - py="$spacing8" + py="$spacing4" > <SearchTextInput autoFocus={isWeb} @@ -291,7 +318,10 @@ function TokenSelectorContent({ selectedChain={chainFilter} onDismiss={onDismiss} onPressAnimation={onPressAnimation} - onPressChain={onChangeChainFilter} + onPressChain={(newChainId) => { + onChangeChainFilter(newChainId) + onSelectChain?.(newChainId) + }} /> </Flex> )} diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx index 671de80a0bd..a435597b68b 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx @@ -42,7 +42,6 @@ function TokenOptionItemWrapper({ onSelectCurrency, section, index, - chainFilter, showWarnings, showTokenAddress, useTokenWarningDismissedHook, @@ -53,7 +52,6 @@ function TokenOptionItemWrapper({ onSelectCurrency: OnSelectCurrency section: TokenSection index: number - chainFilter: Maybe<UniverseChainId> showWarnings: boolean showTokenAddress?: boolean useTokenWarningDismissedHook: TokenWarningDismissedHook @@ -80,7 +78,6 @@ function TokenOptionItemWrapper({ value: tokenOption.quantity, type: NumberType.TokenTx, })} - showNetworkPill={!chainFilter && tokenOption.currencyInfo.currency.chainId !== UniverseChainId.Mainnet} showTokenAddress={showTokenAddress} showWarnings={showWarnings} tokenWarningDismissed={tokenWarningDismissed} @@ -153,7 +150,6 @@ function _TokenSelectorList({ if (!isSuggestedTokenItem(item) && !isSuggestedTokenSection(section)) { return ( <TokenOptionItemWrapper - chainFilter={chainFilter} convertFiatAmountFormattedCallback={convertFiatAmountFormattedCallback} formatNumberOrStringCallback={formatNumberOrStringCallback} index={index} @@ -171,7 +167,6 @@ function _TokenSelectorList({ return null }, [ - chainFilter, onSelectCurrency, showTokenAddress, showTokenWarnings, @@ -208,7 +203,7 @@ function _TokenSelectorList({ ) } - if (loading) { + if (!sections && loading) { return ( <Flex grow> <Flex py="$spacing16" width={80}> diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorSearchResultsList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorSearchResultsList.tsx index d2998ced3e6..0c00bc277c7 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorSearchResultsList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorSearchResultsList.tsx @@ -7,6 +7,7 @@ import { OnSelectCurrency, TokenSection, } from 'uniswap/src/components/TokenSelector/types' +import { PortfolioValueModifier } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' @@ -29,31 +30,37 @@ function EmptyResults({ searchFilter }: { searchFilter: string }): JSX.Element { } function _TokenSelectorSearchResultsList({ - addToSearchHistoryCallback, - onDismiss, onSelectCurrency: parentOnSelectCurrency, + activeAccountAddress, chainFilter, searchFilter, debouncedSearchFilter, isBalancesOnlySearch, + valueModifiers, + onDismiss, + addToSearchHistoryCallback, formatNumberOrStringCallback, convertFiatAmountFormattedCallback, useTokenSectionsForSearchResultsHook, useTokenWarningDismissedHook, }: { onSelectCurrency: OnSelectCurrency + activeAccountAddress: string chainFilter: UniverseChainId | null searchFilter: string debouncedSearchFilter: string | null isBalancesOnlySearch: boolean + valueModifiers?: PortfolioValueModifier[] formatNumberOrStringCallback: (input: FormatNumberOrStringInput) => string convertFiatAmountFormattedCallback: ConvertFiatAmountFormattedCallback addToSearchHistoryCallback: (currencyInfo: CurrencyInfo) => void onDismiss: () => void useTokenSectionsForSearchResultsHook: ( + address: string, chainFilter: UniverseChainId | null, searchFilter: string | null, isBalancesOnlySearch: boolean, + valueModifiers?: PortfolioValueModifier[], ) => GqlResult<TokenSection[]> useTokenWarningDismissedHook: (currencyId: Maybe<string>) => { tokenWarningDismissed: boolean @@ -66,7 +73,13 @@ function _TokenSelectorSearchResultsList({ loading, error, refetch, - } = useTokenSectionsForSearchResultsHook(chainFilter, debouncedSearchFilter, isBalancesOnlySearch) + } = useTokenSectionsForSearchResultsHook( + activeAccountAddress, + chainFilter, + debouncedSearchFilter, + isBalancesOnlySearch, + valueModifiers, + ) const onSelectCurrency: OnSelectCurrency = (currencyInfo, section, index) => { parentOnSelectCurrency(currencyInfo, section, index) diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorSendList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorSendList.tsx index 921df139281..2ca04091350 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorSendList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorSendList.tsx @@ -18,6 +18,7 @@ import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatt function useTokenSectionsForSend({ activeAccountAddress, chainFilter, + valueModifiers, usePortfolioTokenOptionsHook, }: TokenSectionsForSend): GqlResult<TokenSection[]> { const { t } = useTranslation() @@ -27,7 +28,7 @@ function useTokenSectionsForSend({ error: portfolioTokenOptionsError, refetch: refetchPortfolioTokenOptions, loading: portfolioTokenOptionsLoading, - } = usePortfolioTokenOptionsHook(activeAccountAddress, chainFilter) + } = usePortfolioTokenOptionsHook(activeAccountAddress, chainFilter, valueModifiers) const loading = portfolioTokenOptionsLoading const error = !portfolioTokenOptions && portfolioTokenOptionsError @@ -75,10 +76,12 @@ function EmptyList({ onEmptyActionPress }: { onEmptyActionPress?: () => void }): } function _TokenSelectorSendList({ - onDismiss, - onSelectCurrency, activeAccountAddress, chainFilter, + searchHistory, + valueModifiers, + onDismiss, + onSelectCurrency, onEmptyActionPress, formatNumberOrStringCallback, convertFiatAmountFormattedCallback, @@ -103,6 +106,8 @@ function _TokenSelectorSendList({ } = useTokenSectionsForSend({ activeAccountAddress, chainFilter, + searchHistory, + valueModifiers, usePortfolioTokenOptionsHook, }) const emptyElement = useMemo(() => <EmptyList onEmptyActionPress={onEmptyActionPress} />, [onEmptyActionPress]) diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapInputList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapInputList.tsx index 03c5c5f3d58..d029e5df919 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapInputList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapInputList.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { TokenSelectorList } from 'uniswap/src/components/TokenSelector/TokenSelectorList' +import { filterRecentlySearchedTokenOptions } from 'uniswap/src/components/TokenSelector/hooks' import { ConvertFiatAmountFormattedCallback, OnSelectCurrency, @@ -13,12 +14,16 @@ import { getTokenOptionsSection, tokenOptionDifference } from 'uniswap/src/compo import { GqlResult } from 'uniswap/src/data/types' import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' import { UniverseChainId } from 'uniswap/src/types/chains' +import { isExtension } from 'utilities/src/platform' function useTokenSectionsForSwapInput({ activeAccountAddress, chainFilter, - usePortfolioTokenOptionsHook, + searchHistory, + valueModifiers, + useFavoriteTokensOptionsHook, usePopularTokensOptionsHook, + usePortfolioTokenOptionsHook, }: TokenSectionsForSwapInput): GqlResult<TokenSelectorListSections> { const { t } = useTranslation() @@ -27,7 +32,7 @@ function useTokenSectionsForSwapInput({ error: portfolioTokenOptionsError, refetch: refetchPortfolioTokenOptions, loading: portfolioTokenOptionsLoading, - } = usePortfolioTokenOptionsHook(activeAccountAddress, chainFilter) + } = usePortfolioTokenOptionsHook(activeAccountAddress, chainFilter, valueModifiers) const { data: popularTokenOptions, @@ -35,20 +40,32 @@ function useTokenSectionsForSwapInput({ refetch: refetchPopularTokenOptions, loading: popularTokenOptionsLoading, // if there is no chain filter then we show mainnet tokens - } = usePopularTokensOptionsHook(activeAccountAddress, chainFilter ?? UniverseChainId.Mainnet) + } = usePopularTokensOptionsHook(activeAccountAddress, chainFilter ?? UniverseChainId.Mainnet, valueModifiers) + + const { + data: favoriteTokenOptions, + error: favoriteTokenOptionsError, + refetch: refetchFavoriteTokenOptions, + loading: favoriteTokenOptionsLoading, + } = useFavoriteTokensOptionsHook(activeAccountAddress, chainFilter, valueModifiers) + + const recentlySearchedTokenOptions = filterRecentlySearchedTokenOptions(searchHistory) const error = - (!portfolioTokenOptions && portfolioTokenOptionsError) || (!popularTokenOptions && popularTokenOptionsError) + (!portfolioTokenOptions && portfolioTokenOptionsError) || + (!popularTokenOptions && popularTokenOptionsError) || + (!favoriteTokenOptions && favoriteTokenOptionsError) - const loading = portfolioTokenOptionsLoading || popularTokenOptionsLoading + const loading = portfolioTokenOptionsLoading || popularTokenOptionsLoading || favoriteTokenOptionsLoading const refetchAll = useCallback(() => { refetchPortfolioTokenOptions?.() refetchPopularTokenOptions?.() - }, [refetchPopularTokenOptions, refetchPortfolioTokenOptions]) + refetchFavoriteTokenOptions?.() + }, [refetchPopularTokenOptions, refetchPortfolioTokenOptions, refetchFavoriteTokenOptions]) const sections = useMemo(() => { - if (loading) { + if (loading && (!portfolioTokenOptions || !popularTokenOptions)) { return } @@ -56,9 +73,13 @@ function useTokenSectionsForSwapInput({ return [ ...(getTokenOptionsSection(t('tokens.selector.section.yours'), portfolioTokenOptions) ?? []), + ...(getTokenOptionsSection(t('tokens.selector.section.recent'), recentlySearchedTokenOptions) ?? []), + // TODO(WEB-3061): Favorited wallets/tokens + // Extension does not support favoriting but has a default list, so we can't rely on empty array check + ...(isExtension ? [] : getTokenOptionsSection(t('tokens.selector.section.favorite'), favoriteTokenOptions) ?? []), ...(getTokenOptionsSection(t('tokens.selector.section.popular'), popularMinusPortfolioTokens) ?? []), ] satisfies TokenSection[] - }, [loading, popularTokenOptions, portfolioTokenOptions, t]) + }, [loading, popularTokenOptions, portfolioTokenOptions, recentlySearchedTokenOptions, favoriteTokenOptions, t]) return useMemo( () => ({ @@ -76,8 +97,11 @@ function _TokenSelectorSwapInputList({ onSelectCurrency, activeAccountAddress, chainFilter, + searchHistory, + valueModifiers, formatNumberOrStringCallback, convertFiatAmountFormattedCallback, + useFavoriteTokensOptionsHook, usePortfolioTokenOptionsHook, usePopularTokensOptionsHook, useTokenWarningDismissedHook, @@ -96,9 +120,13 @@ function _TokenSelectorSwapInputList({ } = useTokenSectionsForSwapInput({ activeAccountAddress, chainFilter, + valueModifiers, + searchHistory, + useFavoriteTokensOptionsHook, usePortfolioTokenOptionsHook, usePopularTokensOptionsHook, }) + return ( <TokenSelectorList chainFilter={chainFilter} diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapOutputList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapOutputList.tsx index b2cbda5e283..ae3c64fb130 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapOutputList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorSwapOutputList.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { isWeb } from 'ui/src' import { TokenSelectorList } from 'uniswap/src/components/TokenSelector/TokenSelectorList' +import { filterRecentlySearchedTokenOptions } from 'uniswap/src/components/TokenSelector/hooks' import { ConvertFiatAmountFormattedCallback, OnSelectCurrency, @@ -12,30 +12,41 @@ import { getTokenOptionsSection } from 'uniswap/src/components/TokenSelector/uti import { GqlResult } from 'uniswap/src/data/types' import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' import { UniverseChainId } from 'uniswap/src/types/chains' +import { isExtension } from 'utilities/src/platform' function useTokenSectionsForSwapOutput({ activeAccountAddress, chainFilter, + searchHistory, + valueModifiers, + usePortfolioTokenOptionsHook, usePopularTokensOptionsHook, useFavoriteTokensOptionsHook, useCommonTokensOptionsHook, }: TokenSectionsForSwapOutput): GqlResult<TokenSelectorListSections> { const { t } = useTranslation() + const { + data: portfolioTokenOptions, + error: portfolioTokenOptionsError, + refetch: refetchPortfolioTokenOptions, + loading: portfolioTokenOptionsLoading, + } = usePortfolioTokenOptionsHook(activeAccountAddress, chainFilter, valueModifiers) + const { data: popularTokenOptions, error: popularTokenOptionsError, refetch: refetchPopularTokenOptions, loading: popularTokenOptionsLoading, // if there is no chain filter then we show mainnet tokens - } = usePopularTokensOptionsHook(activeAccountAddress, chainFilter ?? UniverseChainId.Mainnet) + } = usePopularTokensOptionsHook(activeAccountAddress, chainFilter ?? UniverseChainId.Mainnet, valueModifiers) const { data: favoriteTokenOptions, error: favoriteTokenOptionsError, refetch: refetchFavoriteTokenOptions, loading: favoriteTokenOptionsLoading, - } = useFavoriteTokensOptionsHook(activeAccountAddress, chainFilter) + } = useFavoriteTokensOptionsHook(activeAccountAddress, chainFilter, valueModifiers) const { data: commonTokenOptions, @@ -43,20 +54,28 @@ function useTokenSectionsForSwapOutput({ refetch: refetchCommonTokenOptions, loading: commonTokenOptionsLoading, // if there is no chain filter then we show mainnet tokens - } = useCommonTokensOptionsHook(activeAccountAddress, chainFilter ?? UniverseChainId.Mainnet) + } = useCommonTokensOptionsHook(activeAccountAddress, chainFilter ?? UniverseChainId.Mainnet, valueModifiers) + + const recentlySearchedTokenOptions = filterRecentlySearchedTokenOptions(searchHistory) const error = + (!portfolioTokenOptions && portfolioTokenOptionsError) || (!popularTokenOptions && popularTokenOptionsError) || (!favoriteTokenOptions && favoriteTokenOptionsError) || (!commonTokenOptions && commonTokenOptionsError) - const loading = popularTokenOptionsLoading || favoriteTokenOptionsLoading || commonTokenOptionsLoading + const loading = + portfolioTokenOptionsLoading || + popularTokenOptionsLoading || + favoriteTokenOptionsLoading || + commonTokenOptionsLoading const refetchAll = useCallback(() => { + refetchPortfolioTokenOptions?.() refetchPopularTokenOptions?.() refetchFavoriteTokenOptions?.() refetchCommonTokenOptions?.() - }, [refetchCommonTokenOptions, refetchFavoriteTokenOptions, refetchPopularTokenOptions]) + }, [refetchCommonTokenOptions, refetchFavoriteTokenOptions, refetchPopularTokenOptions, refetchPortfolioTokenOptions]) const sections = useMemo<TokenSelectorListSections>(() => { if (loading) { @@ -66,11 +85,22 @@ function useTokenSectionsForSwapOutput({ return [ // we draw the pills as a single item of a section list, so `data` is an array of Token[] { title: t('tokens.selector.section.suggested'), data: [commonTokenOptions ?? []] }, - // TODO temporarily hiding favorites from extension until we add favorites functionality - ...(isWeb ? [] : getTokenOptionsSection(t('tokens.selector.section.favorite'), favoriteTokenOptions) ?? []), + ...(getTokenOptionsSection(t('tokens.selector.section.yours'), portfolioTokenOptions) ?? []), + ...(getTokenOptionsSection(t('tokens.selector.section.recent'), recentlySearchedTokenOptions) ?? []), + // TODO(WEB-3061): Favorited wallets/tokens + // Extension does not support favoriting but has a default list, so we can't rely on empty array check + ...(isExtension ? [] : getTokenOptionsSection(t('tokens.selector.section.favorite'), favoriteTokenOptions) ?? []), ...(getTokenOptionsSection(t('tokens.selector.section.popular'), popularTokenOptions) ?? []), ] - }, [commonTokenOptions, favoriteTokenOptions, loading, popularTokenOptions, t]) + }, [ + commonTokenOptions, + favoriteTokenOptions, + loading, + popularTokenOptions, + portfolioTokenOptions, + recentlySearchedTokenOptions, + t, + ]) return useMemo( () => ({ @@ -88,12 +118,15 @@ function _TokenSelectorSwapOutputList({ onSelectCurrency, activeAccountAddress, chainFilter, + searchHistory, + valueModifiers, formatNumberOrStringCallback, convertFiatAmountFormattedCallback, - useTokenWarningDismissedHook, - usePopularTokensOptionsHook, - useFavoriteTokensOptionsHook, useCommonTokensOptionsHook, + useFavoriteTokensOptionsHook, + usePopularTokensOptionsHook, + usePortfolioTokenOptionsHook, + useTokenWarningDismissedHook, }: TokenSectionsForSwapOutput & { onSelectCurrency: OnSelectCurrency chainFilter: UniverseChainId | null @@ -113,6 +146,9 @@ function _TokenSelectorSwapOutputList({ } = useTokenSectionsForSwapOutput({ activeAccountAddress, chainFilter, + searchHistory, + valueModifiers, + usePortfolioTokenOptionsHook, usePopularTokensOptionsHook, useFavoriteTokensOptionsHook, useCommonTokensOptionsHook, diff --git a/packages/wallet/src/components/TokenSelector/flowToModalName.tsx b/packages/uniswap/src/components/TokenSelector/flowToModalName.tsx similarity index 100% rename from packages/wallet/src/components/TokenSelector/flowToModalName.tsx rename to packages/uniswap/src/components/TokenSelector/flowToModalName.tsx diff --git a/packages/uniswap/src/components/TokenSelector/hooks.tsx b/packages/uniswap/src/components/TokenSelector/hooks.tsx new file mode 100644 index 00000000000..189d0a6a3fc --- /dev/null +++ b/packages/uniswap/src/components/TokenSelector/hooks.tsx @@ -0,0 +1,413 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { filter } from 'uniswap/src/components/TokenSelector/filter' +import { flowToModalName } from 'uniswap/src/components/TokenSelector/flowToModalName' +import { TokenOption, TokenSection } from 'uniswap/src/components/TokenSelector/types' +import { + createEmptyBalanceOption, + formatSearchResults, + getTokenOptionsSection, +} from 'uniswap/src/components/TokenSelector/utils' +import { BRIDGED_BASE_ADDRESSES } from 'uniswap/src/constants/addresses' +import { DAI, USDC, USDT, WBTC } from 'uniswap/src/constants/tokens' +import { + PortfolioValueModifier, + SafetyLevel, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { GqlResult } from 'uniswap/src/data/types' +import { + sortPortfolioBalances, + usePortfolioBalances, + useTokenBalancesGroupedByVisibility, +} from 'uniswap/src/features/dataApi/balances' +import { useSearchTokens } from 'uniswap/src/features/dataApi/searchTokens' +import { useTokenProjects } from 'uniswap/src/features/dataApi/tokenProjects' +import { usePopularTokens } from 'uniswap/src/features/dataApi/topTokens' +import { CurrencyInfo, PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { buildCurrency, usePersistedError } from 'uniswap/src/features/dataApi/utils' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { buildNativeCurrencyId, buildWrappedNativeCurrencyId, currencyId } from 'uniswap/src/utils/currencyId' + +// Use Mainnet base token addresses since TokenProjects query returns each token +// on each network +const baseCurrencyIds = [ + buildNativeCurrencyId(UniverseChainId.Mainnet), + buildNativeCurrencyId(UniverseChainId.Polygon), + buildNativeCurrencyId(UniverseChainId.Bnb), + buildNativeCurrencyId(UniverseChainId.Celo), + buildNativeCurrencyId(UniverseChainId.Avalanche), + currencyId(DAI), + currencyId(USDC), + currencyId(USDT), + currencyId(WBTC), + buildWrappedNativeCurrencyId(UniverseChainId.Mainnet), +] + +export function currencyInfosToTokenOptions( + currencyInfos: Array<CurrencyInfo | null> | undefined, +): TokenOption[] | undefined { + return currencyInfos + ?.filter((cI): cI is CurrencyInfo => Boolean(cI)) + .map((currencyInfo) => ({ + currencyInfo, + quantity: null, + balanceUSD: undefined, + })) +} + +export function searchResultToCurrencyInfo({ + chainId, + address, + symbol, + name, + logoUrl, + safetyLevel, +}: TokenSearchResult): CurrencyInfo | null { + const currency = buildCurrency({ + chainId: chainId as WalletChainId, + address, + decimals: 0, // this does not matter in a context of CurrencyInfo here, as we do not provide any balance + symbol, + name, + }) + + if (!currency) { + return null + } + + const currencyInfo: CurrencyInfo = { + currency, + currencyId: currencyId(currency), + logoUrl, + safetyLevel: safetyLevel ?? SafetyLevel.StrongWarning, + // defaulting to not spam, as user has searched and chosen this token before + isSpam: false, + } + return currencyInfo +} + +export function useAllCommonBaseCurrencies(): GqlResult<CurrencyInfo[]> { + return useCurrencies(baseCurrencyIds) +} + +export function useCurrencies(currencyIds: string[]): GqlResult<CurrencyInfo[]> { + const { data: baseCurrencyInfos, loading, error, refetch } = useTokenProjects(currencyIds) + const persistedError = usePersistedError(loading, error) + + // TokenProjects returns tokens on every network, so filter out native assets that have a + // bridged version on other networks + const filteredBaseCurrencyInfos = useMemo(() => { + return baseCurrencyInfos?.filter((currencyInfo) => { + if (currencyInfo.currency.isNative) { + return true + } + + const { address } = currencyInfo.currency + const bridgedAsset = BRIDGED_BASE_ADDRESSES.find((bridgedAddress) => areAddressesEqual(bridgedAddress, address)) + + if (!bridgedAsset) { + return true + } + + return false + }) + }, [baseCurrencyInfos]) + + return { data: filteredBaseCurrencyInfos, loading, error: persistedError, refetch } +} + +export function usePortfolioBalancesForAddressById( + address: Address, + valueModifiers?: PortfolioValueModifier[], +): GqlResult<Record<Address, PortfolioBalance> | undefined> { + const { + data: portfolioBalancesById, + error, + refetch, + loading, + } = usePortfolioBalances({ + address, + fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening + valueModifiers, + }) + + return { + data: portfolioBalancesById, + error, + refetch, + loading, + } +} + +export function useCurrencyInfosToTokenOptions({ + currencyInfos, + portfolioBalancesById, + sortAlphabetically, +}: { + currencyInfos?: CurrencyInfo[] + sortAlphabetically?: boolean + portfolioBalancesById?: Record<string, PortfolioBalance> +}): TokenOption[] | undefined { + // we use useMemo here to avoid recalculation of internals when function params are the same, + // but the component, where this hook is used is re-rendered + return useMemo(() => { + if (!currencyInfos) { + return undefined + } + const sortedCurrencyInfos = sortAlphabetically + ? [...currencyInfos].sort((a, b) => { + if (a.currency.name && b.currency.name) { + return a.currency.name.localeCompare(b.currency.name) + } + return 0 + }) + : currencyInfos + + return sortedCurrencyInfos.map( + (currencyInfo) => portfolioBalancesById?.[currencyInfo.currencyId] ?? createEmptyBalanceOption(currencyInfo), + ) + }, [currencyInfos, portfolioBalancesById, sortAlphabetically]) +} + +export function useCommonTokensOptions( + address: Address, + chainFilter: UniverseChainId | null, + valueModifiers?: PortfolioValueModifier[], +): GqlResult<TokenOption[] | undefined> { + const { + data: portfolioBalancesById, + error: portfolioBalancesByIdError, + refetch: portfolioBalancesByIdRefetch, + loading: loadingPorfolioBalancesById, + } = usePortfolioBalancesForAddressById(address, valueModifiers) + + const { + data: commonBaseCurrencies, + error: commonBaseCurrenciesError, + refetch: refetchCommonBaseCurrencies, + loading: loadingCommonBaseCurrencies, + } = useAllCommonBaseCurrencies() + + const commonBaseTokenOptions = useCurrencyInfosToTokenOptions({ + currencyInfos: commonBaseCurrencies, + portfolioBalancesById, + }) + + const refetch = useCallback(() => { + portfolioBalancesByIdRefetch?.() + refetchCommonBaseCurrencies?.() + }, [portfolioBalancesByIdRefetch, refetchCommonBaseCurrencies]) + + const error = + (!portfolioBalancesById && portfolioBalancesByIdError) || (!commonBaseCurrencies && commonBaseCurrenciesError) + + const filteredCommonBaseTokenOptions = useMemo( + () => commonBaseTokenOptions && filter(commonBaseTokenOptions, chainFilter), + [chainFilter, commonBaseTokenOptions], + ) + + return { + data: filteredCommonBaseTokenOptions, + refetch, + error: error || undefined, + loading: loadingPorfolioBalancesById || loadingCommonBaseCurrencies, + } +} + +export function usePopularTokensOptions( + address: Address, + chainFilter: UniverseChainId, + valueModifiers?: PortfolioValueModifier[], +): GqlResult<TokenOption[] | undefined> { + const { + data: portfolioBalancesById, + error: portfolioBalancesByIdError, + refetch: portfolioBalancesByIdRefetch, + loading: loadingPorfolioBalancesById, + } = usePortfolioBalancesForAddressById(address, valueModifiers) + + const { + data: popularTokens, + error: popularTokensError, + refetch: refetchPopularTokens, + loading: loadingPopularTokens, + } = usePopularTokens(chainFilter) + + const popularTokenOptions = useCurrencyInfosToTokenOptions({ + currencyInfos: popularTokens, + portfolioBalancesById, + sortAlphabetically: true, + }) + + const refetch = useCallback(() => { + portfolioBalancesByIdRefetch?.() + refetchPopularTokens?.() + }, [portfolioBalancesByIdRefetch, refetchPopularTokens]) + + const error = (!portfolioBalancesById && portfolioBalancesByIdError) || (!popularTokenOptions && popularTokensError) + + return { + data: popularTokenOptions, + refetch, + error: error || undefined, + loading: loadingPorfolioBalancesById || loadingPopularTokens, + } +} + +export function usePortfolioTokenOptions( + address: Address, + chainFilter: UniverseChainId | null, + valueModifiers?: PortfolioValueModifier[], + searchFilter?: string, +): GqlResult<TokenOption[] | undefined> { + const { + data: portfolioBalancesById, + error, + refetch, + loading, + } = usePortfolioBalancesForAddressById(address, valueModifiers) + + const { shownTokens } = useTokenBalancesGroupedByVisibility({ + balancesById: portfolioBalancesById, + }) + + const portfolioBalances = useMemo(() => (shownTokens ? sortPortfolioBalances(shownTokens) : undefined), [shownTokens]) + + const filteredPortfolioBalances = useMemo( + () => portfolioBalances && filter(portfolioBalances, chainFilter, searchFilter), + [chainFilter, portfolioBalances, searchFilter], + ) + + return { + data: filteredPortfolioBalances, + error, + refetch, + loading, + } +} + +export function useFilterCallbacks( + chainId: UniverseChainId | null, + flow: TokenSelectorFlow, +): { + chainFilter: UniverseChainId | null + searchFilter: string | null + onChangeChainFilter: (newChainFilter: UniverseChainId | null) => void + onClearSearchFilter: () => void + onChangeText: (newSearchFilter: string) => void +} { + const [chainFilter, setChainFilter] = useState<UniverseChainId | null>(chainId) + const [searchFilter, setSearchFilter] = useState<string | null>(null) + + useEffect(() => { + setChainFilter(chainId) + }, [chainId]) + + const onChangeChainFilter = useCallback( + (newChainFilter: typeof chainFilter) => { + setChainFilter(newChainFilter) + sendAnalyticsEvent(WalletEventName.NetworkFilterSelected, { + chain: newChainFilter ?? 'All', + modal: flowToModalName(flow), + }) + }, + [flow], + ) + + const onClearSearchFilter = useCallback(() => { + setSearchFilter(null) + }, []) + + const onChangeText = useCallback((newSearchFilter: string) => setSearchFilter(newSearchFilter), [setSearchFilter]) + + return { + chainFilter, + searchFilter, + onChangeChainFilter, + onClearSearchFilter, + onChangeText, + } +} + +export function filterRecentlySearchedTokenOptions(searchHistory?: TokenSearchResult[]): TokenOption[] | undefined { + return currencyInfosToTokenOptions( + searchHistory + ?.filter((searchResult): searchResult is TokenSearchResult => searchResult.type === SearchResultType.Token) + .map(searchResultToCurrencyInfo), + ) +} + +export function useTokenSectionsForSearchResults( + address: string, + chainFilter: UniverseChainId | null, + searchFilter: string | null, + isBalancesOnlySearch: boolean, + valueModifiers?: PortfolioValueModifier[], +): GqlResult<TokenSection[]> { + const { t } = useTranslation() + + const { + data: portfolioBalancesById, + error: portfolioBalancesByIdError, + refetch: refetchPortfolioBalances, + loading: portfolioBalancesByIdLoading, + } = usePortfolioBalancesForAddressById(address, valueModifiers) + + const { + data: portfolioTokenOptions, + error: portfolioTokenOptionsError, + refetch: refetchPortfolioTokenOptions, + loading: portfolioTokenOptionsLoading, + } = usePortfolioTokenOptions(address, chainFilter, valueModifiers, searchFilter ?? undefined) + + // Only call search endpoint if isBalancesOnlySearch is false + const { + data: searchResultCurrencies, + error: searchTokensError, + refetch: refetchSearchTokens, + loading: searchTokensLoading, + } = useSearchTokens(searchFilter, chainFilter, /*skip*/ isBalancesOnlySearch) + + const searchResults = useMemo(() => { + return formatSearchResults(searchResultCurrencies, portfolioBalancesById, searchFilter) + }, [searchResultCurrencies, portfolioBalancesById, searchFilter]) + + const loading = + portfolioTokenOptionsLoading || portfolioBalancesByIdLoading || (!isBalancesOnlySearch && searchTokensLoading) + + const sections = useMemo( + () => + getTokenOptionsSection( + t('tokens.selector.section.search'), + // Use local search when only searching balances + isBalancesOnlySearch ? portfolioTokenOptions : searchResults, + ), + [isBalancesOnlySearch, portfolioTokenOptions, searchResults, t], + ) + + const error = + (!portfolioBalancesById && portfolioBalancesByIdError) || + (!portfolioTokenOptions && portfolioTokenOptionsError) || + (!isBalancesOnlySearch && !searchResults && searchTokensError) + + const refetchAll = useCallback(() => { + refetchPortfolioBalances?.() + refetchSearchTokens?.() + refetchPortfolioTokenOptions?.() + }, [refetchPortfolioBalances, refetchPortfolioTokenOptions, refetchSearchTokens]) + + return useMemo( + () => ({ + data: sections, + loading, + error: error || undefined, + refetch: refetchAll, + }), + [error, loading, refetchAll, sections], + ) +} diff --git a/packages/uniswap/src/components/TokenSelector/types.ts b/packages/uniswap/src/components/TokenSelector/types.ts index 935d3501a7f..1d1730b01ab 100644 --- a/packages/uniswap/src/components/TokenSelector/types.ts +++ b/packages/uniswap/src/components/TokenSelector/types.ts @@ -1,5 +1,7 @@ +import { PortfolioValueModifier } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' import { UniverseChainId } from 'uniswap/src/types/chains' import { FiatNumberType } from 'utilities/src/format/types' @@ -13,19 +15,23 @@ export type TokenOption = { export type TokenOptionsHookType = ( address: string, chainFilter: UniverseChainId | null, + valueModifiers?: PortfolioValueModifier[], searchFilter?: string, ) => GqlResult<TokenOption[] | undefined> export type TokenOptionsWithChainFilterHookType = ( address: string, chainFilter: UniverseChainId, + valueModifiers?: PortfolioValueModifier[], searchFilter?: string, ) => GqlResult<TokenOption[] | undefined> export type TokenOptionsWithBalanceOnlySearchHookType = ( + address: string, chainFilter: UniverseChainId | null, searchFilter: string | null, isBalancesOnlySearch: boolean, + valueModifiers?: PortfolioValueModifier[], ) => GqlResult<TokenSection[]> export type OnSelectCurrency = ( @@ -56,28 +62,23 @@ export type TokenWarningDismissedHook = (currencyId: Maybe<string>) => { export type TokenSectionsForSwap = { activeAccountAddress: string chainFilter: UniverseChainId | null - usePopularTokensOptionsHook: (address: string, chainFilter: UniverseChainId) => GqlResult<TokenOption[] | undefined> - usePortfolioTokenOptionsHook: ( - address: string, - chainFilter: UniverseChainId | null, - searchFilter?: string | undefined, - ) => GqlResult<TokenOption[] | undefined> + searchHistory?: TokenSearchResult[] + valueModifiers?: PortfolioValueModifier[] + useFavoriteTokensOptionsHook: TokenOptionsHookType + usePopularTokensOptionsHook: TokenOptionsWithChainFilterHookType + usePortfolioTokenOptionsHook: TokenOptionsHookType } export type TokenSectionsForSwapInput = TokenSectionsForSwap -export type TokenSectionsForSwapOutput = Omit<TokenSectionsForSwap, 'usePortfolioTokenOptionsHook'> & { - useFavoriteTokensOptionsHook: ( - address: string, - chainFilter: UniverseChainId | null, - ) => GqlResult<TokenOption[] | undefined> - useCommonTokensOptionsHook: ( - address: string, - chainFilter: UniverseChainId | null, - ) => GqlResult<TokenOption[] | undefined> +export type TokenSectionsForSwapOutput = TokenSectionsForSwap & { + useCommonTokensOptionsHook: TokenOptionsWithChainFilterHookType } -export type TokenSectionsForSend = Omit<TokenSectionsForSwap, 'usePopularTokensOptionsHook'> +export type TokenSectionsForSend = Omit< + TokenSectionsForSwap, + 'usePopularTokensOptionsHook' | 'useFavoriteTokensOptionsHook' +> export type ConvertFiatAmountFormattedCallback = ( fromAmount: Maybe<string | number>, diff --git a/packages/uniswap/src/components/buttons/PasteButton.tsx b/packages/uniswap/src/components/buttons/PasteButton.tsx index f089054fd34..8e88425a2f2 100644 --- a/packages/uniswap/src/components/buttons/PasteButton.tsx +++ b/packages/uniswap/src/components/buttons/PasteButton.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { Flex, Text, TouchableArea } from 'ui/src' -import { ClipboardPaste, StickyNoteSquare } from 'ui/src/components/icons' +import { ClipboardPaste } from 'ui/src/components/icons/ClipboardPaste' +import { StickyNoteSquare } from 'ui/src/components/icons/StickyNoteSquare' import { getClipboard } from 'uniswap/src/utils/clipboard' export default function PasteButton({ diff --git a/packages/uniswap/src/components/buttons/__snapshots__/PasteButton.test.tsx.snap b/packages/uniswap/src/components/buttons/__snapshots__/PasteButton.test.tsx.snap index 09f91655c94..17b61ee8781 100644 --- a/packages/uniswap/src/components/buttons/__snapshots__/PasteButton.test.tsx.snap +++ b/packages/uniswap/src/components/buttons/__snapshots__/PasteButton.test.tsx.snap @@ -104,7 +104,7 @@ exports[`SplitLogo inline renders inline button 1`] = ` { "color": "#9B9B9B", "fontFamily": "Basel-Medium", - "fontSize": 14, + "fontSize": 12, "fontWeight": "500", "lineHeight": 16, } @@ -169,7 +169,7 @@ exports[`SplitLogo renders without error 1`] = ` { "color": "#FC72FF", "fontFamily": "Basel-Medium", - "fontSize": 14, + "fontSize": 12, "fontWeight": "500", "lineHeight": 16, } diff --git a/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx b/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx index ae1a5bd2713..72f642fa5c7 100644 --- a/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx +++ b/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, useMemo, useRef, useState } from 'react' +import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' /* eslint-disable-next-line no-restricted-imports */ import { type View } from 'react-native' import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated' @@ -19,7 +19,7 @@ import { spacing, zIndices } from 'ui/src/theme' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { Scrollbar } from 'uniswap/src/components/misc/Scrollbar' import { MenuItemProp } from 'uniswap/src/components/modals/ActionSheetModal' -import { isAndroid } from 'utilities/src/platform' +import { isAndroid, isInterface, isTouchable } from 'utilities/src/platform' const DEFAULT_MIN_WIDTH = 225 @@ -32,26 +32,34 @@ type LayoutMeasurements = { type DropdownState = { isOpen: boolean - toggleMeasurements: LayoutMeasurements | null + toggleMeasurements: (LayoutMeasurements & { sticky?: boolean }) | null +} + +export type ActionSheetDropdownStyleProps = { + alignment?: 'left' | 'right' + sticky?: boolean + buttonPaddingX?: FlexProps['px'] + buttonPaddingY?: FlexProps['py'] + dropdownMaxHeight?: number + dropdownZIndex?: FlexProps['zIndex'] } type ActionSheetDropdownProps = PropsWithChildren<{ options: MenuItemProp[] - alignment?: 'left' | 'right' - backdropOpacity?: number + styles?: ActionSheetDropdownStyleProps & { backdropOpacity?: number } testID?: string onDismiss?: () => void }> export function ActionSheetDropdown({ children, - backdropOpacity, + styles, testID, onDismiss, ...contentProps }: ActionSheetDropdownProps): JSX.Element { const insets = useDeviceInsets() - const toggleRef = useRef<View>(null) + const containerRef = useRef<View>(null) const [{ isOpen, toggleMeasurements }, setState] = useState<DropdownState>({ isOpen: false, toggleMeasurements: null, @@ -60,7 +68,7 @@ export function ActionSheetDropdown({ const openDropdown = (): void => { onDismiss?.() - const containerNode = toggleRef.current + const containerNode = containerRef?.current if (containerNode) { containerNode.measureInWindow((x, y, width, height) => { @@ -71,33 +79,75 @@ export function ActionSheetDropdown({ y: y + (isAndroid ? insets.top : 0), width, height, + sticky: styles?.sticky, }, }) }) } } + useEffect(() => { + if (!isWeb) { + return + } + + function resizeListener(): void { + containerRef?.current?.measureInWindow((x, y, width, height) => { + setState((prev) => ({ + ...prev, + toggleMeasurements: { + ...prev.toggleMeasurements, + x, + y, + width, + height, + }, + })) + }) + } + + window.addEventListener('resize', resizeListener) + + return () => { + window.removeEventListener('resize', resizeListener) + } + }, [toggleMeasurements?.sticky, insets.top]) + const closeDropdown = (): void => { setState({ isOpen: false, toggleMeasurements: null }) } return ( <> - <TouchableArea hapticFeedback hapticStyle={ImpactFeedbackStyle.Light} py="$spacing8" onPress={openDropdown}> + <TouchableArea hapticFeedback hapticStyle={ImpactFeedbackStyle.Light} onPress={openDropdown}> {/* collapsable property prevents removing view on Android. Without this property we were getting undefined in measureInWindow callback. (https://reactnative.dev/docs/view.html#collapsable-android) */} - <Flex ref={toggleRef} collapsable={false} testID={testID || 'dropdown-toggle'}> + <Flex + ref={containerRef} + collapsable={false} + px={styles?.buttonPaddingX} + py={styles?.buttonPaddingY || '$spacing8'} + testID={testID || 'dropdown-toggle'} + > {children} </Flex> </TouchableArea> - {/* This is the minimum zIndex to ensure that the dropdown is above the modal in the extension. */} - <Portal zIndex={zIndices.overlay}> + <Portal zIndex={styles?.dropdownZIndex || zIndices.popover}> <AnimatePresence custom={{ isOpen }}> {isOpen && toggleMeasurements && ( <> - <Backdrop handleClose={closeDropdown} opacity={backdropOpacity} /> - <DropdownContent {...contentProps} handleClose={closeDropdown} toggleMeasurements={toggleMeasurements} /> + <Backdrop + handleClose={closeDropdown} + opacity={!isInterface || isTouchable ? styles?.backdropOpacity : 0} + /> + <DropdownContent + {...contentProps} + alignment={styles?.alignment} + dropdownMaxHeight={styles?.dropdownMaxHeight} + handleClose={closeDropdown} + toggleMeasurements={toggleMeasurements} + /> </> )} </AnimatePresence> @@ -109,7 +159,8 @@ export function ActionSheetDropdown({ type DropdownContentProps = FlexProps & { options: MenuItemProp[] alignment?: 'left' | 'right' - toggleMeasurements: LayoutMeasurements + dropdownMaxHeight?: number + toggleMeasurements: LayoutMeasurements & { sticky?: boolean } handleClose?: () => void } @@ -134,6 +185,7 @@ const TouchableWhenOpen = styled(Flex, { function DropdownContent({ options, alignment = 'left', + dropdownMaxHeight, toggleMeasurements, handleClose, ...rest @@ -142,7 +194,7 @@ function DropdownContent({ const { fullWidth, fullHeight } = useDeviceDimensions() const scrollOffset = useSharedValue(0) - const [contentHeight, setContentHight] = useState(0) + const [contentHeight, setContentHeight] = useState(0) const scrollHandler = useAnimatedScrollHandler((event) => { scrollOffset.value = event.contentOffset.y @@ -152,21 +204,50 @@ function DropdownContent({ if (alignment === 'left') { return { left: toggleMeasurements.x, + right: 'unset', maxWidth: fullWidth - toggleMeasurements.x - spacing.spacing12, } } return { + left: 'unset', right: fullWidth - (toggleMeasurements.x + toggleMeasurements.width), maxWidth: toggleMeasurements.x + toggleMeasurements.width - spacing.spacing12, } }, [alignment, fullWidth, toggleMeasurements]) const bottomOffset = insets.bottom + spacing.spacing12 - const maxHeight = Math.max(fullHeight - toggleMeasurements.y - toggleMeasurements.height - bottomOffset, 0) + const maxHeight = + (isInterface && dropdownMaxHeight) || + Math.max(fullHeight - toggleMeasurements.y - toggleMeasurements.height - bottomOffset, 0) const overflowsContainer = contentHeight > maxHeight + const initialScrollY = useMemo(() => window.scrollY, []) + const [windowScrollY, setWindowScrollY] = useState(0) + useEffect(() => { + if (!isWeb) { + return + } + + function scrollListener(): void { + if (!toggleMeasurements?.sticky && window.scrollY >= 0) { + setWindowScrollY(window.scrollY - initialScrollY) + } + } + window.addEventListener('scroll', scrollListener) + return () => { + window.removeEventListener('scroll', scrollListener) + } + }, [initialScrollY, toggleMeasurements?.sticky]) + + useEffect(() => { + if (toggleMeasurements) { + setWindowScrollY(0) + } + }, [toggleMeasurements]) + return ( <TouchableWhenOpen + animateOnly={toggleMeasurements?.sticky ? ['opacity', 'y'] : ['opacity']} animation={[ 'quicker', { @@ -187,10 +268,17 @@ function DropdownContent({ minWidth={DEFAULT_MIN_WIDTH} position="absolute" testID="dropdown-content" - top={toggleMeasurements.y + toggleMeasurements.height} + top={toggleMeasurements.y + toggleMeasurements.height - windowScrollY} {...containerProps} > - <BaseCard.Shadow backgroundColor="$surface2" overflow="hidden" p="$none" {...rest}> + <BaseCard.Shadow + backgroundColor="$surface1" + borderColor="$surface3" + borderWidth={1} + overflow="hidden" + p="$none" + {...rest} + > <Flex row maxHeight={maxHeight}> <Animated.ScrollView contentContainerStyle={{ @@ -207,13 +295,15 @@ function DropdownContent({ layout: { height }, }, }) => { - setContentHight(height) + setContentHeight(height) }} > {options.map(({ key, onPress, render }: MenuItemProp) => ( <TouchableArea key={key} hapticFeedback + hoverable + borderRadius="$rounded8" onPress={() => { onPress() handleClose?.() diff --git a/packages/uniswap/src/components/dropdowns/__snapshots__/ActionSheetDropdown.test.tsx.snap b/packages/uniswap/src/components/dropdowns/__snapshots__/ActionSheetDropdown.test.tsx.snap index daaa2488342..d9b29815a62 100644 --- a/packages/uniswap/src/components/dropdowns/__snapshots__/ActionSheetDropdown.test.tsx.snap +++ b/packages/uniswap/src/components/dropdowns/__snapshots__/ActionSheetDropdown.test.tsx.snap @@ -18,8 +18,6 @@ exports[`ActionSheetDropdown should render 1`] = ` { "flexDirection": "column", "opacity": 1, - "paddingBottom": 8, - "paddingTop": 8, "transform": [ { "scale": 1, @@ -33,6 +31,8 @@ exports[`ActionSheetDropdown should render 1`] = ` style={ { "flexDirection": "column", + "paddingBottom": 8, + "paddingTop": 8, } } testID="dropdown-toggle" diff --git a/packages/uniswap/src/components/icons/WarningIcon.tsx b/packages/uniswap/src/components/icons/WarningIcon.tsx index 3d97586cd58..36f85eb4f71 100644 --- a/packages/uniswap/src/components/icons/WarningIcon.tsx +++ b/packages/uniswap/src/components/icons/WarningIcon.tsx @@ -1,5 +1,6 @@ import { IconProps, useSporeColors } from 'ui/src' -import { AlertTriangle, XOctagon } from 'ui/src/components/icons' +import { AlertTriangle } from 'ui/src/components/icons/AlertTriangle' +import { XOctagon } from 'ui/src/components/icons/XOctagon' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { useTokenSafetyLevelColors } from 'uniswap/src/features/tokens/safetyHooks' diff --git a/packages/uniswap/src/components/modals/BottomSheetModal.web.tsx b/packages/uniswap/src/components/modals/BottomSheetModal.web.tsx index d2be7456951..25802b7783f 100644 --- a/packages/uniswap/src/components/modals/BottomSheetModal.web.tsx +++ b/packages/uniswap/src/components/modals/BottomSheetModal.web.tsx @@ -1,4 +1,4 @@ -import { pick } from 'lodash' +import { pick } from 'es-toolkit' import { ComponentProps, useEffect, useState } from 'react' import { Flex, Portal, Sheet } from 'ui/src' import { validColor, zIndices } from 'ui/src/theme' @@ -73,7 +73,16 @@ function WebTopSheetModal({ }: WebBottomSheetProps): JSX.Element { const [isMounted, setIsMounted] = useState(false) - useEffect(() => setIsMounted(true), []) + useEffect(() => { + // adding timeout or else it doesn't render twice and animation doesn't run + const tm = setTimeout(() => { + setIsMounted(true) + }) + + return () => { + clearTimeout(tm) + } + }, []) useUpdateScrollLock({ isModalOpen }) @@ -100,23 +109,30 @@ function WebTopSheetModal({ onPress={isDismissible ? onClose : null} {...(isModalOpen && isMounted && { + exitStyle: { opacity: 0 }, opacity: 1, })} /> - {/* sheet */} <Flex - animation="quicker" + animation={[ + 'quicker', + { + opacity: { + overshootClamping: true, + }, + }, + ]} backgroundColor={backgroundColor ? validColor(backgroundColor) : '$surface1'} borderRadius="$rounded24" + enterStyle={{ y: -20, opacity: 0 }} flexShrink={1} opacity={0} p="$spacing12" - y={-20} {...(isModalOpen && isMounted && { + exitStyle: { y: -20, opacity: 0 }, opacity: 1, - y: 0, })} > {/* diff --git a/packages/uniswap/src/components/modals/PaginatedModals.tsx b/packages/uniswap/src/components/modals/PaginatedModals.tsx new file mode 100644 index 00000000000..19c6b785126 --- /dev/null +++ b/packages/uniswap/src/components/modals/PaginatedModals.tsx @@ -0,0 +1,66 @@ +import { memo, useCallback, useRef, useState } from 'react' + +export type PaginatedModalProps = { + onConfirm: () => void + onClose: () => void + key: number +} + +export type PaginatedModalRenderer = (props: PaginatedModalProps) => JSX.Element | null + +type PaginatedModalsProps = { + modals: PaginatedModalRenderer[] + onClose: () => void + onFinish: () => void +} + +export function PaginatedModals({ modals, onClose, onFinish }: PaginatedModalsProps): JSX.Element | null { + const previousPagesRef = useRef<Set<number>>(new Set()) + const [shownModalIndex, setShownModalIndex] = useState(0) + + const cleanup = useCallback(() => { + previousPagesRef.current.clear() + setShownModalIndex(0) + }, []) + + const handleClose = useCallback( + (modalIndex: number) => { + // We want to call onClose only if the modal was closed without confirming. + // (onConfirm will be called first when the user confirms) + if (!previousPagesRef.current.has(modalIndex)) { + cleanup() + onClose() + } + }, + [onClose, cleanup], + ) + + const handleConfirm = useCallback(() => { + setShownModalIndex((currentIndex) => { + previousPagesRef.current.add(currentIndex) + if (currentIndex + 1 >= modals.length) { + cleanup() + onFinish() + return currentIndex + } + return currentIndex + 1 + }) + }, [modals.length, onFinish, cleanup]) + + const renderModal = modals[shownModalIndex] + + return renderModal ? ( + <Page modalIndex={shownModalIndex} renderModal={renderModal} onClose={handleClose} onConfirm={handleConfirm} /> + ) : null +} + +type PageProps = { + modalIndex: number + renderModal: PaginatedModalRenderer + onClose: (modalIndex: number) => void + onConfirm: () => void +} + +const Page = memo(function _Page({ modalIndex, renderModal, onClose, onConfirm }: PageProps): JSX.Element | null { + return renderModal({ onClose: () => onClose(modalIndex), onConfirm, key: modalIndex }) +}) diff --git a/packages/uniswap/src/components/modals/useBottomSheetSafeKeyboard.native.tsx b/packages/uniswap/src/components/modals/useBottomSheetSafeKeyboard.native.tsx new file mode 100644 index 00000000000..34deba181c5 --- /dev/null +++ b/packages/uniswap/src/components/modals/useBottomSheetSafeKeyboard.native.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react' +import { EmitterSubscription, Keyboard } from 'react-native' +import { isIOS } from 'utilities/src/platform' + +/** + * Hook to substitute KeyboardAvoidingView for a bottom sheet modal + * Dynamically add bottom padding equal to keyboard height so that elements have room to shift up + */ +export function useBottomSheetSafeKeyboard(): { + keyboardHeight: number +} { + const [keyboardHeight, setKeyboardHeight] = useState(0) + + useEffect(() => { + let showSubscription: EmitterSubscription + let hideSubscription: EmitterSubscription + + if (isIOS) { + // Using keyboardWillShow makes it feel more responsive, but only available on iOS + showSubscription = Keyboard.addListener('keyboardWillShow', (e) => { + setKeyboardHeight(e.endCoordinates.height) + }) + hideSubscription = Keyboard.addListener('keyboardWillHide', () => { + setKeyboardHeight(0) + }) + } else { + // keyboardDidShow only emits after the keyboard has fully appeared + showSubscription = Keyboard.addListener('keyboardDidShow', (e) => { + setKeyboardHeight(e.endCoordinates.height) + }) + hideSubscription = Keyboard.addListener('keyboardDidHide', () => { + setKeyboardHeight(0) + }) + } + + return () => { + showSubscription.remove() + hideSubscription.remove() + } + }, []) + + return { keyboardHeight } +} diff --git a/packages/uniswap/src/components/modals/useBottomSheetSafeKeyboard.tsx b/packages/uniswap/src/components/modals/useBottomSheetSafeKeyboard.tsx new file mode 100644 index 00000000000..9eb0d4a55aa --- /dev/null +++ b/packages/uniswap/src/components/modals/useBottomSheetSafeKeyboard.tsx @@ -0,0 +1,7 @@ +import { NotImplementedError } from 'utilities/src/errors' + +export function useBottomSheetSafeKeyboard(): { + keyboardHeight: number +} { + throw new NotImplementedError('useBottomSheetSafeKeyboard') +} diff --git a/packages/uniswap/src/components/network/NetworkFilter.tsx b/packages/uniswap/src/components/network/NetworkFilter.tsx index 79dd4213136..72a23c54e6a 100644 --- a/packages/uniswap/src/components/network/NetworkFilter.tsx +++ b/packages/uniswap/src/components/network/NetworkFilter.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback } from 'react' import { Flex, HapticFeedback } from 'ui/src' import { Ellipsis } from 'ui/src/components/icons/Ellipsis' import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' @@ -7,21 +7,16 @@ import { SQUARE_BORDER_RADIUS as NETWORK_LOGO_SQUARE_BORDER_RADIUS, NetworkLogo, } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' -import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown' +import { + ActionSheetDropdown, + ActionSheetDropdownStyleProps, +} from 'uniswap/src/components/dropdowns/ActionSheetDropdown' import { useNetworkOptions } from 'uniswap/src/components/network/hooks' import { UniverseChainId, WALLET_SUPPORTED_CHAIN_IDS, WalletChainId } from 'uniswap/src/types/chains' const ELLIPSIS = 'ellipsis' const NETWORK_ICON_SIZE = iconSizes.icon20 const NETWORK_ICON_SHIFT = 8 -// Array of logos to show when "all networks" are visible. Don't want to show all -// logos because there are too many -const NETWORK_LOGOS_TO_SHOW: WalletChainId[] = [ - UniverseChainId.Mainnet, - UniverseChainId.ArbitrumOne, - UniverseChainId.Optimism, - UniverseChainId.Base, -] interface NetworkFilterProps { selectedChain: UniverseChainId | null @@ -29,7 +24,8 @@ interface NetworkFilterProps { onPressAnimation?: () => void onDismiss?: () => void includeAllNetworks?: boolean - showEllipsisInitially?: boolean + styles?: ActionSheetDropdownStyleProps + hideArrow?: boolean } type EllipsisPosition = 'start' | 'end' @@ -85,22 +81,16 @@ export function NetworkFilter({ onPressAnimation, onDismiss, includeAllNetworks, - showEllipsisInitially, + styles, + hideArrow = false, }: NetworkFilterProps): JSX.Element { - // TODO: remove the comment below once we add it to the main swap screen - // we would need this later, when we add it to the main swap screen - const [showEllipsisIcon, setShowEllipsisIcon] = useState(showEllipsisInitially ?? false) - const onPress = useCallback( async (chainId: UniverseChainId | null) => { onPressAnimation?.() await HapticFeedback.selection() - if (showEllipsisIcon && chainId !== selectedChain) { - setShowEllipsisIcon(false) - } onPressChain(chainId) }, - [showEllipsisIcon, selectedChain, onPressChain, onPressAnimation], + [onPressChain, onPressAnimation], ) const networkOptions = useNetworkOptions({ @@ -110,20 +100,25 @@ export function NetworkFilter({ chainIds: WALLET_SUPPORTED_CHAIN_IDS, }) - const networks = useMemo(() => { - return selectedChain ? [selectedChain] : NETWORK_LOGOS_TO_SHOW - }, [selectedChain]) - return ( - <ActionSheetDropdown alignment="right" options={networkOptions} testID="chain-selector" onDismiss={onDismiss}> - <Flex centered row gap="$spacing4"> - <NetworksInSeries - // show ellipsis as the last item when all networks is selected - ellipsisPosition={!selectedChain ? 'end' : undefined} - // show specific network or all - networks={networks} + <ActionSheetDropdown + options={networkOptions} + styles={{ + alignment: 'right', + ...styles, + }} + testID="chain-selector" + onDismiss={onDismiss} + > + <Flex centered row gap="$spacing8"> + <NetworkLogo chainId={selectedChain ?? null} size={NETWORK_ICON_SIZE} /> + <RotatableChevron + color="$neutral3" + direction="down" + display={hideArrow ? 'none' : 'block'} + height={iconSizes.icon20} + width={iconSizes.icon20} /> - <RotatableChevron color="$neutral3" direction="down" height={iconSizes.icon20} width={iconSizes.icon20} /> </Flex> </ActionSheetDropdown> ) diff --git a/packages/uniswap/src/components/network/NetworkOption.tsx b/packages/uniswap/src/components/network/NetworkOption.tsx index 38ca99a0f73..21e09090f0e 100644 --- a/packages/uniswap/src/components/network/NetworkOption.tsx +++ b/packages/uniswap/src/components/network/NetworkOption.tsx @@ -1,8 +1,7 @@ import { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Flex, Text, useSporeColors } from 'ui/src' -import { Check } from 'ui/src/components/icons/Check' -import { Ellipsis } from 'ui/src/components/icons/Ellipsis' +import { CheckmarkCircle } from 'ui/src/components/icons/CheckmarkCircle' import { iconSizes } from 'ui/src/theme' import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' @@ -26,15 +25,7 @@ export function NetworkOption({ if (!info?.label) { content = ( <Flex row gap="$spacing12"> - <Flex - centered - backgroundColor="$neutral3" - borderRadius={6} - height={NETWORK_OPTION_ICON_SIZE} - width={NETWORK_OPTION_ICON_SIZE} - > - <Ellipsis color={colors.sporeWhite.val} size="$icon.12" /> - </Flex> + <NetworkLogo chainId={null} size={NETWORK_OPTION_ICON_SIZE} /> <Text color="$neutral1" variant="body2"> {t('transaction.network.all')} </Text> @@ -57,7 +48,7 @@ export function NetworkOption({ <Flex row alignItems="center" justifyContent="space-between" px="$spacing8" py={10}> {content} <Flex centered height={NETWORK_OPTION_ICON_SIZE} width={NETWORK_OPTION_ICON_SIZE}> - {currentlySelected && <Check color={colors.neutral1.get()} size={iconSizes.icon20} />} + {currentlySelected && <CheckmarkCircle color={colors.neutral1.get()} size={iconSizes.icon20} />} </Flex> </Flex> ) diff --git a/packages/uniswap/src/components/network/__snapshots__/NetworkPill.test.tsx.snap b/packages/uniswap/src/components/network/__snapshots__/NetworkPill.test.tsx.snap index f7756ae12b6..20f624dd26f 100644 --- a/packages/uniswap/src/components/network/__snapshots__/NetworkPill.test.tsx.snap +++ b/packages/uniswap/src/components/network/__snapshots__/NetworkPill.test.tsx.snap @@ -123,7 +123,7 @@ exports[`NetworkPill renders an InlineNetworkPill 1`] = ` { "color": "#209853", "fontFamily": "Basel-Medium", - "fontSize": 14, + "fontSize": 12, "fontWeight": "500", "lineHeight": 16, "paddingTop": 1, diff --git a/packages/uniswap/src/components/text/__snapshots__/LearnMoreLink.test.tsx.snap b/packages/uniswap/src/components/text/__snapshots__/LearnMoreLink.test.tsx.snap index 7d8d766509c..9d820d913e8 100644 --- a/packages/uniswap/src/components/text/__snapshots__/LearnMoreLink.test.tsx.snap +++ b/packages/uniswap/src/components/text/__snapshots__/LearnMoreLink.test.tsx.snap @@ -33,9 +33,9 @@ exports[`LearnMoreLink renders without error 1`] = ` { "color": "#FC72FF", "fontFamily": "Basel-Medium", - "fontSize": 16, + "fontSize": 14, "fontWeight": "500", - "lineHeight": 24, + "lineHeight": 20, } } suppressHighlighting={true} diff --git a/packages/uniswap/src/config.ts b/packages/uniswap/src/config.ts index 9ff6f04fdc1..e94991e63f8 100644 --- a/packages/uniswap/src/config.ts +++ b/packages/uniswap/src/config.ts @@ -2,13 +2,21 @@ import { APPSFLYER_API_KEY, APPSFLYER_APP_ID, + DATADOG_CLIENT_TOKEN, + DATADOG_PROJECT_ID, FIREBASE_APP_CHECK_DEBUG_TOKEN, INFURA_KEY, - INFURA_PROJECT_ID, ONESIGNAL_APP_ID, + OPENAI_API_KEY, QUICKNODE_ARBITRUM_RPC_URL, + QUICKNODE_AVAX_RPC_URL, + QUICKNODE_BASE_RPC_URL, + QUICKNODE_BLAST_RPC_URL, QUICKNODE_BNB_RPC_URL, + QUICKNODE_CELO_RPC_URL, QUICKNODE_MAINNET_RPC_URL, + QUICKNODE_OP_RPC_URL, + QUICKNODE_POLYGON_RPC_URL, QUICKNODE_ZKSYNC_RPC_URL, QUICKNODE_ZORA_RPC_URL, SENTRY_DSN, @@ -24,17 +32,25 @@ import { isNonJestDev } from 'utilities/src/environment/constants' export interface Config { appsflyerApiKey: string appsflyerAppId: string + datadogClientToken: string + datadogProjectId: string uniswapApiKey: string infuraKey: string - infuraProjectId: string onesignalAppId: string + openaiApiKey: string sentryDsn: string simpleHashApiKey: string simpleHashApiUrl: string statSigProxyUrl: string walletConnectProjectId: string quicknodeArbitrumRpcUrl: string + quicknodeAvaxRpcUrl: string + quicknodeBaseRpcUrl: string + quicknodeBlastRpcUrl: string quicknodeBnbRpcUrl: string + quicknodeCeloRpcUrl: string + quicknodeOpRpcUrl: string + quicknodePolygonRpcUrl: string quicknodeZoraRpcUrl: string quicknodeZkSyncRpcUrl: string quicknodeMainnetRpcUrl: string @@ -55,24 +71,38 @@ export interface Config { const _config: Config = { appsflyerApiKey: process.env.APPSFLYER_API_KEY || APPSFLYER_API_KEY, appsflyerAppId: process.env.APPSFLYER_APP_ID || APPSFLYER_APP_ID, + datadogClientToken: process.env.DATADOG_CLIENT_TOKEN || DATADOG_CLIENT_TOKEN, + datadogProjectId: process.env.DATADOG_PROJECT_ID || DATADOG_PROJECT_ID, uniswapApiKey: process.env.UNISWAP_API_KEY || UNISWAP_API_KEY, infuraKey: process.env.REACT_APP_INFURA_KEY || INFURA_KEY, - infuraProjectId: process.env.INFURA_PROJECT_ID || INFURA_PROJECT_ID, onesignalAppId: process.env.ONESIGNAL_APP_ID || ONESIGNAL_APP_ID, + openaiApiKey: process.env.OPENAI_API_KEY || OPENAI_API_KEY, sentryDsn: process.env.REACT_APP_SENTRY_DSN || process.env.SENTRY_DSN || SENTRY_DSN, simpleHashApiKey: process.env.SIMPLEHASH_API_KEY || SIMPLEHASH_API_KEY, simpleHashApiUrl: process.env.SIMPLEHASH_API_URL || SIMPLEHASH_API_URL, statSigProxyUrl: process.env.REACT_APP_STATSIG_PROXY_URL || process.env.STATSIG_PROXY_URL || STATSIG_PROXY_URL, walletConnectProjectId: process.env.REACT_APP_WALLET_CONNECT_PROJECT_ID || process.env.WALLETCONNECT_PROJECT_ID || WALLETCONNECT_PROJECT_ID, - quicknodeArbitrumRpcUrl: process.env.REACT_APP_QUICKNODE_ARBITRUM_RPC_URL || QUICKNODE_ARBITRUM_RPC_URL, + quicknodeArbitrumRpcUrl: + process.env.REACT_APP_QUICKNODE_ARBITRUM_RPC_URL || + process.env.QUICKNODE_ARBITRUM_RPC_URL || + QUICKNODE_ARBITRUM_RPC_URL, + quicknodeAvaxRpcUrl: process.env.QUICKNODE_AVAX_RPC_URL || QUICKNODE_AVAX_RPC_URL, + quicknodeBaseRpcUrl: process.env.QUICKNODE_BASE_RPC_URL || QUICKNODE_BASE_RPC_URL, + quicknodeBlastRpcUrl: process.env.QUICKNODE_BLAST_RPC_URL || QUICKNODE_BLAST_RPC_URL, quicknodeBnbRpcUrl: process.env.REACT_APP_QUICKNODE_BNB_RPC_URL || process.env.QUICKNODE_BNB_RPC_URL || QUICKNODE_BNB_RPC_URL, + quicknodeCeloRpcUrl: process.env.QUICKNODE_CELO_RPC_URL || QUICKNODE_CELO_RPC_URL, + quicknodeOpRpcUrl: process.env.QUICKNODE_OP_RPC_URL || QUICKNODE_OP_RPC_URL, + quicknodePolygonRpcUrl: process.env.QUICKNODE_POLYGON_RPC_URL || QUICKNODE_POLYGON_RPC_URL, quicknodeZoraRpcUrl: process.env.REACT_APP_QUICKNODE_ZORA_RPC_URL || process.env.QUICKNODE_ZORA_RPC_URL || QUICKNODE_ZORA_RPC_URL, quicknodeZkSyncRpcUrl: process.env.REACT_APP_QUICKNODE_ZKSYNC_RPC_URL || process.env.QUICKNODE_ZKSYNC_RPC_URL || QUICKNODE_ZKSYNC_RPC_URL, - quicknodeMainnetRpcUrl: process.env.REACT_APP_QUICKNODE_MAINNET_RPC_URL || QUICKNODE_MAINNET_RPC_URL, + quicknodeMainnetRpcUrl: + process.env.REACT_APP_QUICKNODE_MAINNET_RPC_URL || + process.env.QUICKNODE_MAINNET_RPC_URL || + QUICKNODE_MAINNET_RPC_URL, tradingApiKey: process.env.TRADING_API_KEY || TRADING_API_KEY, firebaseAppCheckDebugToken: process.env.FIREBASE_APP_CHECK_DEBUG_TOKEN || FIREBASE_APP_CHECK_DEBUG_TOKEN, } diff --git a/packages/uniswap/src/constants/chains.ts b/packages/uniswap/src/constants/chains.ts index 5c20c5e3201..6f03f7fce44 100644 --- a/packages/uniswap/src/constants/chains.ts +++ b/packages/uniswap/src/constants/chains.ts @@ -122,6 +122,9 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { [RPCType.Private]: { http: ['https://rpc.mevblocker.io/?referrer=uniswapwallet'], }, + [RPCType.Public]: { + http: [config.quicknodeMainnetRpcUrl], + }, default: { http: ['https://cloudflare-eth.com'], }, @@ -317,6 +320,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { supportsGasEstimates: true, urlParam: 'arbitrum', rpcUrls: { + [RPCType.Public]: { http: [config.quicknodeArbitrumRpcUrl] }, default: { http: ['https://arb1.arbitrum.io/rpc'] }, fallback: { http: ['https://arbitrum.public-rpc.com'] }, appOnly: { @@ -423,6 +427,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { networkLayer: NetworkLayer.L2, pendingTransactionsRetryOptions: DEFAULT_RETRY_OPTIONS, rpcUrls: { + [RPCType.Public]: { http: [config.quicknodeOpRpcUrl] }, [RPCType.PublicAlt]: { http: ['https://mainnet.optimism.io'] }, default: { http: ['https://mainnet.optimism.io/'] }, fallback: { http: ['https://rpc.ankr.com/optimism'] }, @@ -481,7 +486,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { supportsGasEstimates: true, urlParam: 'base', rpcUrls: { - [RPCType.Public]: { http: ['https://mainnet.base.org'] }, + [RPCType.Public]: { http: [config.quicknodeBaseRpcUrl] }, default: { http: ['https://mainnet.base.org/'] }, fallback: { http: ['https://1rpc.io/base', 'https://base.meowrpc.com'] }, appOnly: { http: [`https://base-mainnet.infura.io/v3/${config.infuraKey}`] }, @@ -644,6 +649,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { networkLayer: NetworkLayer.L1, pendingTransactionsRetryOptions: undefined, rpcUrls: { + [RPCType.Public]: { http: [config.quicknodePolygonRpcUrl] }, [RPCType.PublicAlt]: { http: ['https://polygon-rpc.com/'] }, default: { http: ['https://polygon-rpc.com/'] }, appOnly: { http: [`https://polygon-mainnet.infura.io/v3/${config.infuraKey}`] }, @@ -758,7 +764,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { address: DEFAULT_NATIVE_ADDRESS, }, rpcUrls: { - [RPCType.Public]: { http: ['https://rpc.blast.io'] }, + [RPCType.Public]: { http: [config.quicknodeBlastRpcUrl] }, default: { http: ['https://rpc.blast.io/'] }, appOnly: { http: [`https://blast-mainnet.infura.io/v3/${config.infuraKey}`] }, }, @@ -802,7 +808,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { networkLayer: NetworkLayer.L1, pendingTransactionsRetryOptions: undefined, rpcUrls: { - [RPCType.Public]: { http: ['https://api.avax.network/ext/bc/C/rpc'] }, + [RPCType.Public]: { http: [config.quicknodeAvaxRpcUrl] }, default: { http: ['https://api.avax.network/ext/bc/C/rpc'] }, appOnly: { http: [`https://avalanche-mainnet.infura.io/v3/${config.infuraKey}`] }, }, @@ -863,7 +869,7 @@ export const UNIVERSE_CHAIN_INFO: Record<UniverseChainId, UniverseChainInfo> = { supportsGasEstimates: true, urlParam: 'celo', rpcUrls: { - [RPCType.Public]: { http: ['https://forno.celo.org'] }, + [RPCType.Public]: { http: [config.quicknodeCeloRpcUrl] }, default: { http: [`https://forno.celo.org`] }, appOnly: { http: [`https://celo-mainnet.infura.io/v3/${config.infuraKey}`] }, }, diff --git a/packages/uniswap/src/constants/urls.ts b/packages/uniswap/src/constants/urls.ts index 29b286b8613..260b7bdab0f 100644 --- a/packages/uniswap/src/constants/urls.ts +++ b/packages/uniswap/src/constants/urls.ts @@ -27,7 +27,6 @@ export const uniswapUrls = { helpArticleUrls: { approvalsExplainer: `${helpUrl}/articles/8120520483085-What-is-an-approval-transaction`, extensionHelp: `${helpUrl}/categories/25219141467405`, - extensionWaitlist: `${helpUrl}/articles/24458735271181-Get-started-with-the-Uniswap-Extension`, extensionDappTroubleshooting: `${helpUrl}/articles/25811698471565-Connecting-Uniswap-Extension-Beta-to-other-dapps`, feeOnTransferHelp: `${helpUrl}/articles/18673568523789-What-is-a-token-fee-`, howToSwapTokens: `${helpUrl}/articles/8370549680909-How-to-swap-tokens-`, @@ -57,8 +56,6 @@ export const uniswapUrls = { }, termsOfServiceUrl: 'https://uniswap.org/terms-of-service', privacyPolicyUrl: 'https://uniswap.org/privacy-policy', - // TODO(EXT-668): Remove this after beta launch - extensionFeedbackFormUrl: 'https://forms.gle/Znf6nDRa9PMp4BAJ7', chromeExtension: 'http://uniswap.org/ext', // Core API Urls diff --git a/packages/uniswap/src/data/cache.ts b/packages/uniswap/src/data/cache.ts index c71431d316a..26897b5f069 100644 --- a/packages/uniswap/src/data/cache.ts +++ b/packages/uniswap/src/data/cache.ts @@ -77,7 +77,7 @@ export function setupWalletCache(): InMemoryCache { } : {}), - // Ignore `valueModifiers` when caching `portfolios`. + // Ignore `valueModifiers` and `onRampAuth` when caching `portfolios`. // IMPORTANT: This assumes that `valueModifiers` are always the same when querying `portfolios` across the entire app. portfolios: { keyArgs: ['chains', 'ownerAddresses'], diff --git a/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql b/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql index e995d51cedb..611d409ec37 100644 --- a/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql +++ b/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql @@ -680,6 +680,7 @@ query TransactionList($address: String!, $onRampAuth: OnRampTransactionsAuth) { onRampTransfer { id transactionReferenceId + externalSessionId token { id symbol @@ -779,6 +780,7 @@ query TransactionList($address: String!, $onRampAuth: OnRampTransactionsAuth) { ... on OnRampTransfer { id transactionReferenceId + externalSessionId token { id symbol @@ -1101,22 +1103,90 @@ fragment TopTokenParts on Token { } } -query ExploreTokensTab($topTokensOrderBy: TokenSortableField!) { +query ExploreTokensTab($topTokensOrderBy: TokenSortableField!, $chain: Chain!, $pageSize: Int!) { topTokens( - chain: ETHEREUM + chain: $chain page: 1 - pageSize: 100 + pageSize: $pageSize orderBy: $topTokensOrderBy ) { ...TopTokenParts } # `topTokens` returns WETH rather than ETH # here we retrieve ETH information to swap out in the UI - eth: token(address: null, chain: ETHEREUM) { + eth: token(address: null, chain: $chain) { ...TopTokenParts } } +fragment AITopTokenParts on Token { + symbol + chain + address + market { + totalValueLocked { + value + } + volume(duration: DAY) { + value + } + } + project { + name + markets(currencies: [USD]) { + price { + value + } + pricePercentChange24h { + value + } + marketCap { + value + } + } + } +} + +query AITopTokens($topTokensOrderBy: TokenSortableField!, $chain: Chain!, $pageSize: Int!) { + topTokens( + chain: $chain + page: 1 + pageSize: $pageSize + orderBy: $topTokensOrderBy + ) { + ...AITopTokenParts + } +} + +fragment HomeScreenTokenParts on Token { + symbol + chain + address + project { + id + name + logoUrl + markets(currencies: [USD]) { + id + price { + value + } + pricePercentChange24h { + value + } + } + } +} + +query HomeScreenTokens($contracts: [ContractInput!]!, $chain: Chain!) { + tokens(contracts: $contracts) { + ...HomeScreenTokenParts + } + eth: token(address: null, chain: $chain) { + ...HomeScreenTokenParts + } +} + query FavoriteTokenCard($chain: Chain!, $address: String) { token(chain: $chain, address: $address) { symbol diff --git a/packages/uniswap/src/data/graphql/uniswap-data-api/web/portfolios.graphql b/packages/uniswap/src/data/graphql/uniswap-data-api/web/portfolios.graphql index 5d78ea56bf8..fd4bdc70230 100644 --- a/packages/uniswap/src/data/graphql/uniswap-data-api/web/portfolios.graphql +++ b/packages/uniswap/src/data/graphql/uniswap-data-api/web/portfolios.graphql @@ -4,6 +4,7 @@ fragment QuickTokenBalanceParts on TokenBalance { denominatedValue { id value + currency } token { id diff --git a/packages/uniswap/src/data/tradingApi/api.json b/packages/uniswap/src/data/tradingApi/api.json new file mode 100644 index 00000000000..ae9b9544244 --- /dev/null +++ b/packages/uniswap/src/data/tradingApi/api.json @@ -0,0 +1 @@ +{"openapi":"3.0.0","servers":[{"description":"Uniswap trading APIs Beta","url":"https://beta.trade-api.gateway.uniswap.org/v1"},{"description":"Uniswap trading APIs","url":"https://trade-api.gateway.uniswap.org/v1"}],"info":{"version":"1.0.0","title":"Token Trading","description":"Uniswap trading APIs for fungible tokens."},"paths":{"/check_approval":{"post":{"tags":["Approval"],"summary":"Check if token approval is required","description":"Checks if the swapper has the required approval. If the swapper does not have the required approval, then the response will include the transaction to approve the token. If the swapper has the required approval, then the response will be empty. If the parameter `includeGasInfo` is set to `true`, then the response will include the gas fee for the approval transaction.","operationId":"check_approval","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/ApprovalSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/ApprovalNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/quote":{"post":{"tags":["Quote"],"summary":"Get a quote","description":"Get a quote according to the provided configuration. Optionally adds a fee to the quote according to the API key being used. The fee is **ALWAYS** taken from the output token. If there is a fee and the trade is `EXACT_INPUT`, then the output amount will **NOT** include the fee subtraction. For `EXACT_INPUT` swaps, use `portionBips` to calculate the fee from the quoted amount. If there is a fee and the trade is `EXACT_OUTPUT`, then the input amount will **NOT** include the fee addition to account for the fee. For `EXACT_OUTPUT` swaps, use `portionAmount` to get the fee. \n \n We also support Wrapping and Unwrapping of native tokens on their respective chains. Wrapping and Unwrapping only works for when `routingPreference` is `CLASSIC`, `BEST_PRICE`, or `BEST_PRICE_V2`. We do not support `UNISWAPX` or `UNISWAPX_V2` for these actions.","operationId":"aggregator_quote","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/QuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/order":{"post":{"tags":["Order"],"summary":"Create a gasless order","description":"Submits a new gasless encoded order. The order will be validated and if valid, will be submitted to the filler network. The network will try to fill the order at the quoted `startAmount`, and if not, the amount will start decaying until the `endAmount` is reached. While the order is within `decayEndTime`, the `orderStatus` is `open`. If the order does not get filled after the `decayEndTime` has passed, that is reflected in the `expired` `orderStatus`. then The order will be filled at the best price possible. Once the order is filled, `orderStatus` becomes `filled`.","operationId":"post_dutch_order","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderRequest"}}}},"responses":{"201":{"$ref":"#/components/responses/OrderSuccess201"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/orders":{"get":{"tags":["Order"],"summary":"Get gasless orders","description":"Retrieve gasless orders filtered by query param(s). Some fields on the order can be used as query param.","operationId":"get_dutch_order","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/orderIdParam"},{"$ref":"#/components/parameters/orderIdsParam"},{"$ref":"#/components/parameters/limitParam"},{"$ref":"#/components/parameters/orderStatusParam"},{"$ref":"#/components/parameters/swapperParam"},{"$ref":"#/components/parameters/sortKeyParam"},{"$ref":"#/components/parameters/sortParam"},{"$ref":"#/components/parameters/fillerParam"},{"$ref":"#/components/parameters/cursorParam"}],"responses":{"200":{"$ref":"#/components/responses/OrdersSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/OrdersNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swap":{"post":{"tags":["Swap"],"summary":"Create swap calldata","description":"Create the calldata for a swap transaction (including wrap/unwrap) against the Uniswap Protocols. If the `quote` parameter includes the fee parameters, then the calldata will include the fee disbursement. The gas estimates will be **more precise** when the the response calldata would be valid if submitted on-chain.","operationId":"create_swap_transaction","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateSwapSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/SwapUnauthorized401"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swap/{txHash}":{"get":{"tags":["Swap"],"summary":"Get swap status","description":"Get the status of a swap transaction.","operationId":"get_swap_transaction","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/transactionHashParam"}],"responses":{"200":{"$ref":"#/components/responses/GetSwapSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/indicative_quote":{"post":{"tags":["IndicativeQuote"],"summary":"Get an indicative quote","description":"Get an indicative quote according to the provided configuration. The quote will not include a fee.","operationId":"indicative_quote","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/IndicativeQuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/send":{"post":{"tags":["Send"],"summary":"Create send calldata","description":"Create the calldata for a send transaction.","operationId":"create_send","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateSendSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/SendNotFound404"},"429":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}}},"components":{"responses":{"OrdersSuccess200":{"description":"The request orders matching the query parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetOrdersResponse"}}}},"OrderSuccess201":{"description":"Encoded order submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderResponse"}}}},"QuoteSuccess200":{"description":"Quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"ApprovalSuccess200":{"description":"Check approval successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalResponse"}}}},"CreateSendSuccess200":{"description":"Create send successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendResponse"}}}},"CreateSwapSuccess200":{"description":"Create swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapResponse"}}}},"GetSwapSuccess200":{"description":"Get swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSwapResponse"}}}},"BadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"ApprovalUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"ApprovalNotFound404":{"description":"ResourceNotFound eg. Token allowance not found or Gas info not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"Unauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"QuoteNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SendNotFound404":{"description":"ResourceNotFound eg. Gas fee not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SwapBadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"SwapUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked or Fee is not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"SwapNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"OrdersNotFound404":{"description":"Orders not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"RateLimitedErr429":{"description":"Ratelimited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err429"}}}},"InternalErr500":{"description":"Unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err500"}}}},"Timeout504":{"description":"Request duration limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err504"}}}},"IndicativeQuoteSuccess200":{"description":"Indicative quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteResponse"}}}}},"schemas":{"NullablePermit":{"allOf":[{"$ref":"#/components/schemas/Permit"},{"type":"object","nullable":true}]},"TokenAmount":{"type":"string"},"SwapStatus":{"type":"string","enum":["pending","success","error"]},"GetSwapResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"status":{"$ref":"#/components/schemas/SwapStatus"}},"required":["requestId","status"]},"CreateSwapRequest":{"type":"object","description":"The parameters **signature** and **permitData** should only be included if *permitData* was returned from **/quote**.","properties":{"quote":{"oneOf":[{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"}]},"signature":{"type":"string","description":"The signed permit."},"includeGasInfo":{"type":"boolean","default":false,"deprecated":true,"description":"Use `refreshGasPrice` instead."},"refreshGasPrice":{"type":"boolean","default":false,"description":"If true, the gas price will be re-fetched from the network."},"simulateTransaction":{"type":"boolean","default":false,"description":"If true, the transaction will be simulated. If the simulation results on an onchain error, endpoint will return an error."},"permitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"safetyMode":{"$ref":"#/components/schemas/SwapSafetyMode"},"deadline":{"type":"integer","description":"The deadline for the swap in unix timestamp format. If the deadline is not defined OR in the past then the default deadline is 30 minutes."},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["quote"]},"CreateSendRequest":{"type":"object","properties":{"sender":{"$ref":"#/components/schemas/Address"},"recipient":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["sender","recipient","token","amount"]},"Address":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{40}$"},"ClassicGasUseEstimateUSD":{"description":"The gas fee you would pay if you opted for a CLASSIC swap over a Uniswap X order in terms of USD.","type":"string"},"CreateSwapResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"swap":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}},"required":["requestId","swap"]},"CreateSendResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"send":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"},"gasFeeUSD":{"type":"number"}},"required":["requestId","send"]},"QuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"quote":{"$ref":"#/components/schemas/Quote"},"routing":{"$ref":"#/components/schemas/Routing"},"permitData":{"$ref":"#/components/schemas/NullablePermit"}},"required":["routing","quote","permitData","requestId"]},"QuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"},"swapper":{"$ref":"#/components/schemas/Address"},"slippageTolerance":{"description":"For **Classic** swaps, the slippage tolerance is the maximum amount the price can change between the time the transaction is submitted and the time it is executed. The slippage tolerance is represented as a percentage of the total value of the swap. \n\n Slippage tolerance works differently in **DutchLimit** swaps, it does not set a limit on the Spread in an order. See [here](https://uniswap-docs.readme.io/reference/faqs#why-do-the-uniswapx-quotes-have-more-slippage-than-the-tolerance-i-set) for more information. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","type":"number"},"autoSlippage":{"$ref":"#/components/schemas/AutoSlippage"},"routingPreference":{"$ref":"#/components/schemas/RoutingPreference"},"protocols":{"$ref":"#/components/schemas/Protocols"},"spreadOptimization":{"$ref":"#/components/schemas/SpreadOptimization"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut","swapper"]},"GetOrdersResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orders":{"type":"array","items":{"$ref":"#/components/schemas/UniswapXOrder"}},"cursor":{"type":"string"}},"required":["orders","requestId"]},"OrderResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orderId":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"}},"required":["requestId","orderId","orderStatus"]},"OrderRequest":{"type":"object","properties":{"signature":{"type":"string","description":"The signed permit."},"quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"}]},"routing":{"$ref":"#/components/schemas/Routing"}},"required":["signature","quote"]},"Urgency":{"type":"string","enum":["normal","fast","urgent"],"description":"The urgency determines the urgency of the transaction. The default value is `urgent`.","default":"urgent"},"Protocols":{"type":"array","items":{"$ref":"#/components/schemas/ProtocolItems"},"description":"The protocols to use for the swap/order. If the `protocols` field is defined, then you can only set the `routingPreference` to `BEST_PRICE`"},"Err400":{"type":"object","properties":{"errorCode":{"default":"RequestValidationError","type":"string"},"detail":{"type":"string"}}},"Err401":{"type":"object","properties":{"errorCode":{"default":"UnauthorizedError","type":"string"},"detail":{"type":"string"}}},"Err404":{"type":"object","properties":{"errorCode":{"default":"ResourceNotFound","type":"string"},"detail":{"type":"string"}}},"Err429":{"type":"object","properties":{"errorCode":{"default":"Ratelimited","type":"string"},"detail":{"type":"string"}}},"Err500":{"type":"object","properties":{"errorCode":{"default":"InternalServerError","type":"string"},"detail":{"type":"string"}}},"Err504":{"type":"object","properties":{"errorCode":{"default":"Timeout","type":"string"},"detail":{"type":"string"}}},"ChainId":{"type":"number","enum":[1,10,56,137,8453,42161,81457,43114,42220,7777777,324]},"OrderInput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"}},"required":["token"]},"OrderOutput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"},"isFeeOutput":{"type":"boolean"},"recipient":{"type":"string"}},"required":["token"]},"CosignerData":{"type":"object","properties":{"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"inputOverride":{"type":"string"},"outputOverrides":{"type":"array","items":{"type":"string"}}}},"SettledAmount":{"type":"object","properties":{"tokenOut":{"$ref":"#/components/schemas/Address"},"amountOut":{"type":"string"},"tokenIn":{"$ref":"#/components/schemas/Address"},"amountIn":{"type":"string"}}},"OrderType":{"type":"string","enum":["DutchLimit","Dutch","Dutch_V2"]},"UniswapXOrder":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/OrderType"},"encodedOrder":{"type":"string"},"signature":{"type":"string"},"nonce":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"},"orderId":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"quoteId":{"type":"string"},"swapper":{"type":"string"},"txHash":{"type":"string"},"input":{"$ref":"#/components/schemas/OrderInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/OrderOutput"}},"settledAmounts":{"type":"array","items":{"$ref":"#/components/schemas/SettledAmount"}},"cosignature":{"type":"string"},"cosignerData":{"$ref":"#/components/schemas/CosignerData"}},"required":["encodedOrder","signature","nonce","orderId","orderStatus","chainId","type"]},"SortKey":{"type":"string","enum":["createdAt"]},"OrderId":{"type":"string"},"OrderIds":{"type":"string"},"OrderStatus":{"type":"string","enum":["open","expired","error","cancelled","filled","unverified","insufficient-funds"]},"Permit":{"type":"object","properties":{"domain":{"type":"object"},"values":{"type":"object"},"types":{"type":"object"}}},"DutchInput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"}},"required":["startAmount","endAmount","type"]},"DutchOutput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"},"recipient":{"type":"string"}},"required":["startAmount","endAmount","token","recipient"]},"DutchOrderInfo":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"exclusivityOverrideBps":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchOrderInfoV2":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}},"cosigner":{"$ref":"#/components/schemas/Address"}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchQuote":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfo"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"DutchQuoteV2":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfoV2"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"deadlineBufferSecs":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"TradeType":{"type":"string","enum":["EXACT_INPUT","EXACT_OUTPUT"]},"Routing":{"type":"string","enum":["DUTCH_LIMIT","CLASSIC","DUTCH_V2"]},"Quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"}]},"ApprovalRequest":{"type":"object","properties":{"walletAddress":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"},"includeGasInfo":{"type":"boolean","default":false},"tokenOut":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{40}$","description":"relevant for if we go from a wrapped token to a native token (unwrapping)"},"tokenOutChainId":{"type":"number","enum":[1,10,56,137,8453,42161,81457,43114,42220,7777777,324],"default":1,"description":"relevant for if we go from a wrapped token to a native token (unwrapping)"}},"required":["walletAddress","token","amount"]},"ApprovalResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"approval":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}},"required":["requestId","approval"]},"ClassicQuote":{"type":"object","properties":{"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"swapper":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"slippage":{"type":"number"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei. It does NOT include the additional gas for token approvals."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD. It does NOT include the additional gas for token approvals."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency. It does NOT include the additional gas for token approvals."},"route":{"type":"array","items":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/V3PoolInRoute"},{"$ref":"#/components/schemas/V2PoolInRoute"}]}}},"portionBips":{"type":"number","description":"The portion of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionAmount":{"type":"string","description":"The amount of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionRecipient":{"$ref":"#/components/schemas/Address"},"routeString":{"type":"string","description":"The route in string format."},"quoteId":{"type":"string","description":"The quote id. Used for analytics purposes."},"gasUseEstimate":{"type":"string","description":"The estimated gas use. It does NOT include the additional gas for token approvals."},"blockNumber":{"type":"string","description":"The current block number."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."},"txFailureReasons":{"type":"array","items":{"$ref":"#/components/schemas/TransactionFailureReason"}},"priceImpact":{"type":"number","description":"The impact the trade has on the market price of the pool, between 0-100 percent"}}},"WrapUnwrapQuote":{"type":"object","properties":{"swapper":{"$ref":"#/components/schemas/Address"},"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"chainId":{"$ref":"#/components/schemas/ChainId"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency."},"gasUseEstimate":{"type":"string","description":"The estimated gas use."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."}}},"TokenInRoute":{"type":"object","properties":{"address":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"symbol":{"type":"string"},"decimals":{"type":"string"},"buyFeeBps":{"type":"string"},"sellFeeBps":{"type":"string"}}},"V2Reserve":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/TokenInRoute"},"quotient":{"type":"string"}}},"V2PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v2-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"reserve0":{"$ref":"#/components/schemas/V2Reserve"},"reserve1":{"$ref":"#/components/schemas/V2Reserve"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"V3PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v3-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"sqrtRatioX96":{"type":"string"},"liquidity":{"type":"string"},"tickCurrent":{"type":"string"},"fee":{"type":"string"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"TransactionHash":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{64}$"},"ClassicInput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"}}},"ClassicOutput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"},"recipient":{"$ref":"#/components/schemas/Address"}}},"RequestId":{"type":"string"},"SpreadOptimization":{"type":"string","enum":["EXECUTION","PRICE"],"description":"For **Dutch Limit** orders only. When set to `EXECUTION`, quotes optimize for looser spreads at higher fill rates. When set to `PRICE`, quotes optimize for tighter spreads at lower fill rates","default":"EXECUTION"},"AutoSlippage":{"type":"string","enum":["DEFAULT"],"description":"For **Classic** swaps only. The auto slippage strategy to employ. If auto slippage is not defined then we don't compute it. If the auto slippage strategy is `DEFAULT`, then the swap will use the default slippage tolerance computation. You cannot define auto slippage and slippage tolerance at the same time. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","default":"undefined"},"RoutingPreference":{"type":"string","description":"The routing preference determines which protocol to use for the swap. If the routing preference is `UNISWAPX`, then the swap will be routed through the UniswapX Dutch Auction Protocol. If the routing preference is `CLASSIC`, then the swap will be routed through the Classic Protocol. If the routing preference is `BEST_PRICE`, then the swap will be routed through the protocol that provides the best price. When `UNIXWAPX_V2` is passed, the swap will be routed through the UniswapX V2 Dutch Auction Protocol. When `V3_ONLY` is passed, the swap will be routed ONLY through the Uniswap V3 Protocol. When `V2_ONLY` is passed, the swap will be routed ONLY through the Uniswap V2 Protocol.","enum":["CLASSIC","UNISWAPX","BEST_PRICE","BEST_PRICE_V2","UNISWAPX_V2","V3_ONLY","V2_ONLY"],"default":"BEST_PRICE"},"ProtocolItems":{"type":"string","enum":["V2","V3","UNISWAPX","UNISWAPX_V2"]},"TransactionRequest":{"type":"object","properties":{"to":{"$ref":"#/components/schemas/Address"},"from":{"$ref":"#/components/schemas/Address"},"data":{"type":"string","description":"The calldata for the transaction."},"value":{"type":"string","description":"The value of the transaction in terms of wei in hex format."},"gasLimit":{"type":"string"},"chainId":{"type":"integer"},"maxFeePerGas":{"type":"string"},"maxPriorityFeePerGas":{"type":"string"},"gasPrice":{"type":"string"}},"required":["to","from","data","value","chainId"]},"TransactionFailureReason":{"type":"string","enum":["SIMULATION_ERROR","UNSUPPORTED_SIMULATION"]},"SwapSafetyMode":{"type":"string","enum":["SAFE"],"description":"The safety mode determines the safety level of the swap. If the safety mode is `SAFE`, then the swap will include a SWEEP for the native token."},"IndicativeQuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut"]},"IndicativeQuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"input":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"output":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"type":{"$ref":"#/components/schemas/TradeType"}},"required":["requestId","input","output","type"]},"IndicativeQuoteToken":{"type":"object","properties":{"amount":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"token":{"$ref":"#/components/schemas/Address"}}}},"parameters":{"addressParam":{"name":"address","in":"path","schema":{"$ref":"#/components/schemas/Address"},"required":true},"tokenIdParam":{"name":"tokenId","in":"path","schema":{"type":"string"},"required":true},"cursorParam":{"name":"cursor","in":"query","schema":{"type":"string"},"required":false},"limitParam":{"name":"limit","in":"query","schema":{"type":"number"},"required":false},"chainParam":{"name":"chain","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"addressPathParam":{"name":"address","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"orderStatusParam":{"name":"orderStatus","in":"query","description":"Filter by order status.","required":false,"schema":{"$ref":"#/components/schemas/OrderStatus"}},"orderIdParam":{"name":"orderId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/OrderId"}},"orderIdsParam":{"name":"orderIds","in":"query","required":false,"description":"ids split by commas","schema":{"$ref":"#/components/schemas/OrderIds"}},"swapperParam":{"name":"swapper","in":"query","description":"Filter by swapper address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"fillerParam":{"name":"filler","in":"query","description":"Filter by filler address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"sortKeyParam":{"name":"sortKey","in":"query","description":"Order the query results by the sort key.","required":false,"schema":{"$ref":"#/components/schemas/SortKey"}},"sortParam":{"name":"sort","in":"query","description":"Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`.","required":false,"schema":{"type":"string"}},"descParam":{"description":"Sort query results by sortKey in descending order.","name":"desc","in":"query","required":false,"schema":{"type":"string"}},"transactionHashParam":{"description":"The transaction hash.","name":"txHash","in":"path","required":true,"schema":{"$ref":"#/components/schemas/TransactionHash"}}},"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"x-api-key"}}},"security":[{"apiKey":[]}]} \ No newline at end of file diff --git a/packages/uniswap/src/features/dataApi/balances.ts b/packages/uniswap/src/features/dataApi/balances.ts new file mode 100644 index 00000000000..b4ef0d36860 --- /dev/null +++ b/packages/uniswap/src/features/dataApi/balances.ts @@ -0,0 +1,432 @@ +import { NetworkStatus, Reference, useApolloClient, WatchQueryFetchPolicy } from '@apollo/client' +import { useCallback, useMemo } from 'react' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { + IAmount, + PortfolioBalanceDocument, + PortfolioBalancesQuery, + PortfolioValueModifier, + usePortfolioBalancesQuery, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { GqlResult } from 'uniswap/src/data/types' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { CurrencyInfo, PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { buildCurrency, usePersistedError } from 'uniswap/src/features/dataApi/utils' +import { CurrencyId } from 'uniswap/src/types/currency' +import { currencyId } from 'uniswap/src/utils/currencyId' +import { usePlatformBasedFetchPolicy } from 'uniswap/src/utils/usePlatformBasedFetchPolicy' +import { logger } from 'utilities/src/logger/logger' + +export type SortedPortfolioBalances = { + balances: PortfolioBalance[] + hiddenBalances: PortfolioBalance[] +} + +export type PortfolioTotalValue = { + balanceUSD: number | undefined + percentChange: number | undefined + absoluteChangeUSD: number | undefined +} + +export type PortfolioCacheUpdater = (hidden: boolean, portfolioBalance?: PortfolioBalance) => void + +/** + * Returns all balances indexed by checksummed currencyId for a given address + * @param address + * @param pollInterval optional `PollingInterval` representing polling frequency. + * If undefined, will query once and not poll. + * NOTE: + * on TokenDetails, useBalances relies rely on usePortfolioBalances but don't need + * polling versions of it. Including polling was causing multiple polling intervals + * to be kicked off with usePortfolioBalances. + * Same with on Token Selector's TokenSearchResultList, since the home screen + * has a usePortfolioBalances polling hook, we don't need to duplicate the + * polling interval when token selector is open + * @param onCompleted + * @param fetchPolicy + * @param valueModifiers + * @returns + */ +export function usePortfolioBalances({ + address, + pollInterval, + onCompleted, + fetchPolicy, + valueModifiers, +}: { + address?: Address + pollInterval?: PollingInterval + onCompleted?: () => void + fetchPolicy?: WatchQueryFetchPolicy + valueModifiers?: PortfolioValueModifier[] +}): GqlResult<Record<CurrencyId, PortfolioBalance>> & { networkStatus: NetworkStatus } { + const { fetchPolicy: internalFetchPolicy, pollInterval: internalPollInterval } = usePlatformBasedFetchPolicy({ + fetchPolicy, + pollInterval, + }) + + const { + data: balancesData, + loading, + networkStatus, + refetch, + error, + } = usePortfolioBalancesQuery({ + fetchPolicy: internalFetchPolicy, + notifyOnNetworkStatusChange: true, + onCompleted, + pollInterval: internalPollInterval, + variables: address ? { ownerAddress: address, valueModifiers } : undefined, + skip: !address, + }) + + const persistedError = usePersistedError(loading, error) + const balancesForAddress = balancesData?.portfolios?.[0]?.tokenBalances + + const formattedData = useMemo(() => { + if (!balancesForAddress) { + return + } + + const byId: Record<CurrencyId, PortfolioBalance> = {} + balancesForAddress.forEach((balance) => { + const { + __typename: tokenBalanceType, + id: tokenBalanceId, + denominatedValue, + token, + tokenProjectMarket, + quantity, + isHidden, + } = balance || {} + const { address: tokenAddress, chain, decimals, symbol, project } = token || {} + const { name, logoUrl, isSpam, safetyLevel } = project || {} + const chainId = fromGraphQLChain(chain) + + // require all of these fields to be defined + if (!balance || !quantity || !token) { + return + } + + const currency = buildCurrency({ + chainId, + address: tokenAddress, + decimals, + symbol, + name, + }) + + if (!currency) { + return + } + + const id = currencyId(currency) + + const currencyInfo: CurrencyInfo = { + currency, + currencyId: currencyId(currency), + logoUrl, + isSpam, + safetyLevel, + } + + const portfolioBalance: PortfolioBalance = { + cacheId: `${tokenBalanceType}:${tokenBalanceId}`, + quantity, + balanceUSD: denominatedValue?.value, + currencyInfo, + relativeChange24: tokenProjectMarket?.relativeChange24?.value, + isHidden, + } + + byId[id] = portfolioBalance + }) + + return byId + }, [balancesForAddress]) + + const retry = useCallback( + () => refetch({ ownerAddress: address, valueModifiers }), + [address, valueModifiers, refetch], + ) + + return { + data: formattedData, + loading, + networkStatus, + refetch: retry, + error: persistedError, + } +} + +export function usePortfolioTotalValue({ + address, + pollInterval, + onCompleted, + fetchPolicy, + valueModifiers, +}: { + address?: Address + pollInterval?: PollingInterval + onCompleted?: () => void + fetchPolicy?: WatchQueryFetchPolicy + valueModifiers?: PortfolioValueModifier[] +}): GqlResult<PortfolioTotalValue> & { networkStatus: NetworkStatus } { + const { fetchPolicy: internalFetchPolicy, pollInterval: internalPollInterval } = usePlatformBasedFetchPolicy({ + fetchPolicy, + pollInterval, + }) + + const { + data: balancesData, + loading, + networkStatus, + refetch, + error, + } = usePortfolioBalancesQuery({ + fetchPolicy: internalFetchPolicy, + notifyOnNetworkStatusChange: true, + onCompleted, + pollInterval: internalPollInterval, + variables: address ? { ownerAddress: address, valueModifiers } : undefined, + skip: !address, + }) + + const persistedError = usePersistedError(loading, error) + const portfolioForAddress = balancesData?.portfolios?.[0] + + const formattedData = useMemo(() => { + if (!portfolioForAddress) { + return + } + + return { + balanceUSD: portfolioForAddress?.tokensTotalDenominatedValue?.value, + percentChange: portfolioForAddress?.tokensTotalDenominatedValueChange?.percentage?.value, + absoluteChangeUSD: portfolioForAddress?.tokensTotalDenominatedValueChange?.absolute?.value, + } + }, [portfolioForAddress]) + + const retry = useCallback( + () => refetch({ ownerAddress: address, valueModifiers }), + [address, valueModifiers, refetch], + ) + + return { + data: formattedData, + loading, + networkStatus, + refetch: retry, + error: persistedError, + } +} + +/** + * Returns NativeCurrency with highest balance. + * + * @param address to get portfolio balances for + * @returns CurrencyId of the NativeCurrency with highest balance + * + */ +export function useHighestBalanceNativeCurrencyId( + address: Address, + valueModifiers?: PortfolioValueModifier[], +): CurrencyId | undefined { + const { data } = useSortedPortfolioBalances({ address, valueModifiers }) + return data?.balances.find((balance) => balance.currencyInfo.currency.isNative)?.currencyInfo.currencyId +} + +/** + * Custom hook to group Token Balances fetched from API to shown and hidden. + * + * @param balancesById - An object where keys are token ids and values are the corresponding balances. May be undefined. + * + * @returns {object} An object containing two fields: + * - `shownTokens`: shown tokens. + * - `hiddenTokens`: hidden tokens. + * + * @example + * const { shownTokens, hiddenTokens } = useTokenBalancesGroupedByVisibility({ balancesById }); + */ +export function useTokenBalancesGroupedByVisibility({ + balancesById, +}: { + balancesById?: Record<string, PortfolioBalance> +}): { + shownTokens: PortfolioBalance[] | undefined + hiddenTokens: PortfolioBalance[] | undefined +} { + return useMemo(() => { + if (!balancesById) { + return { shownTokens: undefined, hiddenTokens: undefined } + } + + const { shown, hidden } = Object.values(balancesById).reduce<{ + shown: PortfolioBalance[] + hidden: PortfolioBalance[] + }>( + (acc, balance) => { + if (balance.isHidden) { + acc.hidden.push(balance) + } else { + acc.shown.push(balance) + } + return acc + }, + { shown: [], hidden: [] }, + ) + return { + shownTokens: shown.length ? shown : undefined, + hiddenTokens: hidden.length ? hidden : undefined, + } + }, [balancesById]) +} + +/** + * Returns portfolio balances for a given address sorted by USD value. + * + * @param address to get portfolio balances for + * @param pollInterval optional polling interval for auto refresh. + * If undefined, query will run only once. + * @param onCompleted callback + * @param valueModifiers optional array of PortfolioValueModifier objects + * @returns SortedPortfolioBalances object with `balances` and `hiddenBalances` + */ +export function useSortedPortfolioBalances({ + valueModifiers, + address, + pollInterval, + onCompleted, +}: { + address: Address + pollInterval?: PollingInterval + valueModifiers?: PortfolioValueModifier[] + onCompleted?: () => void +}): GqlResult<SortedPortfolioBalances> & { networkStatus: NetworkStatus } { + // Fetch all balances including small balances and spam tokens because we want to return those in separate arrays + const { + data: balancesById, + loading, + networkStatus, + refetch, + } = usePortfolioBalances({ + address, + pollInterval, + onCompleted, + fetchPolicy: 'cache-and-network', + valueModifiers, + }) + + const { shownTokens, hiddenTokens } = useTokenBalancesGroupedByVisibility({ balancesById }) + + return { + data: { + balances: sortPortfolioBalances(shownTokens || []), + hiddenBalances: sortPortfolioBalances(hiddenTokens || []), + }, + loading, + networkStatus, + refetch, + } +} + +/** + * Helper function to stable sort balances by descending balanceUSD, + * followed by balances with null balanceUSD values sorted alphabetically + * */ +export function sortPortfolioBalances(balances: PortfolioBalance[]): PortfolioBalance[] { + const balancesWithUSDValue = balances.filter((b) => b.balanceUSD) + const balancesWithoutUSDValue = balances.filter((b) => !b.balanceUSD) + + return [ + ...balancesWithUSDValue.sort((a, b) => { + if (!a.balanceUSD) { + return 1 + } + if (!b.balanceUSD) { + return -1 + } + return b.balanceUSD - a.balanceUSD + }), + ...balancesWithoutUSDValue.sort((a, b) => { + if (!a.currencyInfo.currency.name) { + return 1 + } + if (!b.currencyInfo.currency.name) { + return -1 + } + return a.currencyInfo.currency.name?.localeCompare(b.currencyInfo.currency.name) + }), + ] +} + +/** + * Creates a function to update the Apollo cache when a token is shown or hidden. + * We manually modify the cache to avoid having to wait for the server's response, + * so that the change is immediately reflected in the UI. + * + * @param address active wallet address + * @returns a `PortfolioCacheUpdater` function that will update the Apollo cache + */ +export function usePortfolioCacheUpdater(address: string): PortfolioCacheUpdater { + const apolloClient = useApolloClient() + + const updater = useCallback( + (hidden: boolean, portfolioBalance?: PortfolioBalance) => { + if (!portfolioBalance) { + return + } + + const cachedPortfolio = apolloClient.readQuery<PortfolioBalancesQuery>({ + query: PortfolioBalanceDocument, + variables: { + owner: address, + }, + })?.portfolios?.[0] + + if (!cachedPortfolio) { + return + } + + apolloClient.cache.modify({ + id: portfolioBalance.cacheId, + fields: { + isHidden() { + return hidden + }, + }, + }) + + apolloClient.cache.modify({ + id: apolloClient.cache.identify(cachedPortfolio), + fields: { + tokensTotalDenominatedValue(amount: Reference | IAmount, { isReference }) { + if (isReference(amount)) { + // I don't think this should ever happen, but this is required to keep TS happy after upgrading to @apollo/client > 3.8. + logger.error(new Error('Unable to modify cache for `tokensTotalDenominatedValue`'), { + tags: { + file: 'balances.ts', + function: 'usePortfolioCacheUpdater', + }, + extra: { + portfolioId: apolloClient.cache.identify(cachedPortfolio), + }, + }) + return amount + } + + const newValue = portfolioBalance.balanceUSD + ? hidden + ? amount.value - portfolioBalance.balanceUSD + : amount.value + portfolioBalance.balanceUSD + : amount.value + return { ...amount, value: newValue } + }, + }, + }) + }, + [apolloClient, address], + ) + + return updater +} diff --git a/packages/uniswap/src/features/fiatOnRamp/FORQuoteItem.tsx b/packages/uniswap/src/features/fiatOnRamp/FORQuoteItem.tsx index b3c12d633bb..381ce9a7653 100644 --- a/packages/uniswap/src/features/fiatOnRamp/FORQuoteItem.tsx +++ b/packages/uniswap/src/features/fiatOnRamp/FORQuoteItem.tsx @@ -4,7 +4,6 @@ import { Flex, Loader, Text, TouchableArea, UniversalImage, useIsDarkMode } from import { borderRadii, iconSizes } from 'ui/src/theme' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' -import { concatStrings } from 'utilities/src/primitives/string' function LogoLoader(): JSX.Element { return <Loader.Box borderRadius="$roundedFull" height={iconSizes.icon40} width={iconSizes.icon40} /> @@ -32,13 +31,7 @@ export function FORQuoteItem({ const paymentMethods = serviceProvider.paymentMethods.length > 3 - ? concatStrings( - [ - serviceProvider.paymentMethods.slice(0, 3).join(', ') + ',', // oxford comma - (t('fiatOnRamp.quote.type.other') as string).toLowerCase(), - ], - t('common.endAdornment'), - ) + ? t('fiatOnRamp.quote.type.list', { optionsList: serviceProvider.paymentMethods.slice(3).join(', ') }) // oxford comma : serviceProvider.paymentMethods.join(', ') return ( diff --git a/packages/uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx b/packages/uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx index 90a995c2923..0807f39c6c2 100644 --- a/packages/uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx +++ b/packages/uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker.tsx @@ -36,7 +36,7 @@ export function FiatOnRampCountryPicker({ py="$spacing2" onPress={onPress} > - <Flex row shrink alignItems="center" flex={0} gap="$spacing2"> + <Flex row shrink alignItems="center" data-testid="FiatOnRampCountryPicker" flex={0} gap="$spacing2"> <Flex borderRadius="$roundedFull" overflow="hidden"> {isWeb ? ( <img alt={countryCode} height={ICON_SIZE} src={countryFlagUrl} width={ICON_SIZE} /> diff --git a/packages/uniswap/src/features/fiatOnRamp/SelectTokenButton.tsx b/packages/uniswap/src/features/fiatOnRamp/SelectTokenButton.tsx index 589055ffe26..be2eb4f1c9c 100644 --- a/packages/uniswap/src/features/fiatOnRamp/SelectTokenButton.tsx +++ b/packages/uniswap/src/features/fiatOnRamp/SelectTokenButton.tsx @@ -4,6 +4,7 @@ import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' import { iconSizes, spacing } from 'ui/src/theme' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' interface SelectTokenButtonProps { @@ -16,7 +17,7 @@ interface SelectTokenButtonProps { iconSize?: number backgroundColor?: ComponentProps<typeof TouchableArea>['backgroundColor'] chevronDirection?: ComponentProps<typeof RotatableChevron>['direction'] - testID?: string + testID?: TestIDType } export function SelectTokenButton({ diff --git a/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts b/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts index 9d8e9f72dd0..d3cfa60be42 100644 --- a/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts +++ b/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts @@ -1,9 +1,9 @@ import { useFiatOnRampAggregatorTransferServiceProvidersQuery } from 'uniswap/src/features/fiatOnRamp/api' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' -export function useCexTransferProviders(isEnabled: boolean): FORServiceProvider[] { +export function useCexTransferProviders(params?: { isDisabled?: boolean }): FORServiceProvider[] { const { data, isLoading } = useFiatOnRampAggregatorTransferServiceProvidersQuery(undefined, { - skip: !isEnabled, + skip: params?.isDisabled, }) if (isLoading || !data) { diff --git a/packages/uniswap/src/features/gating/configs.ts b/packages/uniswap/src/features/gating/configs.ts index c27acf76982..4c94ed6c7da 100644 --- a/packages/uniswap/src/features/gating/configs.ts +++ b/packages/uniswap/src/features/gating/configs.ts @@ -29,7 +29,19 @@ export enum SwapConfigKey { AverageL1BlockTimeMs = 'averageL1BlockTimeMs', AverageL2BlockTimeMs = 'averageL2BlockTimeMs', TradingApiSwapRequestMs = 'tradingApiSwapRequestMs', + MinAutoSlippageToleranceL2 = 'minAutoSlippageToleranceL2', + + EthSwapMinGasAmount = 'ethSwapMinGasAmount', + EthSendMinGasAmount = 'ethSendMinGasAmount', + PolygonSwapMinGasAmount = 'polygonSwapMinGasAmount', + PolygonSendMinGasAmount = 'polygonSendMinGasAmount', + AvalancheSwapMinGasAmount = 'avalancheSwapMinGasAmount', + AvalancheSendMinGasAmount = 'avalancheSendMinGasAmount', + CeloSwapMinGasAmount = 'celoSwapMinGasAmount', + CeloSendMinGasAmount = 'celoSendMinGasAmount', + GenericL2SwapMinGasAmount = 'genericL2SwapMinGasAmount', + GenericL2SendMinGasAmount = 'genericL2SendMinGasAmount', } export enum UwuLinkConfigKey { diff --git a/packages/uniswap/src/features/gating/experiments.ts b/packages/uniswap/src/features/gating/experiments.ts index 28e42a28b13..26f684ab38e 100644 --- a/packages/uniswap/src/features/gating/experiments.ts +++ b/packages/uniswap/src/features/gating/experiments.ts @@ -5,6 +5,8 @@ */ export enum Experiments { ArbitrumXV2OpenOrders = 'arbitrum_uniswapx_openorders', + OnboardingRedesignHomeScreen = 'onboarding-redesign-home-screen', + OnboardingRedesignRecoveryBackup = 'onboarding-redesign-recovery-backup', } export enum ArbitrumXV2OpenOrderProperties { @@ -13,6 +15,18 @@ export enum ArbitrumXV2OpenOrderProperties { DeadlineBufferSecs = 'deadlineBufferSecs', } +export enum OnboardingRedesignHomeScreenProperties { + Enabled = 'enabled', + ExploreChainId = 'exploreChainId', + ExploreTokenAddresses = 'exploreTokenAddresses', +} + +export enum OnboardingRedesignRecoveryBackupProperties { + Enabled = 'enabled', +} + export type ExperimentProperties = { [Experiments.ArbitrumXV2OpenOrders]: ArbitrumXV2OpenOrderProperties + [Experiments.OnboardingRedesignHomeScreen]: OnboardingRedesignHomeScreenProperties + [Experiments.OnboardingRedesignRecoveryBackup]: OnboardingRedesignRecoveryBackupProperties } diff --git a/packages/uniswap/src/features/gating/flags.ts b/packages/uniswap/src/features/gating/flags.ts index 2abd691044c..830c661a054 100644 --- a/packages/uniswap/src/features/gating/flags.ts +++ b/packages/uniswap/src/features/gating/flags.ts @@ -2,46 +2,37 @@ import { logger } from 'utilities/src/logger/logger' import { isInterface } from 'utilities/src/platform' /** * Feature flag names - * These must match the Gate Key on Statsig */ export enum FeatureFlags { // Shared - CurrencyConversion, + ForAggregator, // Wallet - ExtensionOnboarding, // this is beta onboarding, cant change name for version compatibility - ExtensionPromotionGA, - FeedTab, - ForAggregator, - CexTransfers, - LanguageSelection, + ForTransactionsFromGraphQL, MevBlocker, - OptionalRouting, - OnboardingKeyring, - PlaystoreAppRating, PortionFields, - RestoreWallet, - Scantastic, - ScantasticOnboardingOnly, - SeedPhraseRefactorNative, SendRewrite, TransactionDetailsSheet, + OpenAIAssistant, UnitagsDeviceAttestation, - UwULink, UniswapX, + // Mobile + Datadog, + ExtensionPromotionGA, + FeedTab, + OnboardingKeyring, + Scantastic, + UwULink, + // Extension - ExtensionBuyButton, - ExtensionBetaFeedbackPrompt, ExtensionAutoConnect, // Web - NavRefresh, NavigationHotkeys, Eip6936Enabled, ExitAnimation, ExtensionLaunch, - ForAggregatorWeb, GqlTokenLists, LimitsFees, L2NFTs, @@ -61,11 +52,11 @@ export enum FeatureFlags { OutageBannerPolygon, } +// These names must match the gate name on statsig export const WEB_FEATURE_FLAG_NAMES = new Map<FeatureFlags, string>([ // Shared - [FeatureFlags.CurrencyConversion, 'currency_conversion'], + [FeatureFlags.ForAggregator, 'for_aggregator_web'], // Web Specific - [FeatureFlags.NavRefresh, 'navigation_refresh'], [FeatureFlags.NavigationHotkeys, 'navigation_hotkeys'], [FeatureFlags.Eip6936Enabled, 'eip6963_enabled'], [FeatureFlags.ExitAnimation, 'exit_animation'], @@ -83,40 +74,31 @@ export const WEB_FEATURE_FLAG_NAMES = new Map<FeatureFlags, string>([ [FeatureFlags.UniswapXv2, 'uniswapx_v2'], [FeatureFlags.V2Everywhere, 'v2_everywhere'], [FeatureFlags.Zora, 'zora'], - [FeatureFlags.ForAggregatorWeb, 'for_aggregator_web'], // TODO(WEB-3625): Remove these once we have a generalized system for outage banners. [FeatureFlags.OutageBannerArbitrum, 'outage_banner_feb_2024_arbitrum'], [FeatureFlags.OutageBannerOptimism, 'outage_banner_feb_2024_optimism'], [FeatureFlags.OutageBannerPolygon, 'outage_banner_feb_2024_polygon'], ]) +// These names must match the gate name on statsig export const WALLET_FEATURE_FLAG_NAMES = new Map<FeatureFlags, string>([ // Shared - [FeatureFlags.CurrencyConversion, 'currency_conversion'], + [FeatureFlags.ForAggregator, 'for-aggregator'], // Wallet Specific - [FeatureFlags.ExtensionOnboarding, 'extension-onboarding'], - [FeatureFlags.ExtensionPromotionGA, 'extension-promotion-ga'], + [FeatureFlags.Datadog, 'datadog'], [FeatureFlags.FeedTab, 'feed-tab'], - [FeatureFlags.ForAggregator, 'for-aggregator'], - [FeatureFlags.CexTransfers, 'cex-transfers'], - [FeatureFlags.LanguageSelection, 'language-selection'], + [FeatureFlags.ForTransactionsFromGraphQL, 'for-from-graphql'], [FeatureFlags.MevBlocker, 'mev-blocker'], - [FeatureFlags.OptionalRouting, 'optional-routing'], + [FeatureFlags.OpenAIAssistant, 'openai-assistant'], [FeatureFlags.OnboardingKeyring, 'onboarding-keyring'], - [FeatureFlags.PlaystoreAppRating, 'playstore-app-rating'], [FeatureFlags.PortionFields, 'portion-fields'], - [FeatureFlags.RestoreWallet, 'restore-wallet'], [FeatureFlags.Scantastic, 'scantastic'], - [FeatureFlags.ScantasticOnboardingOnly, 'scantastic-onboarding-only'], - [FeatureFlags.SeedPhraseRefactorNative, 'refactor-seed-phrase-native'], [FeatureFlags.SendRewrite, 'send-rewrite'], [FeatureFlags.TransactionDetailsSheet, 'transaction-details-sheet'], [FeatureFlags.UnitagsDeviceAttestation, 'unitags-device-attestation'], [FeatureFlags.UwULink, 'uwu-link'], [FeatureFlags.UniswapX, 'uniswapx'], // Extension Specific - [FeatureFlags.ExtensionBuyButton, 'extension-buy-button'], - [FeatureFlags.ExtensionBetaFeedbackPrompt, 'extension-beta-feedback-prompt'], [FeatureFlags.ExtensionAutoConnect, 'extension-auto-connect'], ]) diff --git a/packages/uniswap/src/features/gating/hooks.ts b/packages/uniswap/src/features/gating/hooks.ts index 828059ab5ec..41c3911e87d 100644 --- a/packages/uniswap/src/features/gating/hooks.ts +++ b/packages/uniswap/src/features/gating/hooks.ts @@ -2,6 +2,8 @@ import { DynamicConfigKeys } from 'uniswap/src/features/gating/configs' import { ExperimentProperties, Experiments } from 'uniswap/src/features/gating/experiments' import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' import { + DynamicConfig, + Statsig, useConfig, useExperiment, useExperimentWithExposureLoggingDisabled, @@ -15,24 +17,40 @@ export function useFeatureFlag(flag: FeatureFlags): boolean { return value } +export function useFeatureFlagWithLoading(flag: FeatureFlags): { value: boolean; isLoading: boolean } { + const name = getFeatureFlagName(flag) + const { value, isLoading } = useGate(name) + return { value, isLoading } +} + +export function getFeatureFlag(flag: FeatureFlags): boolean { + const name = getFeatureFlagName(flag) + return Statsig.checkGate(name) +} + export function useFeatureFlagWithExposureLoggingDisabled(flag: FeatureFlags): boolean { const name = getFeatureFlagName(flag) const { value } = useGateWithExposureLoggingDisabled(name) return value } +export function getFeatureFlagWithExposureLoggingDisabled(flag: FeatureFlags): boolean { + const name = getFeatureFlagName(flag) + return Statsig.checkGateWithExposureLoggingDisabled(name) +} + export function useExperimentGroupName(experiment: Experiments): string | null { const statsigExperiment = useExperiment(experiment).config return statsigExperiment.getGroupName() } -export function useExperimentValue< - Exp extends keyof ExperimentProperties, - Param extends ExperimentProperties[Exp], - ValType, ->(experiment: Exp, param: Param, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { - const statsigExperiment = useExperiment(experiment).config - return statsigExperiment.get(param, defaultValue, (value): value is ValType => { +function getValueFromConfig<ValType>( + config: DynamicConfig, + param: string, + defaultValue: ValType, + customTypeGuard?: (x: unknown) => boolean, +): ValType { + return config.get(param, defaultValue, (value): value is ValType => { if (customTypeGuard) { return customTypeGuard(value) } else { @@ -41,19 +59,40 @@ export function useExperimentValue< }) } +export function useExperimentValue< + Exp extends keyof ExperimentProperties, + Param extends ExperimentProperties[Exp], + ValType, +>(experiment: Exp, param: Param, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { + const statsigExperiment = useExperiment(experiment).config + return getValueFromConfig(statsigExperiment, param, defaultValue, customTypeGuard) +} + +export function getExperimentValue< + Exp extends keyof ExperimentProperties, + Param extends ExperimentProperties[Exp], + ValType, +>(experiment: Exp, param: Param, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { + const statsigExperiment = Statsig.getExperiment(experiment) + return getValueFromConfig(statsigExperiment, param, defaultValue, customTypeGuard) +} + export function useExperimentValueWithExposureLoggingDisabled< Exp extends keyof ExperimentProperties, Param extends ExperimentProperties[Exp], ValType, >(experiment: Exp, param: Param, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { const statsigExperiment = useExperimentWithExposureLoggingDisabled(experiment).config - return statsigExperiment.get(param, defaultValue, (value): value is ValType => { - if (customTypeGuard) { - return customTypeGuard(value) - } else { - return typeof value === typeof defaultValue - } - }) + return getValueFromConfig(statsigExperiment, param, defaultValue, customTypeGuard) +} + +export function getExperimentValueWithExposureLoggingDisabled< + Exp extends keyof ExperimentProperties, + Param extends ExperimentProperties[Exp], + ValType, +>(experiment: Exp, param: Param, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { + const statsigExperiment = Statsig.getExperimentWithExposureLoggingDisabled(experiment) + return getValueFromConfig(statsigExperiment, param, defaultValue, customTypeGuard) } export function useDynamicConfigValue< @@ -62,11 +101,14 @@ export function useDynamicConfigValue< ValType, >(config: Conf, key: Key, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { const { config: dynamicConfig } = useConfig(config) - return dynamicConfig.get(key, defaultValue, (value): value is ValType => { - if (customTypeGuard) { - return customTypeGuard(value) - } else { - return typeof value === typeof defaultValue - } - }) + return getValueFromConfig(dynamicConfig, key, defaultValue, customTypeGuard) +} + +export function getDynamicConfigValue< + Conf extends keyof DynamicConfigKeys, + Key extends DynamicConfigKeys[Conf], + ValType, +>(config: Conf, key: Key, defaultValue: ValType, customTypeGuard?: (x: unknown) => boolean): ValType { + const dynamicConfig = Statsig.getConfig(config) + return getValueFromConfig(dynamicConfig, key, defaultValue, customTypeGuard) } diff --git a/packages/uniswap/src/features/search/SearchResult.ts b/packages/uniswap/src/features/search/SearchResult.ts index b23ab16eb27..8a340aa38c3 100644 --- a/packages/uniswap/src/features/search/SearchResult.ts +++ b/packages/uniswap/src/features/search/SearchResult.ts @@ -1,3 +1,6 @@ +import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { UniverseChainId } from 'uniswap/src/types/chains' + // Retain original ordering as these are saved to storage and loaded back out export enum SearchResultType { ENSAddress, @@ -7,3 +10,18 @@ export enum SearchResultType { Unitag, WalletByAddress, } + +export interface SearchResultBase { + type: SearchResultType + searchId?: string +} + +export interface TokenSearchResult extends SearchResultBase { + type: SearchResultType.Token + chainId: UniverseChainId + symbol: string + address: Address | null + name: string | null + logoUrl: string | null + safetyLevel: SafetyLevel | null +} diff --git a/packages/uniswap/src/features/search/SearchTextInput.tsx b/packages/uniswap/src/features/search/SearchTextInput.tsx index 9c18f94e4c3..9e5664fdb45 100644 --- a/packages/uniswap/src/features/search/SearchTextInput.tsx +++ b/packages/uniswap/src/features/search/SearchTextInput.tsx @@ -19,13 +19,16 @@ import { isWeb, useComposedRefs, } from 'ui/src' -import { RotatableChevron, Search, X } from 'ui/src/components/icons' +import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' +import { Search } from 'ui/src/components/icons/Search' +import { X } from 'ui/src/components/icons/X' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { fonts, iconSizes, spacing } from 'ui/src/theme' import { SHADOW_OFFSET_SMALL } from 'uniswap/src/components/BaseCard/BaseCard' import ViewGestureHandler from 'uniswap/src/components/ViewGestureHandler' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { isAndroid, isIOS } from 'utilities/src/platform' const DEFAULT_MIN_HEIGHT = 48 @@ -175,6 +178,7 @@ export const SearchTextInput = forwardRef<NativeTextInput, SearchTextInputProps> placeholderTextColor="$neutral3" position="absolute" returnKeyType="done" + testID={TestID.ExploreSearchInput} textContentType="none" top={0} // fix horizontal text wobble on iOS diff --git a/packages/uniswap/src/features/telemetry/constants/mobile.ts b/packages/uniswap/src/features/telemetry/constants/mobile.ts index b4cc83627e6..1565732afc9 100644 --- a/packages/uniswap/src/features/telemetry/constants/mobile.ts +++ b/packages/uniswap/src/features/telemetry/constants/mobile.ts @@ -11,9 +11,9 @@ export enum MobileEventName { ExploreFilterSelected = 'Explore Filter Selected', ExploreSearchResultClicked = 'Explore Search Result Clicked', ExploreTokenItemSelected = 'Explore Token Item Selected', - ExtensionPromoBannerActionTaken = 'Extension Promo Banner Action Taken', FavoriteItem = 'Favorite Item', FiatOnRampQuickActionButtonPressed = 'Fiat OnRamp QuickAction Button Pressed', + HomeExploreTokenItemSelected = 'Home Explore Token Item Selected', NotificationsToggled = 'Notifications Toggled', OnboardingCompleted = 'Onboarding Completed', PerformanceReport = 'Performance Report', diff --git a/packages/uniswap/src/features/telemetry/constants/trace.ts b/packages/uniswap/src/features/telemetry/constants/trace.ts index b583643dce6..7131785dee8 100644 --- a/packages/uniswap/src/features/telemetry/constants/trace.ts +++ b/packages/uniswap/src/features/telemetry/constants/trace.ts @@ -11,6 +11,7 @@ export const ModalName = { AccountSwitcher: 'account-switcher-modal', AddWallet: 'add-wallet-modal', BlockedAddress: 'blocked-address', + BuyNativeToken: 'buy-native-token-modal', ChooseProfilePhoto: 'choose-profile-photo-modal', CloudBackupInfo: 'cloud-backup-info-modal', DappRequest: 'dapp-request', @@ -19,8 +20,6 @@ export const ModalName = { ExchangeTransferModal: 'exchange-transfer-modal', Experiments: 'experiments', Explore: 'explore-modal', - ExtensionPromoModal: 'extension-promo', - ExtensionBetaFeedbackModal: 'extension-beta-feedback', FaceIDWarning: 'face-id-warning', FOTInfo: 'fee-on-transfer', FiatCurrencySelector: 'fiat-currency-selector', @@ -31,6 +30,7 @@ export const ModalName = { ForgotPassword: 'forgot-password', Legal: 'legal', LanguageSelector: 'language-selector-modal', + NewAddressWarning: 'new-address-warning-modal', NetworkFeeInfo: 'network-fee-info', NetworkSelector: 'network-selector-modal', NftCollection: 'nft-collection', @@ -39,6 +39,11 @@ export const ModalName = { OtpScanInput: 'otp-scan-input', QRCodeNetworkInfo: 'qr-code-network-info', QueuedOrderModal: 'queued-order-modal', + RecipientSelectErc20Warning: 'recipient-select-erc20-warning', + RecipientSelectNewWarning: 'recipient-select-new-warning', + RecipientSelectSelfSendWarning: 'recipient-select-self-send-warning', + RecipientSelectSmartContractWarning: 'recipient-select-smart-contract-warning', + RecipientSelectViewOnlyWarning: 'recipient-select-view-only-warning', ReceiveCryptoModal: 'receive-crypto-modal', RemoveWallet: 'remove-wallet-modal', RestoreWallet: 'restore-wallet-modal', @@ -55,7 +60,6 @@ export const ModalName = { SwapReview: 'swap-review-modal', SwapSettings: 'swap-settings-modal', SwapWarning: 'swap-warning-modal', - ViewOnlyRecipientWarning: 'view-only-recipient-warning-modal', SwapProtection: 'swap-protection-modal', TokenSelector: 'token-selector', TokenWarningModal: 'token-warning-modal', @@ -92,6 +96,7 @@ export const ElementName = { AddViewOnlyWallet: 'add-view-only-wallet', AddCloudBackup: 'add-cloud-backup', Buy: 'buy', + BuyNativeTokenButton: 'buy-native-token-button', Cancel: 'cancel', ChainEthereum: 'chain-ethereum', ChainEthereumGoerli: 'chain-ethereum-goerli', @@ -122,6 +127,7 @@ export const ElementName = { EmptyStateReceive: 'empty-state-receive', Enable: 'enable', EtherscanView: 'etherscan-view', + ExtensionPopupOpenButton: 'extension-popup-open-button', FiatOnRampTokenSelector: 'fiat-on-ramp-token-selector', FiatOnRampWidgetButton: 'fiat-on-ramp-widget-button', FiatOnRampCountryPicker: 'fiat-on-ramp-country-picker', @@ -133,6 +139,7 @@ export const ElementName = { Next: 'next', NftItem: 'nft-item', OK: 'ok', + OnboardingIntroCardFundWallet: 'onboarding-intro-card-fund-wallet', OnboardingImportBackup: 'onboarding-import-backup', OnboardingImportSeedPhrase: 'onboarding-import-seed-phrase', OnDeviceRecoveryImportOther: 'on-device-recovery-import-other', @@ -192,6 +199,7 @@ export const SectionName = { ExploreSearch: 'explore-search', ExploreTopTokensSection: 'explore-top-tokens-section', HomeActivityTab: 'home-activity-tab', + HomeExploreTab: 'home-explore-tab', HomeFeedTab: 'home-feed-tab', HomeNFTsTab: 'home-nfts-tab', HomeTokensTab: 'home-tokens-tab', diff --git a/packages/uniswap/src/features/telemetry/types.ts b/packages/uniswap/src/features/telemetry/types.ts index a17b1a483da..20cddc6227e 100644 --- a/packages/uniswap/src/features/telemetry/types.ts +++ b/packages/uniswap/src/features/telemetry/types.ts @@ -91,6 +91,8 @@ export type SwapTradeBaseProperties = { chain_id?: number token_in_amount?: string | number token_out_amount?: string | number + token_in_amount_usd?: number + token_out_amount_usd?: number fee_amount?: string requestId?: string quoteId?: string @@ -134,6 +136,7 @@ type TransferProperties = { chainId: WalletChainId tokenAddress: Address toAddress: Address + amountUSD?: number } export type WindowEthereumRequestProperties = { @@ -365,9 +368,6 @@ export type UniverseEventProperties = { fee_tier?: number pool_address?: string } & ITraceContext - [MobileEventName.ExtensionPromoBannerActionTaken]: { - action: 'join' | 'dismiss' - } [MobileEventName.AppRating]: { type: 'store-review' | 'feedback-form' | 'remind' appRatingPromptedMs?: number @@ -410,6 +410,9 @@ export type UniverseEventProperties = { [MobileEventName.ExploreTokenItemSelected]: AssetDetailsBaseProperties & { position: number } + [MobileEventName.HomeExploreTokenItemSelected]: AssetDetailsBaseProperties & { + position: number + } [MobileEventName.FavoriteItem]: AssetDetailsBaseProperties & { type: 'token' | 'wallet' } diff --git a/packages/uniswap/src/features/tokens/TokenWarningModal.tsx b/packages/uniswap/src/features/tokens/TokenWarningModal.tsx index 8a732b89cbe..ec067571d00 100644 --- a/packages/uniswap/src/features/tokens/TokenWarningModal.tsx +++ b/packages/uniswap/src/features/tokens/TokenWarningModal.tsx @@ -58,12 +58,8 @@ export default function TokenWarningModal({ const showWarningIcon = safetyLevel === SafetyLevel.StrongWarning || safetyLevel === SafetyLevel.Blocked - if (!isVisible) { - return null - } - return ( - <BottomSheetModal name={ModalName.TokenWarningModal} onClose={onClose}> + <BottomSheetModal isModalOpen={isVisible} maxWidth={420} name={ModalName.TokenWarningModal} onClose={onClose}> <Flex centered gap="$spacing16" diff --git a/packages/uniswap/src/features/unitags/api.ts b/packages/uniswap/src/features/unitags/api.ts index a32a0da7112..1e6ec425387 100644 --- a/packages/uniswap/src/features/unitags/api.ts +++ b/packages/uniswap/src/features/unitags/api.ts @@ -5,11 +5,7 @@ import { uniswapUrls } from 'uniswap/src/constants/urls' import { createNewInMemoryCache } from 'uniswap/src/data/cache' import { REQUEST_SOURCE, getVersionHeader } from 'uniswap/src/data/constants' import { useRestQuery } from 'uniswap/src/data/rest' -import { - UnitagAddressResponse, - UnitagUsernameResponse, - UnitagWaitlistPositionResponse, -} from 'uniswap/src/features/unitags/types' +import { UnitagAddressResponse, UnitagUsernameResponse } from 'uniswap/src/features/unitags/types' import { ONE_MINUTE_MS } from 'utilities/src/time/time' const restLink = new RestLink({ @@ -76,21 +72,3 @@ export function useUnitagByAddressQuery(address?: Address): ReturnType<typeof us unitagsApolloClient, ) } - -export function useWaitlistPositionQuery( - accounts: Address[], - skip: boolean, -): ReturnType<typeof useRestQuery<UnitagWaitlistPositionResponse>> { - const addresses = accounts.join(',') - return useRestQuery<UnitagWaitlistPositionResponse, Record<string, unknown>>( - addQueryParamsToEndpoint('/waitlist/position', { addresses }), - { addresses }, // dummy body so that cache key is unique per query params - ['isAccepted', 'waitlistPosition'], // return all fields - { - skip, - ttlMs: ONE_MINUTE_MS * 2, - }, - 'GET', - unitagsApolloClient, - ) -} diff --git a/packages/uniswap/src/features/unitags/types.ts b/packages/uniswap/src/features/unitags/types.ts index e611bcfc000..5516397f8c9 100644 --- a/packages/uniswap/src/features/unitags/types.ts +++ b/packages/uniswap/src/features/unitags/types.ts @@ -96,18 +96,6 @@ export type UnitagChangeUsernameRequestBody = { deviceId: string } -export type UnitagWaitlistPositionResponse = - | { - isAccepted: false - waitlistPosition?: never - address?: never - } - | { - isAccepted: true - waitlistPosition: number - address: Address - } - // Copied enum from unitags backend code -- needs to be up-to-date export enum UnitagErrorCodes { UnitagNotAvailable = 'unitags-1', diff --git a/apps/web/src/i18n/Plural.tsx b/packages/uniswap/src/i18n/Plural.tsx similarity index 70% rename from apps/web/src/i18n/Plural.tsx rename to packages/uniswap/src/i18n/Plural.tsx index 6e47ab93b06..767bee2fba6 100644 --- a/apps/web/src/i18n/Plural.tsx +++ b/packages/uniswap/src/i18n/Plural.tsx @@ -1,7 +1,8 @@ import { Translation } from 'react-i18next' +import { PluralProps } from 'uniswap/src/i18n/shared' import { isTestEnv } from 'utilities/src/environment' -export function Plural({ value, one, other }: { value: number; one: string; other: string }) { +export function Plural({ value, one, other }: PluralProps): JSX.Element { const children = value === 1 ? one : other if (isTestEnv()) { return <>{children}</> diff --git a/apps/web/src/i18n/Trans.tsx b/packages/uniswap/src/i18n/Trans.tsx similarity index 56% rename from apps/web/src/i18n/Trans.tsx rename to packages/uniswap/src/i18n/Trans.tsx index aefd1757219..d899db41179 100644 --- a/apps/web/src/i18n/Trans.tsx +++ b/packages/uniswap/src/i18n/Trans.tsx @@ -1,8 +1,8 @@ -import { useTranslation } from 'i18n/useTranslation' -import { Trans as OGTrans } from 'react-i18next' +import { Trans as OGTrans, useTranslation } from 'react-i18next' -export const Trans = ((props) => { +export const Trans = ((props): JSX.Element => { // forces re-render on language change because it doesn't by default useTranslation() + return <OGTrans {...props}>{props.children}</OGTrans> }) satisfies typeof OGTrans diff --git a/apps/web/src/i18n/dynamicActivate.tsx b/packages/uniswap/src/i18n/changeLanguage.tsx similarity index 66% rename from apps/web/src/i18n/dynamicActivate.tsx rename to packages/uniswap/src/i18n/changeLanguage.tsx index c8f069fe6c5..4f8c54d0599 100644 --- a/apps/web/src/i18n/dynamicActivate.tsx +++ b/packages/uniswap/src/i18n/changeLanguage.tsx @@ -1,9 +1,8 @@ -import { SupportedLocale } from 'constants/locales' import i18n from 'i18next' +import { SupportedLocale } from 'uniswap/src/i18n/locales' let changingTo = '' - -export async function dynamicActivate(locale: SupportedLocale) { +export async function changeLanguage(locale: SupportedLocale): Promise<void> { if (i18n.language === locale || locale === changingTo) { return } diff --git a/packages/uniswap/src/i18n/i18n-setup-interface.tsx b/packages/uniswap/src/i18n/i18n-setup-interface.tsx new file mode 100644 index 00000000000..07ea0ce4b7c --- /dev/null +++ b/packages/uniswap/src/i18n/i18n-setup-interface.tsx @@ -0,0 +1,78 @@ +import i18n from 'i18next' +import resourcesToBackend from 'i18next-resources-to-backend' +import { initReactI18next } from 'react-i18next' +import enUsLocale from 'uniswap/src/i18n/locales/web-source/en-US.json' +import { logger } from 'utilities/src/logger/logger' + +let isSetup = false + +setupi18n() + +export function setupi18n(): undefined { + if (isSetup) { + return + } + isSetup = true + + i18n + .use(initReactI18next) + .use( + resourcesToBackend((language: string) => { + // not sure why but it tries to load es THEN es-ES, for any language, but we just want the second + if (!language.includes('-')) { + return + } + if (language === 'en-US') { + return enUsLocale + } + // eslint-disable-next-line no-unsanitized/method + return import(`./locales/web-translations/${language}.json`) + }), + ) + .on('failedLoading', (language, namespace, msg) => { + logger.error(new Error(`Error loading language ${language} ${namespace}: ${msg}`), { + tags: { + file: 'i18n', + function: 'onFailedLoading', + }, + }) + }) + + i18n + .init({ + react: { + useSuspense: false, + }, + returnEmptyString: false, + keySeparator: false, + lng: 'en-US', + fallbackLng: 'en-US', + interpolation: { + escapeValue: false, // react already safes from xss + }, + }) + .catch((err) => { + logger.error(new Error(`Error initializing i18n ${err}`), { + tags: { + file: 'i18n', + function: 'onFailedInit', + }, + }) + }) + + // add default english ns right away + i18n.addResourceBundle('en-US', 'translations', { + 'en-US': { + translation: enUsLocale, + }, + }) + + i18n.changeLanguage('en-US').catch((err) => { + logger.error(new Error(`${err}`), { + tags: { + file: 'i18n', + function: 'setupi18n', + }, + }) + }) +} diff --git a/packages/uniswap/src/i18n/i18n-setup.tsx b/packages/uniswap/src/i18n/i18n-setup.tsx new file mode 100644 index 00000000000..f2589931d77 --- /dev/null +++ b/packages/uniswap/src/i18n/i18n-setup.tsx @@ -0,0 +1,81 @@ +import 'uniswap/src/i18n/locales/@types/i18next' + +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import enUS from 'uniswap/src/i18n/locales/source/en-US.json' +import esES from 'uniswap/src/i18n/locales/translations/es-ES.json' +import frFR from 'uniswap/src/i18n/locales/translations/fr-FR.json' +import hiIN from 'uniswap/src/i18n/locales/translations/hi-IN.json' +import idID from 'uniswap/src/i18n/locales/translations/id-ID.json' +import jaJP from 'uniswap/src/i18n/locales/translations/ja-JP.json' +import msMY from 'uniswap/src/i18n/locales/translations/ms-MY.json' +import nlNL from 'uniswap/src/i18n/locales/translations/nl-NL.json' +import ptPT from 'uniswap/src/i18n/locales/translations/pt-PT.json' +import ruRU from 'uniswap/src/i18n/locales/translations/ru-RU.json' +import thTH from 'uniswap/src/i18n/locales/translations/th-TH.json' +import trTR from 'uniswap/src/i18n/locales/translations/tr-TR.json' +import ukUA from 'uniswap/src/i18n/locales/translations/uk-UA.json' +import urPK from 'uniswap/src/i18n/locales/translations/ur-PK.json' +import viVN from 'uniswap/src/i18n/locales/translations/vi-VN.json' +import zhCN from 'uniswap/src/i18n/locales/translations/zh-CN.json' +import zhTW from 'uniswap/src/i18n/locales/translations/zh-TW.json' +import { MissingI18nInterpolationError } from 'uniswap/src/i18n/shared' +import { logger } from 'utilities/src/logger/logger' + +const resources = { + 'zh-Hans': { translation: zhCN }, + 'zh-Hant': { translation: zhTW }, + 'nl-NL': { translation: nlNL }, + 'en-US': { translation: enUS }, + 'fr-FR': { translation: frFR }, + 'hi-IN': { translation: hiIN }, + 'id-ID': { translation: idID }, + 'ja-JP': { translation: jaJP }, + 'ms-MY': { translation: msMY }, + 'pt-PT': { translation: ptPT }, + 'ru-RU': { translation: ruRU }, + 'es-ES': { translation: esES }, + 'es-US': { translation: esES }, + 'es-419': { translation: esES }, + 'th-TH': { translation: thTH }, + 'tr-TR': { translation: trTR }, + 'uk-UA': { translation: ukUA }, + 'ur-PK': { translation: urPK }, + 'vi-VN': { translation: viVN }, +} + +const defaultNS = 'translation' + +i18n + .use(initReactI18next) + .init({ + defaultNS, + lng: 'en-US', + fallbackLng: 'en-US', + resources, + interpolation: { + escapeValue: false, // react already safes from xss + }, + react: { + transSupportBasicHtmlNodes: false, // disabling since this breaks for mobile + }, + missingInterpolationHandler: (text) => { + logger.error(new MissingI18nInterpolationError(`Missing i18n interpolation value: ${text}`), { + tags: { + file: 'i18n.ts', + function: 'init', + }, + }) + return '' // Using empty string for missing interpolation + }, + }) + .catch(() => undefined) + +i18n.on('missingKey', (_lngs, _ns, key, _res) => { + logger.error(new Error(`Missing i18n string key ${key} for language ${i18n.language}`), { + tags: { + file: 'i18n.ts', + function: 'onMissingKey', + }, + }) +}) diff --git a/packages/uniswap/src/i18n/i18n.ts b/packages/uniswap/src/i18n/i18n.ts index 93e5e5e8f5c..8352e5d3baa 100644 --- a/packages/uniswap/src/i18n/i18n.ts +++ b/packages/uniswap/src/i18n/i18n.ts @@ -1,88 +1,6 @@ -import 'uniswap/src/i18n/locales/@types/i18next' +import i18n from 'uniswap/src/i18n/index' -import i18n, { TFunction } from 'i18next' -import { initReactI18next } from 'react-i18next' -import enUS from 'uniswap/src/i18n/locales/source/en-US.json' -import esES from 'uniswap/src/i18n/locales/translations/es-ES.json' -import frFR from 'uniswap/src/i18n/locales/translations/fr-FR.json' -import hiIN from 'uniswap/src/i18n/locales/translations/hi-IN.json' -import idID from 'uniswap/src/i18n/locales/translations/id-ID.json' -import jaJP from 'uniswap/src/i18n/locales/translations/ja-JP.json' -import msMY from 'uniswap/src/i18n/locales/translations/ms-MY.json' -import nlNL from 'uniswap/src/i18n/locales/translations/nl-NL.json' -import ptPT from 'uniswap/src/i18n/locales/translations/pt-PT.json' -import ruRU from 'uniswap/src/i18n/locales/translations/ru-RU.json' -import thTH from 'uniswap/src/i18n/locales/translations/th-TH.json' -import trTR from 'uniswap/src/i18n/locales/translations/tr-TR.json' -import ukUA from 'uniswap/src/i18n/locales/translations/uk-UA.json' -import urPK from 'uniswap/src/i18n/locales/translations/ur-PK.json' -import viVN from 'uniswap/src/i18n/locales/translations/vi-VN.json' -import zhCN from 'uniswap/src/i18n/locales/translations/zh-CN.json' -import zhTW from 'uniswap/src/i18n/locales/translations/zh-TW.json' -import { logger } from 'utilities/src/logger/logger' - -export const resources = { - 'zh-Hans': { translation: zhCN }, - 'zh-Hant': { translation: zhTW }, - 'nl-NL': { translation: nlNL }, - 'en-US': { translation: enUS }, - 'fr-FR': { translation: frFR }, - 'hi-IN': { translation: hiIN }, - 'id-ID': { translation: idID }, - 'ja-JP': { translation: jaJP }, - 'ms-MY': { translation: msMY }, - 'pt-PT': { translation: ptPT }, - 'ru-RU': { translation: ruRU }, - 'es-ES': { translation: esES }, - 'es-US': { translation: esES }, - 'es-419': { translation: esES }, - 'th-TH': { translation: thTH }, - 'tr-TR': { translation: trTR }, - 'uk-UA': { translation: ukUA }, - 'ur-PK': { translation: urPK }, - 'vi-VN': { translation: viVN }, -} - -export const defaultNS = 'translation' - -export const changeLanguage = async (str: string): Promise<TFunction> => { - return await i18n.changeLanguage(str) -} - -export class MissingI18nInterpolationError extends Error {} - -i18n - .use(initReactI18next) - .init({ - defaultNS, - lng: 'en-US', - fallbackLng: 'en-US', - resources, - interpolation: { - escapeValue: false, // react already safes from xss - }, - react: { - transSupportBasicHtmlNodes: false, // disabling since this breaks for mobile - }, - missingInterpolationHandler: (text) => { - logger.error(new MissingI18nInterpolationError(`Missing i18n interpolation value: ${text}`), { - tags: { - file: 'i18n.ts', - function: 'init', - }, - }) - return '' // Using empty string for missing interpolation - }, - }) - .catch(() => undefined) - -i18n.on('missingKey', (_lngs, _ns, key, _res) => { - logger.error(new Error(`Missing i18n string key ${key} for language ${i18n.language}`), { - tags: { - file: 'i18n.ts', - function: 'onMissingKey', - }, - }) -}) +// TODO(WALL-3996): remove this file in favor of just index.ts +export * from './index' export default i18n diff --git a/packages/uniswap/src/i18n/index.ts b/packages/uniswap/src/i18n/index.ts new file mode 100644 index 00000000000..6d1fb6af5d1 --- /dev/null +++ b/packages/uniswap/src/i18n/index.ts @@ -0,0 +1,14 @@ +import i18n from 'i18next' + +// note: not using isInterface here for tree shaking +if (!process.env.REACT_APP_IS_UNISWAP_INTERFACE) { + require('./i18n-setup') +} + +export { t } from 'i18next' +export { Plural } from './Plural' +export { Trans } from './Trans' +export { changeLanguage } from './changeLanguage' +export { useTranslation } from './useTranslation' + +export default i18n diff --git a/packages/uniswap/src/i18n/locales.ts b/packages/uniswap/src/i18n/locales.ts new file mode 100644 index 00000000000..1aa040544cb --- /dev/null +++ b/packages/uniswap/src/i18n/locales.ts @@ -0,0 +1,72 @@ +export const SUPPORTED_LOCALES = [ + // order as they appear in the language dropdown + 'en-US', + 'af-ZA', + 'ar-SA', + 'ca-ES', + 'cs-CZ', + 'da-DK', + 'el-GR', + 'es-ES', + 'fi-FI', + 'fr-FR', + 'he-IL', + 'hu-HU', + 'id-ID', + 'it-IT', + 'ja-JP', + 'ko-KR', + 'nl-NL', + 'no-NO', + 'pl-PL', + 'pt-BR', + 'pt-PT', + 'ro-RO', + 'ru-RU', + 'sr-SP', + 'sv-SE', + 'sw-TZ', + 'tr-TR', + 'uk-UA', + 'vi-VN', + 'zh-CN', + 'zh-TW', +] + +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number] + +export const DEFAULT_LOCALE: SupportedLocale = 'en-US' + +export const LOCALE_LABEL: { [locale in SupportedLocale]: string } = { + 'af-ZA': 'Afrikaans', + 'ar-SA': 'العربية', + 'ca-ES': 'Català', + 'cs-CZ': 'čeština', + 'da-DK': 'dansk', + 'el-GR': 'ελληνικά', + 'en-US': 'English', + 'es-ES': 'Español', + 'fi-FI': 'suomi', + 'fr-FR': 'français', + 'he-IL': 'עִברִית', + 'hu-HU': 'Magyar', + 'id-ID': 'bahasa Indonesia', + 'it-IT': 'Italiano', + 'ja-JP': '日本語', + 'ko-KR': '한국어', + 'nl-NL': 'Nederlands', + 'no-NO': 'norsk', + 'pl-PL': 'Polskie', + 'pt-BR': 'português', + 'pt-PT': 'português', + 'ro-RO': 'Română', + 'ru-RU': 'русский', + 'sr-SP': 'Српски', + 'sv-SE': 'svenska', + 'sw-TZ': 'Kiswahili', + 'tr-TR': 'Türkçe', + 'uk-UA': 'Український', + 'vi-VN': 'Tiếng Việt', + 'zh-CN': '简体中文', + 'zh-TW': '繁体中文', +} diff --git a/packages/uniswap/src/i18n/locales/source/en-US.json b/packages/uniswap/src/i18n/locales/source/en-US.json index e620611808f..7dfd5698972 100644 --- a/packages/uniswap/src/i18n/locales/source/en-US.json +++ b/packages/uniswap/src/i18n/locales/source/en-US.json @@ -41,7 +41,7 @@ "account.recoveryPhrase.remove.import.description": "You can only store one recovery phrase at a time. To continue importing a new one, you’ll need to remove your current recovery phrase and any associated wallets from this device.", "account.recoveryPhrase.remove.initial.description": "This will remove your wallet from this device along with your recovery phrase.", "account.recoveryPhrase.remove.initial.title": "You’re removing <highlight>{{walletName}}</highlight>", - "account.recoveryPhrase.remove.mnemonic.description": "It shares the same recovery phrase as {{walletName}}. Your recovery phrase will remain stored until you delete all remaining wallets.", + "account.recoveryPhrase.remove.mnemonic.description": "It shares the same recovery phrase as {{walletNames, list}}. Your recovery phrase will remain stored until you delete all remaining wallets.", "account.recoveryPhrase.subtitle.import": "Your recovery phrase will only be stored locally on your device.", "account.recoveryPhrase.subtitle.restoring": "Enter your recovery phrase below, or try searching for backups again.", "account.recoveryPhrase.title.import": "Enter your recovery phrase", @@ -85,6 +85,8 @@ "account.wallet.watch.message": "Adding a view-only wallet allows you to try out the app or track a wallet. You will not be able to swap or send funds.", "account.wallet.watch.placeholder": "ENS or address", "account.wallet.watch.title": "Enter a wallet address", + "common.action.go": "Go", + "common.action.swipe": "Swipe", "common.button.accept": "Accept", "common.button.back": "Back", "common.button.buy": "Buy", @@ -123,6 +125,8 @@ "common.button.setup": "Set up", "common.button.share": "Share", "common.button.show": "Show", + "common.button.showLess": "Show less", + "common.button.showMore": "Show more", "common.button.sign": "Sign", "common.button.skip": "Skip", "common.button.swap": "Swap", @@ -132,7 +136,6 @@ "common.button.yes": "Yes", "common.card.error.description": "Something went wrong", "common.card.error.title": "Oops! Something went wrong.", - "common.endAdornment": "and", "common.error.general": "Something went wrong.", "common.input.password.confirm": "Confirm password", "common.input.password.error.mismatch": "Passwords don’t match", @@ -242,8 +245,6 @@ "extension.connection.popupWithButton": "Your wallet isn’t connected to this site.", "extension.connection.titleConnected": "Connected", "extension.connection.titleNotConnected": "Not connected", - "extension.feedback.description": "Tell us how we can improve — request features, report a bug, or anything else.", - "extension.feedback.title": "We’d love to get your feedback", "extension.lock.button.forgot": "Forgot password?", "extension.lock.button.reset": "Reset wallet", "extension.lock.button.submit": "Unlock", @@ -257,6 +258,9 @@ "extension.lock.subtitle": "Enter your password to unlock", "extension.lock.title": "Welcome back", "extension.network.notSupported": "Unsupported network", + "extension.popup.chrome.button": "Open extension", + "extension.popup.chrome.description": "Complete this action by opening the Uniswap extension.", + "extension.popup.chrome.title": "Continue in Uniswap", "extension.settings.password.enter.title": "Enter your current password", "extension.settings.password.error.wrong": "Wrong password", "extension.settings.password.placeholder": "Current password", @@ -276,6 +280,7 @@ "fiatOnRamp.error.unsupported": "Not supported in region", "fiatOnRamp.error.usd": "Only available to purchase in USD", "fiatOnRamp.quote.advice": "You’ll continue to the provider’s portal to see the fees associated with your transaction.", + "fiatOnRamp.quote.type.list": "{{optionsList}}, and other options", "fiatOnRamp.quote.type.other": "Other options", "fiatOnRamp.quote.type.recent": "Recently used", "fiatOnRamp.region.placeholder": "Search by country or region", @@ -292,12 +297,9 @@ "home.activity.empty.title": "No activity yet", "home.activity.error.load": "Couldn’t load activity", "home.activity.title": "Activity", - "home.banner.extension.confirm.beta": "Join Beta", - "home.banner.extension.confirm.default": "Download", - "home.banner.extension.message.beta": "Be the first to try out the Uniswap Extension on your web browser", - "home.banner.extension.message.default": "Download on Chrome to access this wallet from your desktop", - "home.banner.extension.title": "Uniswap Extension is here", "home.banner.offline": "You are in offline mode", + "home.explore.footer": "Tap here to explore thousands of tokens, NFTs, and more", + "home.explore.title": "Explore tokens", "home.extension.error": "Error loading accounts", "home.feed.empty.description": "When your favorited wallets makes transactions, they’ll appear here.", "home.feed.empty.title": "No activity yet", @@ -308,12 +310,6 @@ "home.label.scan": "Scan", "home.label.send": "Send", "home.label.swap": "Swap", - "home.modal.getExtension.beta.step3": "3. Enter your username to get access", - "home.modal.getExtension.beta.title": "Join the Uniswap Extension Beta", - "home.modal.getExtension.ga.step1": "1. Visit <highlight>uniswap.org/ext</highlight> on Chrome desktop", - "home.modal.getExtension.ga.step2": "2. Add the Uniswap Extension to your browser", - "home.modal.getExtension.ga.step3": "3. Scan the QR code with your Uniswap mobile app to import your wallet", - "home.modal.getExtension.ga.title": "Download the Uniswap Extension", "home.nfts.title": "NFTs", "home.tokens.empty.action.buy.description": "Purchase crypto with a debit card or a bank account.", "home.tokens.empty.action.buy.title": "Buy crypto with card", @@ -440,13 +436,17 @@ "onboarding.complete.pin.description": "Click the pin icon to add Uniswap Extension to your toolbar.", "onboarding.complete.pin.title": "Pin Uniswap Extension", "onboarding.complete.title": "You’re all set", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "Download the mobile app to claim a username", - "onboarding.extension.getOnTheBetaWaitlist.title": "Get on the Beta waitlist", "onboarding.extension.password.subtitle": "You’ll need this to unlock your wallet and access your recovery phrase", "onboarding.extension.password.title.default": "Create password", "onboarding.extension.password.title.reset": "Reset your password", + "onboarding.extension.unsupported.android.description": "Uniswap Extension is only compatible with Chrome on desktop.", + "onboarding.extension.unsupported.android.title": "Chrome on mobile is not supported (yet)", "onboarding.extension.unsupported.description": "Uniswap Extension is only compatible with Chrome right now.", "onboarding.extension.unsupported.title": "This browser is not supported (yet)", + "onboarding.home.intro.fund.description": "Fund your wallet by buying crypto or transferring from another account.", + "onboarding.home.intro.fund.title": "Get your first token", + "onboarding.home.intro.welcome.description": "Finish setting up your wallet to begin swapping in seconds.", + "onboarding.home.intro.welcome.title": "Welcome to Uniswap", "onboarding.import.error.invalidWords_one": "1 word is invalid or misspelled", "onboarding.import.error.invalidWords_other": "{{count}} words are invalid or misspelled", "onboarding.import.method.import.message": "Enter your recovery phrase from another crypto wallet", @@ -472,13 +472,6 @@ "onboarding.intro.button.alreadyHave": "I already have a wallet", "onboarding.intro.mobileScan.button": "Scan QR code to import", "onboarding.intro.mobileScan.title": "Have the Uniswap app?", - "onboarding.introBetaWaitlist.button.checkEligibility": "Check eligibility", - "onboarding.introBetaWaitlist.button.letsGo": "Let’s go", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "Enter your <highlight>uni.eth</highlight> username below to check if you’re eligible for the Beta.", - "onboarding.introBetaWaitlist.eligible.tagline": "Welcome to the Beta — you’re one of the first to try out the Uniswap Extension.", - "onboarding.introBetaWaitlist.eligible.title": "You’re off the waitlist!", - "onboarding.introBetaWaitlist.ineligibleExplanation": "You’re still on the waitlist. We’ll notify you in the Uniswap mobile app when you become eligible!", - "onboarding.introBetaWaitlist.unitagPlaceholder": "username", "onboarding.landing.button.add": "Add an existing wallet", "onboarding.landing.button.create": "Create a wallet", "onboarding.notification.permission.message": "To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.", @@ -535,10 +528,6 @@ "qrScanner.recipient.action.show": "Show my QR code", "qrScanner.recipient.error.message": "Make sure that you’re scanning a valid Ethereum address QR code before trying again.", "qrScanner.recipient.error.title": "Invalid QR Code", - "qrScanner.recipient.input.placeholder": "Search ENS or address", - "qrScanner.recipient.label.send": "Send", - "qrScanner.recipient.results.empty": "No results found", - "qrScanner.recipient.results.error": "The address you typed either does not exist or is spelled incorrectly.", "qrScanner.request.message.unavailable": "No message found.", "qrScanner.request.method.default": "Request from {{dappNameOrUrl}}", "qrScanner.request.method.signature": "Signature request from {{dappNameOrUrl}}", @@ -572,8 +561,12 @@ "send.gas.error.title": "N/A", "send.gas.networkCost.title": "Network cost", "send.input.token.balance.title": "Balance: {{balance}} {{symbol}}", + "send.recipient.header": "Select recipient", + "send.recipient.input.placeholder": "Search ENS or address", "send.recipient.previous_one": "1 previous transfer", "send.recipient.previous_other": "{{count}} previous transfers", + "send.recipient.results.empty": "No results found", + "send.recipient.results.error": "The address you typed either does not exist or is spelled incorrectly.", "send.recipient.section.favorite": "Favorite wallets", "send.recipient.section.recent": "Recent", "send.recipient.section.search": "Search results", @@ -609,6 +602,9 @@ "send.warning.modal.button.cta.blocking": "OK", "send.warning.modal.button.cta.cancel": "Cancel", "send.warning.modal.button.cta.confirm": "Confirm", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "Username", + "send.warning.newAddress.details.walletAddress": "Wallet Address", "send.warning.newAddress.message": "You haven’t transacted with this address before. Please confirm that the address is correct before continuing.", "send.warning.newAddress.title": "New address", "send.warning.restore": "Restore your wallet to send", @@ -705,7 +701,6 @@ "settings.setting.biometrics.warning.message.ios": "If you don’t turn on {{biometricsMethod}}, anyone who gains access to your device can open Uniswap Wallet and make transactions.", "settings.setting.biometrics.warning.title": "Are you sure?", "settings.setting.currency.title": "Local currency", - "settings.setting.giveFeedback.title": "Share feedback", "settings.setting.helpCenter.title": "Help center", "settings.setting.language.button.navigate": "Go to settings", "settings.setting.language.description.extension": "Uniswap defaults to your system language settings. To change your preferred language, go to your system settings.", @@ -738,8 +733,6 @@ "swap.button.unwrap": "Unwrap", "swap.button.view": "View transaction", "swap.button.wrap": "Wrap", - "swap.details.action.less": "Show less", - "swap.details.action.more": "Show more", "swap.details.feeOnTransfer": "{{tokenSymbol}} fee", "swap.details.newQuote.input": "New input", "swap.details.newQuote.output": "New output", @@ -787,6 +780,7 @@ "swap.warning.insufficientBalance.button": "Not enough {{currencySymbol}}", "swap.warning.insufficientBalance.title": "You don’t have enough {{currencySymbol}}", "swap.warning.insufficientGas.button": "Not enough {{currencySymbol}}", + "swap.warning.insufficientGas.button.buy": "Buy {{ tokenSymbol }}", "swap.warning.insufficientGas.message.withNetwork": "Not enough <highlight>{{currencySymbol}} on {{networkName}}</highlight> to swap", "swap.warning.insufficientGas.message.withoutNetwork": "Not enough <highlight>{{currencySymbol}}</highlight> to swap", "swap.warning.insufficientGas.title": "You don’t have enough {{currencySymbol}} to cover the network cost", @@ -846,6 +840,8 @@ "token.stats.translation.original": "Show original", "token.stats.translation.translate": "Translate to {{language}}", "token.stats.volume": "24h Volume", + "token.zeroNativeBalance.description": "To get {{ tokenSymbol }}, you first need {{ nativeTokenSymbol }} to pay for the network cost. Get started by funding your wallet with {{ nativeTokenSymbol }}.", + "token.zeroNativeBalance.title": "You need {{ nativeTokenName }} ", "tokens.action.hide": "Hide Token", "tokens.action.unhide": "Unhide Token", "tokens.hidden.label": "Hidden ({{numHidden}})", @@ -892,17 +888,18 @@ "transaction.action.cancel.description": "If you cancel this transaction before it’s processed by the network, you’ll pay a new network cost instead of the original one.", "transaction.action.cancel.title": "Cancel this transaction?", "transaction.action.copy": "Copy transaction ID", - "transaction.action.copyMoonPay": "Copy MoonPay transaction ID", + "transaction.action.copyProvider": "Copy {{providerName}} transaction ID", "transaction.action.view": "View {{tokenSymbol}}", "transaction.action.viewEtherscan": "View on {{blockExplorerName}}", - "transaction.action.viewMoonPay": "View on MoonPay", "transaction.amount.unlimited": "Unlimited", "transaction.currency.unknown": "unknown token", "transaction.date": "Submitted on {{date}}", "transaction.details.dappName": "App", "transaction.details.from": "From", "transaction.details.networkFee": "Network cost", + "transaction.details.swapRate": "Rate", "transaction.details.transactionId": "Transaction ID", + "transaction.details.uniswapFee": "Fee ({{ feePercent }}%)", "transaction.network.all": "All networks", "transaction.networkCost.label": "Network cost", "transaction.notification.error.cancel": "Unable to cancel transaction", @@ -1091,6 +1088,8 @@ "walletConnect.request.button.sign": "Sign", "walletConnect.request.details.label.function": "Function", "walletConnect.request.details.label.sending": "Sending", + "walletConnect.request.details.label.token": "Token", + "walletConnect.request.details.label.tokens": "Tokens", "walletConnect.request.error.insufficientFunds": "You don’t have enough {{currencySymbol}} to complete this transaction.", "walletConnect.request.error.network": "Internet or network connection error", "walletConnect.request.warning.general.message": "Be careful: this message may transfer assets", diff --git a/packages/uniswap/src/i18n/locales/translations/es-ES.json b/packages/uniswap/src/i18n/locales/translations/es-ES.json index 3b958f45fb7..69d5e0af9a5 100644 --- a/packages/uniswap/src/i18n/locales/translations/es-ES.json +++ b/packages/uniswap/src/i18n/locales/translations/es-ES.json @@ -41,7 +41,7 @@ "account.recoveryPhrase.remove.import.description": "Solo puedes almacenar una frase de recuperación a la vez. Para seguir importando una nueva, deberás eliminar la frase de recuperación actual y todas las wallets asociadas de este dispositivo.", "account.recoveryPhrase.remove.initial.description": "Esto eliminará tu wallet de este dispositivo, junto con tu frase de recuperación.", "account.recoveryPhrase.remove.initial.title": "Eliminarás <highlight>{{walletName}}</highlight>", - "account.recoveryPhrase.remove.mnemonic.description": "Comparte la misma frase de recuperación que {{walletName}}. Tu frase de recuperación permanecerá almacenada hasta que elimines todas las wallets restantes.", + "account.recoveryPhrase.remove.mnemonic.description": "Comparte la misma frase de recuperación que {{walletNames, list}}. Tu frase de recuperación permanecerá almacenada hasta que elimines todas las wallets restantes.", "account.recoveryPhrase.subtitle.import": "Tu frase de recuperación solo se almacenará localmente en el dispositivo.", "account.recoveryPhrase.subtitle.restoring": "Ingresa tu frase de recuperación a continuación o intenta buscar respaldos nuevamente.", "account.recoveryPhrase.title.import": "Ingresa tu frase de recuperación", @@ -75,7 +75,7 @@ "account.wallet.select.loading.subtitle": "Tus wallets aparecerán a continuación.", "account.wallet.select.loading.title": "Buscando wallets", "account.wallet.select.title_one_one": "Se encontró una wallet", - "account.wallet.select.title_one_other": "Seleccionar wallets para importar", + "account.wallet.select.title_one_other": "Selecciona wallets para importar", "account.wallet.viewOnly.button": "Importar wallet", "account.wallet.viewOnly.description": "Para intercambiar, comprar, enviar y recibir tokens, debes importar la frase de recuperación de esta wallet.", "account.wallet.viewOnly.title": "Esta wallet es de solo visualización", @@ -85,6 +85,8 @@ "account.wallet.watch.message": "Agregar una wallet de solo visualización te permite probar la aplicación o rastrear una wallet. No podrás intercambiar ni enviar fondos.", "account.wallet.watch.placeholder": "ENS o dirección", "account.wallet.watch.title": "Ingresa una dirección de wallet", + "common.action.go": "Ir", + "common.action.swipe": "Deslizar", "common.button.accept": "Aceptar", "common.button.back": "Atrás", "common.button.buy": "Comprar", @@ -123,6 +125,8 @@ "common.button.setup": "Configurar", "common.button.share": "Compartir", "common.button.show": "Mostrar", + "common.button.showLess": "Mostrar menos", + "common.button.showMore": "Mostrar más", "common.button.sign": "Firmar", "common.button.skip": "Saltar", "common.button.swap": "Intercambiar", @@ -132,7 +136,6 @@ "common.button.yes": "Sí", "common.card.error.description": "Algo salió mal", "common.card.error.title": "Lo sentimos. Algo salió mal.", - "common.endAdornment": "y", "common.error.general": "Algo salió mal.", "common.input.password.confirm": "Confirmar contraseña", "common.input.password.error.mismatch": "Las contraseñas no coinciden", @@ -228,8 +231,8 @@ "explore.tokens.sort.option.marketCap": "Capitalización de mercado", "explore.tokens.sort.option.priceDecrease": "Disminución de precio (24 h)", "explore.tokens.sort.option.priceIncrease": "Incremento de precio (24 h)", - "explore.tokens.sort.option.totalValueLocked": "TVL Uniswap", - "explore.tokens.sort.option.volume": "Volumen Uniswap (24 h)", + "explore.tokens.sort.option.totalValueLocked": "TVL de Uniswap", + "explore.tokens.sort.option.volume": "Volumen de Uniswap (24 h)", "explore.tokens.top.title": "Tokens principales", "explore.wallets.favorite.action.add": "Wallet favorita", "explore.wallets.favorite.action.edit": "Editar favoritos", @@ -238,12 +241,10 @@ "explore.wallets.favorite.title.edit": "Editar wallets favoritas", "extension.connection.networks": "Redes", "extension.connection.popup": "Tu wallet no está conectada a este sitio. Busca el botón “Conectar wallet” o “Iniciar sesión”.", - "extension.connection.popup.trouble": "¿Problemas para conectarse?", + "extension.connection.popup.trouble": "¿Tienes problemas para conectarte?", "extension.connection.popupWithButton": "Tu wallet no está conectada a este sitio.", - "extension.connection.titleConnected": "Conectada", + "extension.connection.titleConnected": "Se conectó", "extension.connection.titleNotConnected": "No conectada", - "extension.feedback.description": "Dinos cómo podemos mejorar: solicita características, informa un error, o cualquier otra cosa.", - "extension.feedback.title": "Nos encantaría recibir tus comentarios", "extension.lock.button.forgot": "¿Olvidaste tu contraseña?", "extension.lock.button.reset": "Restablecer wallet", "extension.lock.button.submit": "Desbloquear", @@ -257,6 +258,9 @@ "extension.lock.subtitle": "Ingresa tu contraseña para desbloquear", "extension.lock.title": "Bienvenido de nuevo", "extension.network.notSupported": "Red no compatible", + "extension.popup.chrome.button": "Abrir extensión", + "extension.popup.chrome.description": "Completa esta acción abriendo la extensión de Uniswap.", + "extension.popup.chrome.title": "Continua en Uniswap", "extension.settings.password.enter.title": "Ingresa tu contraseña actual", "extension.settings.password.error.wrong": "Contraseña incorrecta", "extension.settings.password.placeholder": "Contraseña actual", @@ -275,7 +279,8 @@ "fiatOnRamp.error.unavailable": "Este servicio no está disponible en tu región", "fiatOnRamp.error.unsupported": "No es compatible en la región", "fiatOnRamp.error.usd": "Solo está disponible para comprar en USD", - "fiatOnRamp.quote.advice": "Continuarás al portal del proveedor para ver las tarifas asociadas con tu transacción.", + "fiatOnRamp.quote.advice": "Continuarás al portal del proveedor para ver las tarifas asociadas a tu transacción.", + "fiatOnRamp.quote.type.list": "{{optionsList}}, y otras opciones", "fiatOnRamp.quote.type.other": "Otras opciones", "fiatOnRamp.quote.type.recent": "Usadas recientemente", "fiatOnRamp.region.placeholder": "Buscar por país o región", @@ -288,16 +293,13 @@ "forceUpgrade.title": "Actualiza la aplicación para continuar", "home.activity.empty.button": "Recibir tokens o NFT", "home.activity.empty.description.default": "Cuando apruebes, compres, vendas o transfieras tokens o NFT, tus transacciones aparecerán aquí.", - "home.activity.empty.description.external": "Cuando esta wallet realice transacciones, aparecerán aquí.", + "home.activity.empty.description.external": "Cuando esta billetera realice transacciones, aparecerán aquí.", "home.activity.empty.title": "Aún no hay actividad", "home.activity.error.load": "No se pudo cargar la actividad", "home.activity.title": "Actividad", - "home.banner.extension.confirm.beta": "Unirse a la versión beta", - "home.banner.extension.confirm.default": "Descargar", - "home.banner.extension.message.beta": "Sé el primero en probar la extensión Uniswap en tu navegador web", - "home.banner.extension.message.default": "Descárgalo en Chrome para acceder a esta wallet desde tu escritorio", - "home.banner.extension.title": "La extensión Uniswap está aquí", "home.banner.offline": "Estás en modo fuera de línea", + "home.explore.footer": "Toque aquí para explorar miles de tokens, NFT y más", + "home.explore.title": "Explorar tokens", "home.extension.error": "Error al cargar cuentas", "home.feed.empty.description": "Cuando tus wallets favoritas realicen transacciones, aparecerán aquí.", "home.feed.empty.title": "Aún no hay actividad", @@ -308,12 +310,6 @@ "home.label.scan": "Escanear", "home.label.send": "Enviar", "home.label.swap": "Intercambiar", - "home.modal.getExtension.beta.step3": "3. Ingresa tu nombre de usuario para obtener acceso", - "home.modal.getExtension.beta.title": "Únete a la versión beta de la extensión Uniswap", - "home.modal.getExtension.ga.step1": "1. Visita <highlight>uniswap.org/ext</highlight> en Chrome para escritorio", - "home.modal.getExtension.ga.step2": "2. Agrega la extensión Uniswap a tu navegador", - "home.modal.getExtension.ga.step3": "3. Escanea el código QR con tu aplicación móvil Uniswap para importar tu wallet", - "home.modal.getExtension.ga.title": "Descarga la extensión Uniswap", "home.nfts.title": "NFT", "home.tokens.empty.action.buy.description": "Comprar cripto con una tarjeta de débito o una cuenta bancaria.", "home.tokens.empty.action.buy.title": "Comprar cripto con tarjeta", @@ -350,10 +346,10 @@ "language.urdu": "Urdu", "language.vietnamese": "Vietnamita", "mobile.appRating.button.decline": "No realmente", - "mobile.appRating.description": "Háganos saber si está teniendo una buena experiencia con esta aplicación.", - "mobile.appRating.feedback.button.cancel": "Quizá después", + "mobile.appRating.description": "Avísanos si tienes una buena experiencia con esta aplicación", + "mobile.appRating.feedback.button.cancel": "Quizás después", "mobile.appRating.feedback.button.send": "Enviar comentarios", - "mobile.appRating.feedback.description": "Haznos saber cómo podemos mejorar tu experiencia.", + "mobile.appRating.feedback.description": "Avísanos cómo podemos mejorar tu experiencia", "mobile.appRating.feedback.title": "Lo sentimos mucho.", "mobile.appRating.title": "¿Estás disfrutando de Uniswap Wallet?", "notification.assetVisibility.hidden": "{{assetName}} oculto", @@ -368,7 +364,7 @@ "notification.copied.transactionId": "Se copió la ID de la transacción", "notification.countryChange": "Se cambió a {{countryName}}", "notification.network.changed": "Se cambió a {{network}}", - "notification.passwordChanged": "Contraseña cambiada", + "notification.passwordChanged": "Se cambió la contraseña", "notification.restore.success": "¡Se restauró la wallet!", "notification.send.network": "Enviando en {{network}}", "notification.swap.network": "Intercambiando en {{network}}", @@ -390,7 +386,7 @@ "notification.transaction.unknown.fail.full": "No se pudo realizar la transacción con {{addressOrEnsName}}", "notification.transaction.unknown.fail.short": "Transacción fallida", "notification.transaction.unknown.success.full": "La transacción se realizó con {{addressOrEnsName}}", - "notification.transaction.unknown.success.short": "Transacción confirmada", + "notification.transaction.unknown.success.short": "Se confirmó la transacción", "notification.transaction.unwrap.canceled": "Se canceló la desenvoltura de {{inputCurrencySymbol}}.", "notification.transaction.unwrap.fail": "No se pudo desenvolver {{inputCurrencyAmountWithSymbol}}.", "notification.transaction.unwrap.success": "Se desenvolvieron {{inputCurrencyAmountWithSymbol}} y se recibieron {{outputCurrencyAmountWithSymbol}}.", @@ -405,24 +401,24 @@ "notification.walletConnect.networkChanged.full": "Se cambió a {{networkName}}", "notification.walletConnect.networkChanged.short": "Se cambió de red", "notifications.scantastic.subtitle": "Continuar en la extensión Uniswap", - "notifications.scantastic.title": "¡Éxito!", + "notifications.scantastic.title": "¡Listo!", "onboarding.backup.manual.banner": "Es mejor escribir esto en una hoja de papel y guardarlo en un lugar seguro o en un administrador de contraseñas seguro.", "onboarding.backup.manual.error": "Palabra no válida o mal escrita", "onboarding.backup.manual.placeholder": "Palabra secreta", "onboarding.backup.manual.progress": "Se completaron {{completedStepsCount}}/{{totalStepsCount}}", - "onboarding.backup.manual.selectedWordPlaceholder": "Seleccionar palabra", - "onboarding.backup.manual.subtitle_one": "¿Cuál es la {{count}}.a palabra en tu frase de recuperación?", - "onboarding.backup.manual.subtitle_two": "¿Cuál es la {{count}}.a palabra en tu frase de recuperación?", - "onboarding.backup.manual.subtitle_few": "¿Cuál es la {{count}}.a palabra en tu frase de recuperación?", - "onboarding.backup.manual.subtitle_other": "¿Cuál es la {{count}}.a palabra en tu frase de recuperación?", - "onboarding.backup.manual.title": "Asegurémonos de que la grabaste correctamente", - "onboarding.backup.option.cloud.description": "Encripta tu frase de recuperación con una contraseña segura", + "onboarding.backup.manual.selectedWordPlaceholder": "Selecciona la palabra", + "onboarding.backup.manual.subtitle_one": "¿Cuál es la {{count}}a palabra en tu frase de recuperación?", + "onboarding.backup.manual.subtitle_two": "¿Cuál es la {{count}}a palabra en tu frase de recuperación?", + "onboarding.backup.manual.subtitle_few": "¿Cuál es la {{count}}a palabra en tu frase de recuperación?", + "onboarding.backup.manual.subtitle_other": "¿Cuál es la {{count}}a palabra en tu frase de recuperación?", + "onboarding.backup.manual.title": "Asegurémonos de que la guardaste correctamente", + "onboarding.backup.option.cloud.description": "Cifra tu frase de recuperación con una contraseña segura", "onboarding.backup.option.cloud.title": "Respaldar en {{cloudProviderName}}", "onboarding.backup.option.manual.description": "Escribe tu frase de recuperación y guárdala en un lugar seguro", "onboarding.backup.option.manual.title": "Respaldo manual", "onboarding.backup.subtitle": "Los respaldos te permiten restaurar la wallet si eliminas la aplicación o pierdes tu dispositivo", - "onboarding.backup.title.existing": "Hacer un respaldo de tu wallet", - "onboarding.backup.title.new": "Elegir un método de respaldo", + "onboarding.backup.title.existing": "Haz un respaldo de tu wallet", + "onboarding.backup.title.new": "Elige un método de respaldo", "onboarding.backup.view.disclaimer": "Entiendo que si pierdo mi frase de recuperación, Uniswap Labs no puede ayudarme a restaurarla.", "onboarding.backup.view.subtitle.message1": "Lee atentamente lo siguiente antes de continuar", "onboarding.backup.view.subtitle.message2": "Deberás ingresar las 12 palabras secretas para recuperar tu wallet.", @@ -435,18 +431,22 @@ "onboarding.cloud.createPassword.description": "Para recuperar tu wallet, deberás ingresar esta contraseña.", "onboarding.cloud.createPassword.title": "Crea tu contraseña de respaldo", "onboarding.complete.button": "Abrir extensión Uniswap", - "onboarding.complete.description": "Tu wallet está lista para enviar y recibir criptomonedas. Abre la extensión Uniswap con el atajo que se indica a continuación.", + "onboarding.complete.description": "Tu wallet está lista para enviar y recibir cripto. Abre la extensión Uniswap con el atajo que se indica a continuación.", "onboarding.complete.go_to_uniswap": "Ve a la aplicación web Uniswap", "onboarding.complete.pin.description": "Haz clic en el ícono de alfiler para agregar la extensión Uniswap a tu barra de herramientas.", "onboarding.complete.pin.title": "Fijar extensión Uniswap", "onboarding.complete.title": "Está todo listo", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "Descarga la aplicación móvil para reclamar un nombre de usuario", - "onboarding.extension.getOnTheBetaWaitlist.title": "Inscríbete en la lista de espera de la versión beta", "onboarding.extension.password.subtitle": "Necesitarás esto para desbloquear tu wallet y acceder a tu frase de recuperación", "onboarding.extension.password.title.default": "Crear contraseña", "onboarding.extension.password.title.reset": "Restablecer la contraseña", + "onboarding.extension.unsupported.android.description": "La extensión Uniswap solo es compatible con Chrome en el escritorio.", + "onboarding.extension.unsupported.android.title": "Chrome en dispositivos móviles no es compatible (aún)", "onboarding.extension.unsupported.description": "La extensión Uniswap solo es compatible con Chrome en este momento.", "onboarding.extension.unsupported.title": "Este navegador no es compatible (todavía)", + "onboarding.home.intro.fund.description": "Deposita fondos en tu wallet comprando cripto o transfiriendo desde otra cuenta.", + "onboarding.home.intro.fund.title": "Consigue tu primer token", + "onboarding.home.intro.welcome.description": "Finaliza la configuración de tu wallet para comenzar a intercambiar en solo segundos.", + "onboarding.home.intro.welcome.title": "Bienvenido a Uniswap", "onboarding.import.error.invalidWords_one": "1 palabra no es válida o está mal escrita", "onboarding.import.error.invalidWords_other": "{{count}} palabras no son válidas o están mal escritas", "onboarding.import.method.import.message": "Ingresa la frase de recuperación de otra wallet de cripto", @@ -455,15 +455,15 @@ "onboarding.import.method.restore.message.ios": "Agrega wallets de las que hayas realizado un respaldo a tu cuenta de iCloud", "onboarding.import.method.restore.title": "Restaurar una wallet", "onboarding.import.onDeviceRecovery.other_options": "Crear o importar una wallet diferente", - "onboarding.import.onDeviceRecovery.other_options.label": "¿No quieres nada de lo anterior?", - "onboarding.import.onDeviceRecovery.subtitle": "Elige en qué wallet quieres volver a iniciar sesión.", + "onboarding.import.onDeviceRecovery.other_options.label": "¿No deseas nada de lo anterior?", + "onboarding.import.onDeviceRecovery.subtitle": "Elige en qué wallet deseas volver a iniciar sesión.", "onboarding.import.onDeviceRecovery.title": "Bienvenido de nuevo a Uniswap", "onboarding.import.onDeviceRecovery.wallet.button": "Ver frase de recuperación", "onboarding.import.onDeviceRecovery.wallet.count_one": "+1 otra wallet", "onboarding.import.onDeviceRecovery.wallet.count_other": "+{{count}} otras wallets", "onboarding.import.onDeviceRecovery.warning.caption": "Asegúrate de haber respaldado todas las otras wallets. Si alguna vez deseas restaurarlas, necesitarás sus frases de recuperación o los respaldos de iCloud respectivos.", "onboarding.import.onDeviceRecovery.warning.title": "¿Estás seguro?", - "onboarding.import.title": "Elige cómo quieres agregar tu wallet", + "onboarding.import.title": "Elige cómo deseas agregar tu wallet", "onboarding.importMnemonic.button.default": "Mi frase de recuperación tiene 12 palabras", "onboarding.importMnemonic.button.longPhrase": "Mi frase de recuperación es más larga", "onboarding.importMnemonic.error.invalidPhrase": "La frase que ingresaste no es válida", @@ -471,14 +471,7 @@ "onboarding.importMnemonic.title": "Ingresa tu frase de recuperación", "onboarding.intro.button.alreadyHave": "Ya tengo una wallet", "onboarding.intro.mobileScan.button": "Escanea el código QR para importar", - "onboarding.intro.mobileScan.title": "¿Tiene la aplicación Uniswap?", - "onboarding.introBetaWaitlist.button.checkEligibility": "Verificar elegibilidad", - "onboarding.introBetaWaitlist.button.letsGo": "Vamos", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "Ingresa tu nombre de usuario de <highlight>uni.eth</highlight> a continuación para verificar si eres elegible para la versión beta.", - "onboarding.introBetaWaitlist.eligible.tagline": "Bienvenido a la versión beta: eres uno de los primeros en probar la extensión Uniswap.", - "onboarding.introBetaWaitlist.eligible.title": "¡Saliste de la lista de espera!", - "onboarding.introBetaWaitlist.ineligibleExplanation": "Todavía estás en la lista de espera. ¡Te notificaremos en la aplicación móvil de Uniswap cuando seas elegible!", - "onboarding.introBetaWaitlist.unitagPlaceholder": "Nombre de usuario", + "onboarding.intro.mobileScan.title": "¿Tienes la aplicación Uniswap?", "onboarding.landing.button.add": "Agregar una wallet existente", "onboarding.landing.button.create": "Crear una wallet", "onboarding.notification.permission.message": "Para recibir notificaciones, activa las notificaciones de Uniswap Wallet en la configuración de tu dispositivo.", @@ -496,15 +489,15 @@ "onboarding.recoveryPhrase.warning.screenshot.message": "Cualquiera que obtenga acceso a tus fotos podrá acceder a tu wallet. Te recomendamos que anotes las palabras.", "onboarding.recoveryPhrase.warning.screenshot.title": "Las capturas de pantalla no son seguras", "onboarding.resetPassword.complete.safety": "Obtén más información sobre la seguridad de la wallet", - "onboarding.resetPassword.complete.subtitle": "Utiliza tu nueva contraseña para desbloquear tu wallet.", + "onboarding.resetPassword.complete.subtitle": "Usa tu nueva contraseña para desbloquear tu wallet.", "onboarding.resetPassword.complete.title": "Restablecimiento de contraseña", "onboarding.scan.button": "Escanear con la aplicación Uniswap", "onboarding.scan.error": "Lo sentimos, no podemos cargar el código QR en este momento. Prueba con otro método de incorporación.", "onboarding.scan.otp.error": "El código que enviaste es incorrecto o hubo un error al enviarlo. Inténtalo de nuevo.", "onboarding.scan.otp.failed": "Intentos fallidos: {{number}}", "onboarding.scan.otp.subtitle": "Consulta la aplicación móvil Uniswap para ver el código de 6 caracteres", - "onboarding.scan.otp.title": "Ingresar el código de un solo uso", - "onboarding.scan.subtitle": "Escanear el código QR con la aplicación móvil Uniswap para comenzar a importar la wallet.", + "onboarding.scan.otp.title": "Ingresa el código de un solo uso", + "onboarding.scan.subtitle": "Escanea el código QR con la aplicación móvil Uniswap para comenzar a importar la wallet.", "onboarding.scan.title": "Importar wallet desde la aplicación", "onboarding.scan.wifi": "Conecta tu teléfono a la misma red WiFi que tu computadora.", "onboarding.security.alert.biometrics.message.android": "Para usar datos biométricos, primero configúralos en Configuración", @@ -516,9 +509,9 @@ "onboarding.security.button.setup": "Configurar", "onboarding.security.subtitle.android": "Agrega una capa adicional de seguridad al requerir datos biométricos para enviar transacciones.", "onboarding.security.subtitle.ios": "Agrega una capa adicional de seguridad al requerir {{biometricsMethod}} para enviar transacciones.", - "onboarding.security.title": "Proteger tu wallet", + "onboarding.security.title": "Protege tu wallet", "onboarding.selectWallets.error": "No se pudieron cargar direcciones", - "onboarding.selectWallets.title.default": "Seleccionar wallets para importar", + "onboarding.selectWallets.title.default": "Elije wallets para importar", "onboarding.selectWallets.title.error": "Error al importar wallets", "onboarding.termsOfService": "Al continuar, acepto los<highlightTerms> Términos de servicio </highlightTerms>y doy mi consentimiento a la<highlightPrivacy> Política de privacidad</highlightPrivacy>", "onboarding.tooltip.recoveryPhrase.trigger": "¿Qué es una frase de recuperación?", @@ -526,8 +519,8 @@ "onboarding.wallet.defaultName": "Wallet {{number}}", "onboarding.wallet.description.full": "Este es tu espacio personal para tokens, NFT y todas tus operaciones. Termina de configurarlo para mantener tus fondos seguros.", "onboarding.wallet.title": "Bienvenido a tu nueva wallet", - "qrScanner.button.connections_one": "1 aplicación conectada", - "qrScanner.button.connections_other": "{{count}} aplicaciones conectadas", + "qrScanner.button.connections_one": "Se conectó 1 aplicación", + "qrScanner.button.connections_other": "Se conectaron {{count}} aplicaciones", "qrScanner.error.camera.message": "Para escanear un código, permite el acceso a la cámara en la configuración del sistema", "qrScanner.error.camera.title": "La cámara está deshabilitada", "qrScanner.error.none": "No se encontró ningún código QR", @@ -535,10 +528,6 @@ "qrScanner.recipient.action.show": "Mostrar mi código QR", "qrScanner.recipient.error.message": "Asegúrate de escanear un código QR de dirección Ethereum válido antes de volver a intentarlo.", "qrScanner.recipient.error.title": "Código QR no válido", - "qrScanner.recipient.input.placeholder": "Buscar ENS o dirección", - "qrScanner.recipient.label.send": "Enviar", - "qrScanner.recipient.results.empty": "No se encontraron resultados", - "qrScanner.recipient.results.error": "La dirección que ingresaste no existe o está mal escrita.", "qrScanner.request.message.unavailable": "No se encontró ningún mensaje.", "qrScanner.request.method.default": "Solicitud de {{dappNameOrUrl}}", "qrScanner.request.method.signature": "Solicitud de firma de {{dappNameOrUrl}}", @@ -550,7 +539,7 @@ "qrScanner.title": "Escanear un código QR", "qrScanner.wallet.title": "Puedes recibir tokens e NFT en Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast y BNB Chain.", "scantastic.code.expired": "Expiró", - "scantastic.code.subtitle": "Ingresa este código en la extensión Uniswap. Tu frase de recuperación se cifrará y transferirá de forma segura.", + "scantastic.code.subtitle": "Ingresa este código en la extensión Uniswap. Tu frase de recuperación se cifrará y transferirá de manera segura.", "scantastic.code.timeRemaining.shorthand.hours": "Nuevo código en {{hours}} h, {{minutes}} min, {{seconds}} s", "scantastic.code.timeRemaining.shorthand.minutes": "Nuevo código en {{minutes}} min, {{seconds}} s", "scantastic.code.timeRemaining.shorthand.seconds": "Nuevo código en {{seconds}} s", @@ -558,7 +547,7 @@ "scantastic.confirmation.button.continue": "Sí, continuar", "scantastic.confirmation.label.browser": "Navegador", "scantastic.confirmation.label.device": "Dispositivo", - "scantastic.confirmation.subtitle": "Continúa solo si estás escaneando un código QR de Uniswap Extension en un dispositivo confiable.", + "scantastic.confirmation.subtitle": "Continúa solo si estás escaneando un código QR de la extensión Uniswap en un dispositivo confiable.", "scantastic.confirmation.title": "¿Intentas importar tu wallet?", "scantastic.confirmation.warning": "Ten cuidado con los sitios y las aplicaciones que se hacen pasar por Uniswap. De lo contrario, tu wallet podría verse comprometida.", "scantastic.error.encryption": "No se pudo preparar la frase de recuperación.", @@ -572,8 +561,12 @@ "send.gas.error.title": "N/A", "send.gas.networkCost.title": "Costo de red", "send.input.token.balance.title": "Saldo: {{balance}} {{symbol}}", + "send.recipient.header": "Seleccionar destinatario", + "send.recipient.input.placeholder": "Buscar ENS o dirección", "send.recipient.previous_one": "1 transferencia anterior", "send.recipient.previous_other": "{{count}} transferencias anteriores", + "send.recipient.results.empty": "No se encontraron resultados", + "send.recipient.results.error": "La dirección que ingresaste no existe o está mal escrita.", "send.recipient.section.favorite": "Wallets favoritas", "send.recipient.section.recent": "Reciente", "send.recipient.section.search": "Resultados de la búsqueda", @@ -594,7 +587,7 @@ "send.status.inProgress.description": "Te notificaremos luego que se complete la transacción.", "send.status.inProgress.title": "Enviando", "send.status.success.description": "Enviaste {{currencyAmount}}{{tokenName}}{{fiatValue}} a {{recipient}}.", - "send.status.success.title": "¡Envío exitoso!", + "send.status.success.title": "¡Envío correcto!", "send.title": "Enviar", "send.warning.blocked.default": "Esta wallet está bloqueada", "send.warning.blocked.modal.message": "Esta dirección está bloqueada en Uniswap Wallet porque está asociada a una o más actividades bloqueadas. Si crees que se trata de un error, envía un correo electrónico a compliance@uniswap.org.", @@ -609,10 +602,13 @@ "send.warning.modal.button.cta.blocking": "Aceptar", "send.warning.modal.button.cta.cancel": "Cancelar", "send.warning.modal.button.cta.confirm": "Confirmar", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "Nombre de usuario", + "send.warning.newAddress.details.walletAddress": "Dirección de la billetera", "send.warning.newAddress.message": "No has realizado transacciones con esta dirección antes. Verifica que la dirección sea correcta antes de continuar.", "send.warning.newAddress.title": "Nueva dirección", "send.warning.restore": "Restaura tu wallet para enviar", - "send.warning.self.message": "Estás intentando enviar fondos a tu wallet actual. Enviar criptomonedas a esta dirección generará costos de red innecesarios.", + "send.warning.self.message": "Estás intentando enviar fondos a tu wallet actual. El envío de criptomonedas a esta dirección generará costos de red innecesarios.", "send.warning.self.title": "Esta es tu wallet actual", "send.warning.smartContract.message": "Estás a punto de enviar tokens a un tipo de dirección especial: un contrato inteligente. Vuelve a verificar que sea la dirección a la que deseas enviarlos. Si la dirección no está correcta, podrías perder tus tokens para siempre.", "send.warning.smartContract.title": "¿Es esta una dirección de wallet?", @@ -659,7 +655,7 @@ "settings.setting.appearance.option.light.subtitle": "Usar siempre el modo claro", "settings.setting.appearance.option.light.title": "Modo claro", "settings.setting.appearance.title": "Aspecto", - "settings.setting.backup.create.description": "Establecer una contraseña cifrará el respaldo de tu frase de recuperación, lo que agregará un nivel adicional de protección si tu cuenta de {{cloudProviderName}} alguna vez se ve comprometida.", + "settings.setting.backup.create.description": "La creación de una contraseña cifrará el respaldo de tu frase de recuperación, lo que agregará un nivel adicional de protección si tu cuenta de {{cloudProviderName}} alguna vez se ve comprometida.", "settings.setting.backup.create.title": "Hacer un respaldo en {{cloudProviderName}}", "settings.setting.backup.delete.confirm.message": "Debido a que estas wallets comparten una frase de recuperación, también se eliminarán los respaldos de estas wallets a continuación", "settings.setting.backup.delete.confirm.title": "¿Estás seguro?", @@ -697,7 +693,7 @@ "settings.setting.biometrics.transactions.subtitle.android": "Solicitar datos biométricos para realizar transacciones", "settings.setting.biometrics.transactions.subtitle.ios": "Solicitar {{biometricsMethod}} para realizar transacciones", "settings.setting.biometrics.transactions.title": "Transacciones", - "settings.setting.biometrics.unavailable.message.android": "La biometría no está configurada en el dispositivo. Para usar datos biométricos, configúralo primero en Configuración.", + "settings.setting.biometrics.unavailable.message.android": "La biometría no está configurada en el dispositivo. Para usar datos biométricos, configúralos primero en Configuración.", "settings.setting.biometrics.unavailable.message.ios": "{{biometricsMethod}} no está configurado en el dispositivo. Para usar {{biometricsMethod}}, configúrala primero en Configuración.", "settings.setting.biometrics.unavailable.title.android": "Los datos biométricos no están configurados", "settings.setting.biometrics.unavailable.title.ios": "{{biometricsMethod}} no está configurado", @@ -705,13 +701,12 @@ "settings.setting.biometrics.warning.message.ios": "Si no activas {{biometricsMethod}}, cualquiera que obtenga acceso a tu dispositivo podrá abrir Uniswap Wallet y realizar transacciones.", "settings.setting.biometrics.warning.title": "¿Estás seguro?", "settings.setting.currency.title": "Moneda local", - "settings.setting.giveFeedback.title": "Compartir comentarios", "settings.setting.helpCenter.title": "Centro de ayuda", "settings.setting.language.button.navigate": "Ir a la configuración", "settings.setting.language.description.extension": "Uniswap utiliza de forma predeterminada la configuración de idioma de tu sistema. Para cambiar tu idioma preferido, ve a la configuración de tu sistema.", "settings.setting.language.description.mobile": "Uniswap utiliza de forma predeterminada la configuración de idioma de tu dispositivo. Para cambiar tu idioma preferido, ve a \"Uniswap\" en la configuración de tu dispositivo y toca \"Idioma\".", "settings.setting.language.title": "Idioma", - "settings.setting.password.title": "Cambiar la contraseña", + "settings.setting.password.title": "Cambiar contraseña", "settings.setting.privacy.analytics.description": "Utilizamos datos de uso anónimos para mejorar tu experiencia en los productos de Uniswap Labs. Cuando esta opción no está habilitada, solo realizamos un seguimiento de los errores y el uso esencial.", "settings.setting.privacy.analytics.title": "Permitir análisis", "settings.setting.privacy.title": "Privacidad", @@ -722,7 +717,7 @@ "settings.setting.wallet.action.editLabel": "Editar etiqueta", "settings.setting.wallet.action.editProfile": "Editar perfil", "settings.setting.wallet.action.remove": "Eliminar wallet", - "settings.setting.wallet.connections.title": "Gestionar las conexiones", + "settings.setting.wallet.connections.title": "Gestionar conexiones", "settings.setting.wallet.editLabel.description": "Las etiquetas no son públicas. Se almacenan localmente y solo tú puedes verlas.", "settings.setting.wallet.editLabel.save": "Guardar cambios", "settings.setting.wallet.label": "Apodo", @@ -738,8 +733,6 @@ "swap.button.unwrap": "Desenvolver", "swap.button.view": "Ver transacción", "swap.button.wrap": "Envolver", - "swap.details.action.less": "Mostrar menos", - "swap.details.action.more": "Mostrar más", "swap.details.feeOnTransfer": "Tarifa en {{tokenSymbol}}", "swap.details.newQuote.input": "Nueva entrada", "swap.details.newQuote.output": "Nueva salida", @@ -750,7 +743,7 @@ "swap.form.header": "Intercambiar", "swap.form.slippage": "Deslizamiento {{slippageTolerancePercent}}", "swap.form.warning.output.fotFees": "Debido a la tarifa del token de {{fotCurrencySymbol}}, los montos de los intercambios solo se pueden ingresar usando el campo de entrada", - "swap.form.warning.output.fotFees.fallback": "Debido a las tarifas de los tokens, los montos de los swaps solo se pueden ingresar usando el campo de entrada", + "swap.form.warning.output.fotFees.fallback": "Debido a las tarifas de los tokens, los montos de los intercambios solo se pueden ingresar usando el campo de entrada", "swap.form.warning.restore": "Restaura tu wallet para intercambiar", "swap.header.viewOnly": "Solo visualización", "swap.hold.swap": "Mantén presionado para intercambiar", @@ -765,10 +758,10 @@ "swap.settings.protection.subtitle.unavailable": "No está disponible en {{chainName}}", "swap.settings.protection.title": "Protección de intercambio", "swap.settings.routingPreference.option.default.description": "El cliente de Uniswap selecciona la opción de operación más barata, teniendo en cuenta el precio y los costos de red.", - "swap.settings.routingPreference.option.default.title": "Por defecto", + "swap.settings.routingPreference.option.default.title": "Valor predeterminado", "swap.settings.routingPreference.option.v2.title": "Pools v2", "swap.settings.routingPreference.option.v3.title": "Pools v3", - "swap.settings.routingPreference.title": "Opciones comerciales", + "swap.settings.routingPreference.title": "Opciones de operación", "swap.settings.slippage.control.auto": "Automático", "swap.settings.slippage.control.title": "Deslizamiento máx.", "swap.settings.slippage.description": "Tu transacción se revertirá si el precio cambia más que el porcentaje de deslizamiento.", @@ -782,11 +775,12 @@ "swap.settings.title": "Configuración de intercambio", "swap.slippage.settings.title": "Configuración de deslizamiento", "swap.warning.expectedFailure": "Se prevé que esta transacción fallará", - "swap.warning.feeOnTransfer.message": "Algunos tokens cobran una tarifa cuando se compran o venden. El emisor del token establece esta tarifa. Uniswap no recibe ninguna parte de estas tarifas.", + "swap.warning.feeOnTransfer.message": "Algunos tokens cobran una tarifa cuando se compran o venden. El emisor del token fija esta tarifa. Uniswap no recibe ninguna parte de estas tarifas.", "swap.warning.feeOnTransfer.title": "¿Por qué hay una tarifa adicional?", "swap.warning.insufficientBalance.button": "No tienes suficientes {{currencySymbol}}", "swap.warning.insufficientBalance.title": "No tienes suficientes {{currencySymbol}}", "swap.warning.insufficientGas.button": "No tienes suficientes {{currencySymbol}}", + "swap.warning.insufficientGas.button.buy": "Comprar {{ tokenSymbol }}", "swap.warning.insufficientGas.message.withNetwork": "No hay suficientes <highlight>{{currencySymbol}} en {{networkName}}</highlight> para intercambiar", "swap.warning.insufficientGas.message.withoutNetwork": "No hay suficientes <highlight>{{currencySymbol}}</highlight> para intercambiar", "swap.warning.insufficientGas.title": "No tienes suficientes {{currencySymbol}} para cubrir el costo de red", @@ -801,11 +795,11 @@ "swap.warning.offline.title": "Estás desconectado", "swap.warning.priceImpact.message": "Debido a la cantidad de liquidez de {{outputCurrencySymbol}} disponible actualmente, cuanto más {{inputCurrencySymbol}} intentes intercambiar, menos {{outputCurrencySymbol}} recibirás.", "swap.warning.priceImpact.title": "Alto impacto en el precio ({{priceImpactValue}})", - "swap.warning.queuedOrder.appClosed": "Tu transacción no se envió porque cerró la aplicación.", + "swap.warning.queuedOrder.appClosed": "Tu transacción no se envió porque cerraste la aplicación.", "swap.warning.queuedOrder.approvalFailed": "Tu transacción no se envió porque falló la aprobación del token.", - "swap.warning.queuedOrder.stale": "Tu transacción no se envió porque cerró la aplicación o la aprobación tomó demasiado tiempo.", + "swap.warning.queuedOrder.stale": "Tu transacción no se envió porque cerraste la aplicación o la aprobación demoró demasiado tiempo.", "swap.warning.queuedOrder.submissionFailed": "Hubo un problema al enviar tu transacción.", - "swap.warning.queuedOrder.title": "Intercambio cancelado", + "swap.warning.queuedOrder.title": "Se canceló el intercambio", "swap.warning.queuedOrder.wrap.message": "Tu ETH permanecerá envuelto como WETH.", "swap.warning.queuedOrder.wrapFailed": "Tu transacción no se envió porque falló la transacción de envoltura.", "swap.warning.rateLimit.message": "Inténtalo de nuevo en unos minutos.", @@ -833,9 +827,9 @@ "token.safetyLevel.blocked.header": "No está disponible", "token.safetyLevel.blocked.message": "No puedes intercambiar este token con Uniswap Wallet.", "token.safetyLevel.medium.header": "Precaución", - "token.safetyLevel.medium.message": "Este token no se negocia en las principales bolsas centralizadas de EE. UU. Realiza siempre tu propia investigación antes de operar.", + "token.safetyLevel.medium.message": "Este token no se tranza en las principales bolsas centralizadas de EE. UU. Realiza siempre tu propia investigación antes de operar.", "token.safetyLevel.strong.header": "Advertencia", - "token.safetyLevel.strong.message": "Este token no se negocia en las principales bolsas centralizadas de EE. UU. ni se intercambia con frecuencia en Uniswap. Realiza siempre tu propia investigación antes de operar.", + "token.safetyLevel.strong.message": "Este token no se tranza en las principales bolsas centralizadas de EE. UU. ni se intercambia con frecuencia en Uniswap. Realiza siempre tu propia investigación antes de operar.", "token.selector.search.error": "No se pudieron cargar los resultados de la búsqueda", "token.stats.fullyDilutedValuation": "Valoración totalmente diluida", "token.stats.marketCap": "Capitalización de mercado", @@ -844,8 +838,10 @@ "token.stats.section.about": "Acerca de {{token}}", "token.stats.title": "Estadísticas", "token.stats.translation.original": "Mostrar original", - "token.stats.translation.translate": "Traducir a {{language}}", + "token.stats.translation.translate": "Traducir al {{language}}", "token.stats.volume": "Volumen de 24 h", + "token.zeroNativeBalance.description": "Para obtener {{ tokenSymbol }}, primero debes {{ nativeTokenSymbol }} pagar el costo de la red. Para comenzar, agrega fondos a tu wallet con {{ nativeTokenSymbol }}.", + "token.zeroNativeBalance.title": "Necesitas {{ nativeTokenName }} ", "tokens.action.hide": "Ocultar token", "tokens.action.unhide": "Mostrar token", "tokens.hidden.label": "Oculto ({{numHidden}})", @@ -889,20 +885,21 @@ "tokens.selector.section.suggested": "Sugerido", "tokens.selector.section.yours": "Tus tokens", "transaction.action.cancel.button": "Cancelar transacción", - "transaction.action.cancel.description": "Si cancela esta transacción antes de que la red la procese, pagará un nuevo costo de red en lugar del original.", + "transaction.action.cancel.description": "Si cancelas esta transacción antes de que la red la procese, pagarás un nuevo costo de red en lugar del original.", "transaction.action.cancel.title": "¿Deseas cancelar esta transacción?", "transaction.action.copy": "Copiar ID de transacción", - "transaction.action.copyMoonPay": "Copiar ID de transacción de MoonPay", + "transaction.action.copyProvider": "Copiar ID de transacción de {{providerName}}", "transaction.action.view": "Ver {{tokenSymbol}}", "transaction.action.viewEtherscan": "Ver en {{blockExplorerName}}", - "transaction.action.viewMoonPay": "Ver en MoonPay", "transaction.amount.unlimited": "Ilimitado", "transaction.currency.unknown": "Token desconocido", "transaction.date": "Se envió el {{date}}", "transaction.details.dappName": "Aplicación", "transaction.details.from": "De", "transaction.details.networkFee": "Costo de red", + "transaction.details.swapRate": "Tasa", "transaction.details.transactionId": "ID de transacción", + "transaction.details.uniswapFee": "Tarifa ({{ feePercent }}%)", "transaction.network.all": "Todas las redes", "transaction.networkCost.label": "Costo de red", "transaction.notification.error.cancel": "No se puede cancelar la transacción", @@ -961,7 +958,7 @@ "transaction.status.sell.failed": "No se pudo vender", "transaction.status.sell.pending": "Vendiendo", "transaction.status.sell.success": "Vendido", - "transaction.status.sell.successDapp": "Vendido en {{externalDappName}}", + "transaction.status.sell.successDapp": "Se vendió en {{externalDappName}}", "transaction.status.send.canceled": "Se canceló el envío", "transaction.status.send.canceling": "Cancelando el envío", "transaction.status.send.failed": "No se pudo enviar", @@ -979,14 +976,14 @@ "transaction.status.unwrap.canceled": "Se canceló la desenvoltura", "transaction.status.unwrap.canceling": "Cancelando la desenvoltura", "transaction.status.unwrap.failed": "No se pudo desenvolver", - "transaction.status.unwrap.pending": "Envolviendo", - "transaction.status.unwrap.success": "Se realizó la envoltura", - "transaction.status.unwrap.successDapp": "La envoltura se realizó en {{externalDappName}}", + "transaction.status.unwrap.pending": "Desnvolviendo", + "transaction.status.unwrap.success": "Se desenvolvió", + "transaction.status.unwrap.successDapp": "La desenvoltura se realizó en {{externalDappName}}", "transaction.status.wrap.canceled": "Se canceló la envoltura", "transaction.status.wrap.canceling": "Cancelando la envoltura", "transaction.status.wrap.failed": "No se pudo realizar la envoltura", "transaction.status.wrap.pending": "Envoltura", - "transaction.status.wrap.success": "Envuelto", + "transaction.status.wrap.success": "Se envolvió", "transaction.status.wrap.successDapp": "Se envolvió en {{externalDappName}}", "transaction.summary.received": "{{tokenAmountWithSymbol}} a {{recipientAddress}}", "transaction.summary.sent": "{{tokenAmountWithSymbol}} de {{senderAddress}}", @@ -1003,7 +1000,7 @@ "unitags.banner.title.compact": "<highlight>Reclama tu nombre de usuario de {{unitagDomain}}</highlight> y crea tu perfil personalizable.", "unitags.banner.title.full": "Reclamar tu nombre de usuario de {{unitagDomain}}", "unitags.choosePhoto.option.cameraRoll": "Elegir del carrete de la cámara", - "unitags.choosePhoto.option.nft": "Elege una NFT", + "unitags.choosePhoto.option.nft": "Elige un NFT", "unitags.choosePhoto.option.remove": "Eliminar foto de perfil", "unitags.claim.confirmation.customize": "Personalizar perfil", "unitags.claim.confirmation.description": "{{unitagAddress}} está listo para enviar y recibir cripto. Continúa creando tu wallet; para ello, personaliza tu perfil web3.", @@ -1022,7 +1019,7 @@ "unitags.claim.username.default": "tunombre", "unitags.delete.confirm.subtitle": "Estás a punto de eliminar tu nombre de usuario y los detalles de perfil personalizables. No podrás volver a usarlo.", "unitags.delete.confirm.title": "¿Estás seguro?", - "unitags.editProfile.placeholder": "Nombre de usuario", + "unitags.editProfile.placeholder": "nombre de usuario", "unitags.editUsername.button.confirm": "Guardar cambios", "unitags.editUsername.confirm.subtitle": "Estás a punto de cambiar tu nombre de usuario. Luego que lo cambies, nunca podrás volver a reclamarlo.", "unitags.editUsername.confirm.title": "¿Estás seguro?", @@ -1041,15 +1038,15 @@ "unitags.notification.username.error": "No se pudo cambiar el nombre de usuario. Vuelve a intentarlo más tarde.", "unitags.notification.username.title": "Se cambió el nombre de usuario", "unitags.onboarding.claim.subtitle": "Este es tu nombre único al que cualquiera puede enviar cripto.", - "unitags.onboarding.claim.title.choose": "Elegir tu nombre de usuario", - "unitags.onboarding.claim.title.claim": "Reclamar tu nombre de usuario", + "unitags.onboarding.claim.title.choose": "Elige tu nombre de usuario", + "unitags.onboarding.claim.title.claim": "Reclama tu nombre de usuario", "unitags.onboarding.claimPeriod.description": "Por tiempo limitado, el nombre de usuario {{username}} está reservado. Importa la wallet propietaria de {{username}}.eth ENS para reclamar este nombre de usuario o vuelve a intentarlo después del período de reclamación.", "unitags.onboarding.claimPeriod.link": "Obtén más información sobre nuestro <highlight>período de reclamación</highlight>.", "unitags.onboarding.claimPeriod.title": "Período de reclamación ENS", "unitags.onboarding.info.description": "Los nombres de usuario transforman direcciones 0x complejas en nombres legibles. Al reclamar un nombre de usuario de {{unitagDomain}}, puedes enviar y recibir cripto fácilmente y crear un perfil web3 público.", "unitags.onboarding.info.title": "Una dirección simplificada", "unitags.onboarding.profile.subtitle": "Sube el tuyo propio o quédate con tu Unicon exclusivo. Siempre puedes cambiar esto más tarde.", - "unitags.onboarding.profile.title": "Elegir una foto de perfil", + "unitags.onboarding.profile.title": "Elige una foto de perfil", "unitags.profile.action.delete": "Eliminar nombre de usuario", "unitags.profile.action.edit": "Editar nombre de usuario", "unitags.profile.bio.label": "Biografía", @@ -1091,6 +1088,8 @@ "walletConnect.request.button.sign": "Firmar", "walletConnect.request.details.label.function": "Función", "walletConnect.request.details.label.sending": "Enviando", + "walletConnect.request.details.label.token": "Token", + "walletConnect.request.details.label.tokens": "Tokens", "walletConnect.request.error.insufficientFunds": "No tienes suficientes {{currencySymbol}} para completar esta transacción.", "walletConnect.request.error.network": "Error de conexión a Internet o red", "walletConnect.request.warning.general.message": "Cuidado: este mensaje puede transferir activos", diff --git a/packages/uniswap/src/i18n/locales/translations/fr-FR.json b/packages/uniswap/src/i18n/locales/translations/fr-FR.json index 2c0617bacad..40cc5a43c62 100644 --- a/packages/uniswap/src/i18n/locales/translations/fr-FR.json +++ b/packages/uniswap/src/i18n/locales/translations/fr-FR.json @@ -1,48 +1,48 @@ { - "account.cloud.backup.subtitle": "Plusieurs phrases de récupération sont sauvegardées auprès de votre {{cloudProviderName}}.", - "account.cloud.backup.title": "Sélectionner une sauvegarde à restaurer", - "account.cloud.button.restore.android": "Restaurer à partir de Google Drive", - "account.cloud.button.restore.ios": "Restaurer à partir de iCloud", - "account.cloud.empty.description": "Il semble que tu vous n'ayez sauvegardé aucune de vos phrases de récupération auprès de {{cloudProviderName}}.", - "account.cloud.empty.title": "0 sauvegardes détectées", - "account.cloud.error.backup.message": "Échec de l’importation des sauvegardes en raison d’autorisations insuffisantes, d’une interruption de l’autorisation ou d’une erreur liée au cloud", + "account.cloud.backup.subtitle": "Plusieurs phrases de récupération sont sauvegardées sur votre {{cloudProviderName}}.", + "account.cloud.backup.title": "Sélectionnez une sauvegarde à restaurer", + "account.cloud.button.restore.android": "Restaurer avec Google Drive", + "account.cloud.button.restore.ios": "Restaurer avec iCloud", + "account.cloud.empty.description": "Il semble que vous n’ayez sauvegardé aucune de vos phrases de récupération sur {{cloudProviderName}}.", + "account.cloud.empty.title": "0 sauvegarde trouvée", + "account.cloud.error.backup.message": "Échec de l’importation des sauvegardes en raison d’un manque d’autorisations, d’une interruption de l’autorisation ou d’une erreur Cloud", "account.cloud.error.backup.title": "Erreur lors de l’importation des sauvegardes", - "account.cloud.error.password.title": "Mot de passe non valide. Réessayez.", + "account.cloud.error.password.title": "Mot de passe non valide. Veuillez réessayer.", "account.cloud.error.unavailable.button.cancel": "Pas maintenant", "account.cloud.error.unavailable.button.settings": "Accéder aux paramètres", - "account.cloud.error.unavailable.message.android": "Vérifiez que vous êtes connecté à un compte Google et que Google Drive est activé sur cet appareil, puis réessayez.", - "account.cloud.error.unavailable.message.ios": "Vérifiez que vous êtes connecté à un ID Apple et qu’iCloud Drive est activé sur cet appareil, puis réessayez.", + "account.cloud.error.unavailable.message.android": "Veuillez vérifier que vous êtes connecté(e) à un compte Google avec Google Drive activé sur cet appareil et réessayez.", + "account.cloud.error.unavailable.message.ios": "Veuillez vérifier que vous êtes connecté(e) à un ID Apple avec iCloud Drive activé sur cet appareil et réessayez.", "account.cloud.error.unavailable.title.android": "Google Drive non disponible", "account.cloud.error.unavailable.title.ios": "iCloud Drive non disponible", "account.cloud.loading.title": "Recherche de sauvegardes…", "account.cloud.lockout.time.hours_one": "Trop de tentatives. Réessayez dans 1 heure.", - "account.cloud.lockout.time.hours_other": "Trop de tentatives. Réessayez dans {{count}} heures.", + "account.cloud.lockout.time.hours_other": "Trop de tentatives. Réessayez dans {{count}} heures.", "account.cloud.lockout.time.minutes_one": "Trop de tentatives. Réessayez dans 1 minute.", - "account.cloud.lockout.time.minutes_other": "Trop de tentatives. Réessayez dans {{count}} minutes.", + "account.cloud.lockout.time.minutes_other": "Trop de tentatives. Réessayez dans {{count}} minutes.", "account.cloud.password.input": "Saisir le mot de passe", - "account.cloud.password.recoveryPhrase": "Saisissez plutôt votre phrase de récupération", - "account.cloud.password.subtitle": "Ce mot de passe est requis pour récupérer votre sauvegarde de phrase de récupération auprès de {{cloudProviderName}}.", - "account.cloud.password.title": "Saisir le mot de passe de sauvegarde", - "account.recoveryPhrase.education.part1": "Une phrase de récupération est un <highlight>ensemble de mots</highlight> nécessaire pour accéder à ton wallet, <highlight>par exemple un mot de passe.</highlight>", - "account.recoveryPhrase.education.part2": "Tu peux <highlight>saisir</highlight> votre phrase de récupération sur un nouvel appareil <highlight>pour restaurer votre wallet</highlight> et son contenu.", - "account.recoveryPhrase.education.part3": "Toutefois, si vous <highlight>perdez votre phrase de récupération</highlight>, vous <highlight>perdrez l’accès</highlight> à votre wallet.", - "account.recoveryPhrase.education.part4": "Au lieu de mémoriser votre phrase de récupération, vous pouvez <highlight>la sauvegarder dans {{cloudProviderName}}</highlight> et la protéger par un mot de passe.", - "account.recoveryPhrase.education.part5": "Vous pouvez également sauvegarder manuellement votre phrase de récupération en <highlight>l’écrivant</highlight> et en la stockant dans un endroit sûr.", + "account.cloud.password.recoveryPhrase": "Saisir votre phrase de récupération à la place", + "account.cloud.password.subtitle": "Ce mot de passe est nécessaire pour récupérer votre sauvegarde de phrase de récupération depuis {{cloudProviderName}}.", + "account.cloud.password.title": "Saisissez le mot de passe de sauvegarde", + "account.recoveryPhrase.education.part1": "Une phrase de récupération est un <highlight>ensemble de mots</highlight> nécessaire pour accéder à votre wallet, <highlight>comme un mot de passe.</highlight>", + "account.recoveryPhrase.education.part2": "Vous pouvez <highlight>saisir</highlight> votre phrase de récupération sur un nouvel appareil <highlight>pour restaurer votre wallet</highlight> et son contenu.", + "account.recoveryPhrase.education.part3": "Mais si vous <highlight>perdez votre phrase de récupération</highlight>, vous <highlight>perdrez l’accès</highlight> à votre wallet.", + "account.recoveryPhrase.education.part4": "Au lieu de mémoriser votre phrase de récupération, vous pouvez <highlight>la sauvegarder dans {{cloudProviderName}}</highlight> et la protéger avec un mot de passe.", + "account.recoveryPhrase.education.part5": "Vous pouvez également sauvegarder manuellement votre phrase de récupération en <highlight>l’écrivant</highlight> et en la conservant dans un endroit sûr.", "account.recoveryPhrase.education.part6": "Nous vous recommandons d’utiliser <highlight>les deux types de sauvegardes</highlight>, car si vous perdez votre phrase de récupération, vous ne pourrez pas restaurer votre wallet.", "account.recoveryPhrase.error.invalid": "Phrase non valide", "account.recoveryPhrase.error.invalidWord": "Mot non valide : {{word}}", - "account.recoveryPhrase.error.phraseLength": "La phrase de récupération doit comprendre 12 à 24 mots", + "account.recoveryPhrase.error.phraseLength": "La phrase de récupération doit comporter entre 12 et 24 mots", "account.recoveryPhrase.error.wrong": "Phrase de récupération incorrecte", "account.recoveryPhrase.helpText.import": "Comment trouver ma phrase de récupération ?", - "account.recoveryPhrase.helpText.restoring": "Relancer la recherche", - "account.recoveryPhrase.input": "Saisissez votre phrase de récupération", - "account.recoveryPhrase.remove.final.description": "Vérifiez que vous avez bien écrit votre phrase de récupération ou que vous l'avez sauvegardée dans {{cloudProviderName}}. <highlight>Sinon, vous ne pourrez pas accéder à vos fonds.</highlight>", + "account.recoveryPhrase.helpText.restoring": "Relancez la recherche", + "account.recoveryPhrase.input": "Tapez votre phrase de récupération", + "account.recoveryPhrase.remove.final.description": "Assurez-vous d’avoir écrit votre phrase de récupération ou de l’avoir sauvegardée sur {{cloudProviderName}}. <highlight>Sinon, vous ne pourrez pas accéder à vos fonds.</highlight>", "account.recoveryPhrase.remove.final.title": "Vous êtes sur le point de supprimer votre <highlight>phrase de récupération</highlight>", "account.recoveryPhrase.remove.import.description": "Vous ne pouvez enregistrer qu’une seule phrase de récupération à la fois. Pour continuer à en importer une nouvelle, vous devrez supprimer votre phrase de récupération actuelle et tous les wallets associés de cet appareil.", - "account.recoveryPhrase.remove.initial.description": "Cela supprimera votre wallet de cet appareil, ainsi que votre phrase de récupération.", + "account.recoveryPhrase.remove.initial.description": "Cela supprimera votre wallet ainsi que votre phrase de récupération de cet appareil.", "account.recoveryPhrase.remove.initial.title": "Vous êtes sur le point de supprimer <highlight>{{walletName}}</highlight>", - "account.recoveryPhrase.remove.mnemonic.description": "Il partage la même phrase de récupération que {{walletName}}. Votre phrase de récupération sera stockée jusqu’à ce que vous supprimiez tous les wallets restants.", - "account.recoveryPhrase.subtitle.import": "Votre phrase de récupération ne sera stockée que localement sur votre appareil.", + "account.recoveryPhrase.remove.mnemonic.description": "Il partage la même phrase de récupération que {{walletNames, list}}. Votre phrase de récupération restera enregistrée jusqu’à ce que vous supprimiez tous les wallets restants.", + "account.recoveryPhrase.subtitle.import": "Votre phrase de récupération ne sera enregistrée que localement sur votre appareil.", "account.recoveryPhrase.subtitle.restoring": "Saisissez votre phrase de récupération ci-dessous ou essayez à nouveau de rechercher des sauvegardes.", "account.recoveryPhrase.title.import": "Saisissez votre phrase de récupération", "account.recoveryPhrase.title.restoring": "Aucune sauvegarde trouvée", @@ -58,7 +58,7 @@ "account.wallet.button.remove": "Supprimer le wallet", "account.wallet.button.restore": "Restaurer le wallet", "account.wallet.button.watch": "Consulter un wallet", - "account.wallet.create.placeholder": "{{index}} du wallet", + "account.wallet.create.placeholder": "Wallet {{index}}", "account.wallet.edit.label.input.placeholder": "Libellé du wallet", "account.wallet.header.button.disabled.title": "Modifier le profil", "account.wallet.header.button.title": "Modifier le libellé", @@ -68,14 +68,14 @@ "account.wallet.menu.edit.title": "Modifier le libellé", "account.wallet.menu.remove.title": "Supprimer le wallet", "account.wallet.remove.check": "J’ai sauvegardé ma phrase de récupération et je comprends qu’Uniswap Labs ne peut pas m’aider à récupérer mes wallets si je n’ai pas effectué cette sauvegarde.", - "account.wallet.remove.title": "Supprimer {{name}}", + "account.wallet.remove.title": "Retirer {{name}}", "account.wallet.remove.viewOnly": "Vous pouvez toujours ajouter des wallets en lecture seule en saisissant l’adresse du wallet.", - "account.wallet.restore.description": "Étant donné que vous utilisez un nouvel appareil, vous allez devoir restaurer votre phrase de récupération. Cela vous permettra d’échanger et d’envoyer des tokens.", + "account.wallet.restore.description": "Étant donné que vous utilisez un nouvel appareil, vous devrez restaurer votre phrase de récupération. Cela vous permettra d’échanger et d’envoyer des tokens.", "account.wallet.select.error": "Impossible de charger les adresses", "account.wallet.select.loading.subtitle": "Vos wallets apparaîtront ci-dessous.", "account.wallet.select.loading.title": "Recherche de wallets", "account.wallet.select.title_one_one": "Un wallet trouvé", - "account.wallet.select.title_one_other": "Sélectionne les wallets à importer", + "account.wallet.select.title_one_other": "Sélectionnez les wallets à importer", "account.wallet.viewOnly.button": "Importer un wallet", "account.wallet.viewOnly.description": "Pour échanger, acheter, envoyer et recevoir des tokens, vous devez importer la phrase de récupération de ce wallet.", "account.wallet.viewOnly.title": "Ce wallet est en lecture seule", @@ -84,7 +84,9 @@ "account.wallet.watch.error.smartContract": "L’adresse est un contrat intelligent", "account.wallet.watch.message": "L’ajout d’un wallet en lecture seule vous permet d’essayer l’application ou de suivre un wallet. Vous ne pourrez ni échanger, ni envoyer de fonds.", "account.wallet.watch.placeholder": "ENS ou adresse", - "account.wallet.watch.title": "Saisir une adresse de wallet", + "account.wallet.watch.title": "Saisissez une adresse de wallet", + "common.action.go": "Accéder", + "common.action.swipe": "Glisser", "common.button.accept": "Accepter", "common.button.back": "Retour", "common.button.buy": "Acheter", @@ -112,7 +114,7 @@ "common.button.paste": "Coller", "common.button.pay": "Payer", "common.button.receive": "Recevoir", - "common.button.remove": "Supprimer", + "common.button.remove": "Retirer", "common.button.restore": "Restaurer", "common.button.retry": "Recommencer", "common.button.review": "Examiner", @@ -123,16 +125,17 @@ "common.button.setup": "Configurer", "common.button.share": "Partager", "common.button.show": "Afficher", + "common.button.showLess": "Afficher moins", + "common.button.showMore": "Afficher plus", "common.button.sign": "Signer", "common.button.skip": "Passer", "common.button.swap": "Échanger", "common.button.tryAgain": "Réessayer", "common.button.understand": "Je comprends", - "common.button.view": "Afficher", + "common.button.view": "Voir", "common.button.yes": "Oui", "common.card.error.description": "Une erreur s’est produite", "common.card.error.title": "Oups ! Une erreur s’est produite.", - "common.endAdornment": "et", "common.error.general": "Une erreur s’est produite.", "common.input.password.confirm": "Confirmer le mot de passe", "common.input.password.error.mismatch": "Les mots de passe ne correspondent pas", @@ -175,10 +178,10 @@ "currency.usd": "Dollar américain", "currency.vnd": "Dong vietnamien", "dapp.request.approve.action": "Approuver", - "dapp.request.approve.fallbackTitle": "Approuver ce site pour accéder aux jetons", - "dapp.request.approve.helptext": "Autorisez ce site à accéder et à dépenser ce jeton pour les transactions. Vérifiez que vous faites confiance à ce site.", + "dapp.request.approve.fallbackTitle": "Approuver ce site pour accéder aux tokens", + "dapp.request.approve.helptext": "Autorisez ce site à accéder et à dépenser ce token pour les transactions. Vérifiez que vous faites confiance à ce site.", "dapp.request.approve.label": "Wallet", - "dapp.request.approve.title": "Approuver l'accès à {{tokenSymbol}}", + "dapp.request.approve.title": "Approuver l’accès à {{tokenSymbol}}", "dapp.request.base.title": "Demande de transaction", "dapp.request.connect.helptext": "Autorisez ce site à afficher l’adresse de votre wallet, votre solde et à demander des approbations pour les transactions.", "dapp.request.connect.title": "Se connecter au site", @@ -192,7 +195,7 @@ "dapp.request.signature.error.712-spec-compliance": "SignTypedDataRequestContent a reçu des données à signer qui ne sont pas conformes à la spécification EIP-712.", "dapp.request.signature.header": "Demande de signature", "dapp.request.signature.toggleDataView.raw": "Afficher les données brutes", - "dapp.request.signature.toggleDataView.readable": "Afficher les données d'origine", + "dapp.request.signature.toggleDataView.readable": "Afficher les données d’origine", "dapp.request.warning.notActive.message": "Vérifier que c’est le bon", "dapp.request.warning.notActive.title": "Ce n’est pas votre wallet actif", "errors.crash.message": "Un plantage s’est produit.", @@ -202,7 +205,7 @@ "explore.search.action.viewEtherscan": "Afficher sur {{blockExplorerName}}", "explore.search.empty.full": "Aucun résultat trouvé pour <highlight>\"{{searchQuery}}\"</highlight>", "explore.search.error": "Impossible de charger les résultats de la recherche", - "explore.search.label.ownedBy": "Détenu par {{ownerAddress}}", + "explore.search.label.ownedBy": "Propriété de {{ownerAddress}}", "explore.search.placeholder": "Rechercher des tokens et des wallets", "explore.search.section.nft": "Collections NFT", "explore.search.section.popularNFT": "Collections NFT populaires", @@ -242,21 +245,22 @@ "extension.connection.popupWithButton": "Votre wallet n’est pas connecté à ce site.", "extension.connection.titleConnected": "Connecté", "extension.connection.titleNotConnected": "Non connecté", - "extension.feedback.description": "Dites-nous comment nous pouvons nous améliorer : demande des fonctionnalités, signale un bug, etc.", - "extension.feedback.title": "Nous serions ravis d'avoir vos commentaires", "extension.lock.button.forgot": "Mot de passe oublié ?", "extension.lock.button.reset": "Réinitialiser le wallet", "extension.lock.button.submit": "Déverrouiller", "extension.lock.password.error": "Mot de passe incorrect. Réessayer", - "extension.lock.password.reset.initial.description": "Uniswap ne peut pas vous aider à récupérer votre mot de passe. Vous devez réinitialiser votre portefeuille en saisissant à nouveau votre phrase de récupération de 12 mots.", + "extension.lock.password.reset.initial.description": "Uniswap ne peut pas vous aider à récupérer votre mot de passe. Vous devez réinitialiser votre wallet en saisissant à nouveau votre phrase de récupération de 12 mots.", "extension.lock.password.reset.initial.help": "Où puis-je trouver ma phrase de récupération ?", "extension.lock.password.reset.initial.title": "Mot de passe oublié", - "extension.lock.password.reset.speedbump.description": "Assurez-vous d'avoir votre phrase de récupération de 12 mots avant de réinitialiser votre portefeuille. Sinon, vous ne pourrez pas récupérer vos fonds.", - "extension.lock.password.reset.speedbump.help": "J'ai perdu ma phrase de récupération", + "extension.lock.password.reset.speedbump.description": "Assurez-vous d’avoir votre phrase de récupération de 12 mots avant de réinitialiser votre wallet. Sinon, vous ne pourrez pas récupérer vos fonds.", + "extension.lock.password.reset.speedbump.help": "J’ai perdu ma phrase de récupération", "extension.lock.password.reset.speedbump.title": "Avant de continuer", "extension.lock.subtitle": "Saisissez votre mot de passe pour déverrouiller", "extension.lock.title": "Content de vous revoir", "extension.network.notSupported": "Réseau non pris en charge", + "extension.popup.chrome.button": "Ouvre l'extension", + "extension.popup.chrome.description": "Complète cette action en ouvrant l'extension Uniswap.", + "extension.popup.chrome.title": "Continue sur Uniswap", "extension.settings.password.enter.title": "Saisissez votre mot de passe actuel", "extension.settings.password.error.wrong": "Mot de passe incorrect", "extension.settings.password.placeholder": "Mot de passe actuel", @@ -270,12 +274,13 @@ "fiatOnRamp.connection.terms": "En continuant, vous reconnaissez que vous serez soumis aux conditions d’utilisation et à la politique de confidentialité de {{serviceProvider}}, le cas échéant.", "fiatOnRamp.error.default": "Une erreur s’est produite.", "fiatOnRamp.error.load": "Impossible de charger les tokens à acheter", - "fiatOnRamp.error.max": "Maximum : {{amount}}", - "fiatOnRamp.error.min": "Minimum : {{amount}}", + "fiatOnRamp.error.max": "Maximum {{amount}}", + "fiatOnRamp.error.min": "Minimum {{amount}}", "fiatOnRamp.error.unavailable": "Ce service n’est pas disponible dans votre région", "fiatOnRamp.error.unsupported": "Non pris en charge dans la région", "fiatOnRamp.error.usd": "Uniquement disponible à l’achat en USD", - "fiatOnRamp.quote.advice": "Vous allez continuer vers le portail du fournisseur pour voir les frais associés à votre transaction.", + "fiatOnRamp.quote.advice": "Vous continuerez vers le portail du fournisseur pour voir les frais associés à votre transaction.", + "fiatOnRamp.quote.type.list": "{{optionsList}}, et d'autres options", "fiatOnRamp.quote.type.other": "Autres options", "fiatOnRamp.quote.type.recent": "Utilisé récemment", "fiatOnRamp.region.placeholder": "Rechercher par pays ou région", @@ -283,7 +288,7 @@ "fiatOnRamp.summary.total": "{{cryptoAmount}} pour {{fiatAmount}}", "forceUpgrade.action.confirm": "Mettre à jour l’application", "forceUpgrade.action.recoveryPhrase": "Afficher la phrase de récupération", - "forceUpgrade.description": "La version d’Uniswap Wallet que vous utilisez est obsolète et ne dispose pas des mises à niveau essentielles. Si vous ne mettez pas à jour l’application ou si vous n’écrivez pas ta phrase de récupération, vous ne pourrez pas accéder à vos actifs.", + "forceUpgrade.description": "La version d’Uniswap Wallet que vous utilisez est obsolète et ne dispose pas des mises à niveau essentielles. Si vous ne mettez pas à jour l’application ou si vous n’écrivez pas votre phrase de récupération, vous ne pourrez pas accéder à vos actifs.", "forceUpgrade.label.recoveryPhrase": "Phrase de récupération", "forceUpgrade.title": "Mettre à jour l’application pour continuer", "home.activity.empty.button": "Recevoir des tokens ou des NFT", @@ -292,12 +297,9 @@ "home.activity.empty.title": "Aucune activité pour le moment", "home.activity.error.load": "Impossible de charger l’activité", "home.activity.title": "Activité", - "home.banner.extension.confirm.beta": "Rejoindre la version bêta", - "home.banner.extension.confirm.default": "Télécharger", - "home.banner.extension.message.beta": "Soyez le premier à essayer l’extension Uniswap sur votre navigateur Web", - "home.banner.extension.message.default": "Télécharger sur Chrome pour accéder à ce wallet depuis un ordinateur", - "home.banner.extension.title": "L’extension Uniswap est disponible", "home.banner.offline": "Vous êtes en mode hors ligne", + "home.explore.footer": "Appuyez ici pour découvrir des milliers de jetons, NFT et plus encore", + "home.explore.title": "Découvrir les jetons", "home.extension.error": "Erreur lors du chargement des comptes", "home.feed.empty.description": "Lorsque vos wallets favoris effectuent des transactions, elles apparaissent ici.", "home.feed.empty.title": "Aucune activité pour le moment", @@ -308,12 +310,6 @@ "home.label.scan": "Analyser", "home.label.send": "Envoyer", "home.label.swap": "Échanger", - "home.modal.getExtension.beta.step3": "3. Saisissez votre nom d’utilisateur pour y accéder", - "home.modal.getExtension.beta.title": "Rejoindre la version bêta de l’extension Uniswap", - "home.modal.getExtension.ga.step1": "1. Visiter <highlight>uniswap.org/ext</highlight> dans le navigateur Chrome", - "home.modal.getExtension.ga.step2": "2. Ajoutez l'extension Uniswap à votre navigateur", - "home.modal.getExtension.ga.step3": "3. Scannez le code QR avec votre application mobile Uniswap pour importer votre portefeuille", - "home.modal.getExtension.ga.title": "Télécharger l'extension Uniswap", "home.nfts.title": "NFT", "home.tokens.empty.action.buy.description": "Achetez des cryptos avec une carte ou un compte bancaire.", "home.tokens.empty.action.buy.title": "Acheter des cryptos avec une carte", @@ -354,8 +350,8 @@ "mobile.appRating.feedback.button.cancel": "Peut-être plus tard", "mobile.appRating.feedback.button.send": "Envoyer des commentaires", "mobile.appRating.feedback.description": "Faites-nous savoir comment nous pouvons améliorer votre expérience", - "mobile.appRating.feedback.title": "Nous sommes navrés de l'apprendre.", - "mobile.appRating.title": "Vous appréciez le portefeuille Uniswap ?", + "mobile.appRating.feedback.title": "Nous sommes navrés de l’apprendre.", + "mobile.appRating.title": "Vous appréciez Uniswap Wallet ?", "notification.assetVisibility.hidden": "{{assetName}} masqué", "notification.assetVisibility.unhidden": "{{assetName}} non masqué", "notification.copied.address": "Adresse copiée", @@ -363,7 +359,7 @@ "notification.copied.contractAddress": "Adresse du contrat copiée", "notification.copied.failed": "Échec de la copie dans le presse-papiers", "notification.copied.image": "Image copiée", - "notification.copied.nftUrl": "URL NFT copiée", + "notification.copied.nftUrl": "URL du NFT copiée", "notification.copied.tokenUrl": "URL du token copiée", "notification.copied.transactionId": "ID de transaction copié", "notification.countryChange": "Basculé vers {{countryName}}", @@ -380,13 +376,13 @@ "notification.transaction.approve.success": "Approbation de {{currencySymbol}} pour une utilisation avec l’adresse {{address}}.", "notification.transaction.pending": "Transaction en attente", "notification.transaction.swap.canceled": "Échange en {{inputCurrencySymbol}}-{{outputCurrencySymbol}} annulé.", - "notification.transaction.swap.expired": "L'échange en {{inputCurrencyAmountWithSymbol}} pour {{outputCurrencyAmountWithSymbol}} a expiré.", - "notification.transaction.swap.fail": "Échec de l’échange en {{inputCurrencyAmountWithSymbol}} pour {{outputCurrencyAmountWithSymbol}}.", - "notification.transaction.swap.success": "Échange de {{inputCurrencyAmountWithSymbol}} pour {{outputCurrencyAmountWithSymbol}} effectué.", + "notification.transaction.swap.expired": "Échange de {{inputCurrencyAmountWithSymbol}} en {{outputCurrencyAmountWithSymbol}} expiré.", + "notification.transaction.swap.fail": "Échec de l’échange de {{inputCurrencyAmountWithSymbol}} en {{outputCurrencyAmountWithSymbol}}.", + "notification.transaction.swap.success": "Échange de {{inputCurrencyAmountWithSymbol}} en {{outputCurrencyAmountWithSymbol}} effectué.", "notification.transaction.transfer.canceled": "Envoi de {{tokenNameOrAddress}} annulé.", "notification.transaction.transfer.fail": "Échec de l’envoi de {{tokenNameOrAddress}} à {{walletNameOrAddress}}.", - "notification.transaction.transfer.received": "Reçu {{tokenNameOrAddress}} de {{walletNameOrAddress}}.", - "notification.transaction.transfer.success": "Envoi de {{tokenNameOrAddress}} à {{walletNameOrAddress}} effectué.", + "notification.transaction.transfer.received": "{{tokenNameOrAddress}} reçu de {{walletNameOrAddress}}.", + "notification.transaction.transfer.success": "{{tokenNameOrAddress}} envoyé à {{walletNameOrAddress}}.", "notification.transaction.unknown.fail.full": "Échec de la transaction avec {{addressOrEnsName}}", "notification.transaction.unknown.fail.short": "La transaction a échoué", "notification.transaction.unknown.success.full": "Transaction effectuée avec {{addressOrEnsName}}", @@ -402,53 +398,57 @@ "notification.walletConnect.connected": "Connecté", "notification.walletConnect.disconnected": "Déconnecté", "notification.walletConnect.failed": "La transaction a échoué avec {{dappName}}", - "notification.walletConnect.networkChanged.full": "Basculé vers {{networkName}}", + "notification.walletConnect.networkChanged.full": "Passé à {{networkName}}", "notification.walletConnect.networkChanged.short": "Réseaux commutés", "notifications.scantastic.subtitle": "Continuer sur l’extension Uniswap", "notifications.scantastic.title": "Succès !", - "onboarding.backup.manual.banner": "Il est préférable de l'écrire sur un morceau de papier et de le conserver dans un endroit sûr ou dans un gestionnaire de mots de passe sécurisé.", + "onboarding.backup.manual.banner": "Il est préférable de l’écrire sur un morceau de papier et de le conserver dans un endroit sûr ou dans un gestionnaire de mots de passe sécurisé.", "onboarding.backup.manual.error": "Mot invalide ou mal orthographié", "onboarding.backup.manual.placeholder": "Mot secret", "onboarding.backup.manual.progress": "{{completedStepsCount}} étape(s) sur {{totalStepsCount}} terminée(s)", "onboarding.backup.manual.selectedWordPlaceholder": "Sélectionner un mot", - "onboarding.backup.manual.subtitle_one": "Quel est le {{count}}premier mot de votre phrase de récupération ?", + "onboarding.backup.manual.subtitle_one": "Quel est le {{count}}er mot de votre phrase de récupération ?", "onboarding.backup.manual.subtitle_two": "Quel est le {{count}}e mot de votre phrase de récupération ?", "onboarding.backup.manual.subtitle_few": "Quel est le {{count}}e mot de votre phrase de récupération ?", - "onboarding.backup.manual.subtitle_other": "Quel est le {{count}}ème mot de votre phrase de récupération ?", + "onboarding.backup.manual.subtitle_other": "Quel est le {{count}}e mot de votre phrase de récupération ?", "onboarding.backup.manual.title": "Assurons-nous que vous l’avez enregistré correctement", "onboarding.backup.option.cloud.description": "Chiffrez votre phrase de récupération avec un mot de passe sécurisé", - "onboarding.backup.option.cloud.title": "Sauvegarde de {{cloudProviderName}}", + "onboarding.backup.option.cloud.title": "Sauvegarde {{cloudProviderName}}", "onboarding.backup.option.manual.description": "Notez votre phrase de récupération et conservez-la dans un endroit sûr", "onboarding.backup.option.manual.title": "Sauvegarde manuelle", "onboarding.backup.subtitle": "Les sauvegardes vous permettent de restaurer votre wallet si vous supprimez l’application ou perdez votre appareil", "onboarding.backup.title.existing": "Sauvegarder votre wallet", - "onboarding.backup.title.new": "Choisissez une méthode de sauvegarde", - "onboarding.backup.view.disclaimer": "Je comprends que si je perds ma phrase de récupération, Uniswap Labs ne peut pas m'aider à la restaurer.", + "onboarding.backup.title.new": "Choisir une méthode de sauvegarde", + "onboarding.backup.view.disclaimer": "Je comprends que si je perds ma phrase de récupération, Uniswap Labs ne peut pas m’aider à la restaurer.", "onboarding.backup.view.subtitle.message1": "Lisez attentivement ce qui suit avant de continuer", "onboarding.backup.view.subtitle.message2": "Vous devrez saisir ces 12 mots secrets pour récupérer votre wallet.", "onboarding.backup.view.title": "Notez votre phrase de récupération", "onboarding.backup.view.warning.message1": "Cette phrase de récupération vous donne un accès complet à votre wallet et à vos fonds", "onboarding.backup.view.warning.message2": "Notez-la et conservez-la dans un endroit sûr", "onboarding.backup.view.warning.message3": "Consultez-la en privé et <u>ne la partagez avec personne</u>", - "onboarding.cloud.confirm.description": "Vous devez saisir ce mot de passe pour récupérer votre compte. Il n’est stocké nulle part et ne peut donc être récupéré par personne d’autre.", - "onboarding.cloud.confirm.title": "Confirmer votre mot de passe de sauvegarde", + "onboarding.cloud.confirm.description": "Vous devrez saisir ce mot de passe pour récupérer votre compte. Il n’est stocké nulle part et ne peut donc être récupéré par personne d’autre.", + "onboarding.cloud.confirm.title": "Confirmez votre mot de passe de sauvegarde", "onboarding.cloud.createPassword.description": "Vous devrez saisir ce mot de passe pour récupérer votre wallet.", "onboarding.cloud.createPassword.title": "Créer votre mot de passe de sauvegarde", - "onboarding.complete.button": "Ouvrir l'extension Uniswap", - "onboarding.complete.description": "Votre wallet est prêt à envoyer et recevoir des crypto-monnaies. Ouvrez l'extension Uniswap avec le raccourci ci-dessous.", - "onboarding.complete.go_to_uniswap": "Accéder à l'application Web Uniswap", - "onboarding.complete.pin.description": "Cliquez sur l'icône en forme d'épingle pour ajouter l'extension Uniswap à la barre d'outils.", - "onboarding.complete.pin.title": "Épingler l'extension Uniswap", + "onboarding.complete.button": "Ouvrir l’extension Uniswap", + "onboarding.complete.description": "Votre wallet est prêt à envoyer et recevoir des crypto-monnaies. Ouvrez l’extension Uniswap avec le raccourci ci-dessous.", + "onboarding.complete.go_to_uniswap": "Accéder à l’application Web Uniswap", + "onboarding.complete.pin.description": "Cliquez sur l’icône en forme d’épingle pour ajouter l’extension Uniswap à la barre d’outils.", + "onboarding.complete.pin.title": "Épingler l’extension Uniswap", "onboarding.complete.title": "Vous êtes prêt(e)", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "Téléchargez l’application mobile pour demander un nom d’utilisateur", - "onboarding.extension.getOnTheBetaWaitlist.title": "Inscrivez-vous sur la liste d’attente de la version bêta", "onboarding.extension.password.subtitle": "Vous en aurez besoin pour déverrouiller votre wallet et accéder à votre phrase de récupération", "onboarding.extension.password.title.default": "Créer un mot de passe", "onboarding.extension.password.title.reset": "Réinitialiser votre mot de passe", - "onboarding.extension.unsupported.description": "L'extension Uniswap n'est actuellement compatible qu'avec Chrome.", - "onboarding.extension.unsupported.title": "Ce navigateur n'est pas (encore) pris en charge", + "onboarding.extension.unsupported.android.description": "L'extension Uniswap est uniquement compatible avec Chrome sur ordinateur de bureau.", + "onboarding.extension.unsupported.android.title": "Chrome sur mobile n'est pas encore pris en charge", + "onboarding.extension.unsupported.description": "L’extension Uniswap n’est actuellement compatible qu’avec Chrome.", + "onboarding.extension.unsupported.title": "Ce navigateur n’est pas (encore) pris en charge", + "onboarding.home.intro.fund.description": "Alimentez votre wallet en achetant des cryptos ou en effectuant un transfert depuis un autre compte.", + "onboarding.home.intro.fund.title": "Obtenez votre premier jeton", + "onboarding.home.intro.welcome.description": "Terminez la configuration de votre portefeuille pour commencer à échanger en quelques secondes.", + "onboarding.home.intro.welcome.title": "Bienvenue sur Uniswap", "onboarding.import.error.invalidWords_one": "1 mot n’est pas valide ou est mal orthographié", - "onboarding.import.error.invalidWords_other": "{{count}} mots ne sont pas valides ou sont mal orthographiés", + "onboarding.import.error.invalidWords_other": "{{count}} mots sont non valides ou mal orthographiés", "onboarding.import.method.import.message": "Saisissez votre phrase de récupération depuis un autre wallet de cryptos", "onboarding.import.method.import.title": "Importer un wallet", "onboarding.import.method.restore.message.android": "Ajoutez les wallets que vous avez sauvegardés à votre compte Google Drive", @@ -462,7 +462,7 @@ "onboarding.import.onDeviceRecovery.wallet.count_one": "+1 autre wallet", "onboarding.import.onDeviceRecovery.wallet.count_other": "+{{count}} autres wallets", "onboarding.import.onDeviceRecovery.warning.caption": "Veuillez vous assurer que vous avez sauvegardé tous les autres wallets. Si jamais vous souhaitez les restaurer, vous aurez besoin de leurs phrases de récupération ou des sauvegardes iCloud correspondantes.", - "onboarding.import.onDeviceRecovery.warning.title": "Confirmez-vous ?", + "onboarding.import.onDeviceRecovery.warning.title": "Êtes-vous sûr(e) ?", "onboarding.import.title": "Choisissez comment vous souhaitez ajouter votre wallet", "onboarding.importMnemonic.button.default": "Ma phrase de récupération est de 12 mots", "onboarding.importMnemonic.button.longPhrase": "Ma phrase de récupération est plus longue", @@ -471,19 +471,12 @@ "onboarding.importMnemonic.title": "Saisissez votre phrase de récupération", "onboarding.intro.button.alreadyHave": "J’ai déjà un wallet", "onboarding.intro.mobileScan.button": "Scannez le code QR pour importer", - "onboarding.intro.mobileScan.title": "Vous avez l'application Uniswap ?", - "onboarding.introBetaWaitlist.button.checkEligibility": "Vérifier l’éligibilité", - "onboarding.introBetaWaitlist.button.letsGo": "Allons-y", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "Saisissez votre nom d’utilisateur <highlight>uni.eth</highlight> ci-dessous pour vérifier si vous êtes éligible à la version bêta.", - "onboarding.introBetaWaitlist.eligible.tagline": "Bienvenue dans la version bêta. Vous êtes l’un des premiers à essayer l’extension Uniswap.", - "onboarding.introBetaWaitlist.eligible.title": "Vous n'êtes plus sur la liste d’attente !", - "onboarding.introBetaWaitlist.ineligibleExplanation": "Vous êtes toujours sur la liste d’attente. Nous vous informerons dans l’application mobile Uniswap lorsque vous deviendrez éligible !", - "onboarding.introBetaWaitlist.unitagPlaceholder": "nom d’utilisateur", + "onboarding.intro.mobileScan.title": "Vous avez l’application Uniswap ?", "onboarding.landing.button.add": "Ajouter un wallet existant", - "onboarding.landing.button.create": "Créer un portefeuille", + "onboarding.landing.button.create": "Créer un wallet", "onboarding.notification.permission.message": "Pour recevoir des notifications, activez les notifications pour Uniswap Wallet dans les paramètres de votre appareil.", "onboarding.notification.permission.title": "Autorisation de notification", - "onboarding.notification.subtitle": "Soyez averti(e) lorsque tes transferts, échanges et approbations sont terminés.", + "onboarding.notification.subtitle": "Soyez averti lorsque vos transferts, échanges et approbations sont terminés.", "onboarding.notification.title": "Activer les notifications push", "onboarding.recoveryPhrase.confirm.subtitle.combined": "Confirmez votre phrase de récupération. Sélectionnez les mots manquants dans l’ordre.", "onboarding.recoveryPhrase.confirm.subtitle.default": "Sélectionnez les mots manquants dans l’ordre.", @@ -498,11 +491,11 @@ "onboarding.resetPassword.complete.safety": "En savoir plus sur la sécurité du wallet", "onboarding.resetPassword.complete.subtitle": "Utilisez votre nouveau mot de passe pour déverrouiller votre wallet.", "onboarding.resetPassword.complete.title": "Réinitialisation du mot de passe", - "onboarding.scan.button": "Scanner avec l’application Uniswap", - "onboarding.scan.error": "Nous sommes désolés, nous ne pouvons pas charger le code QR pour le moment. Essayez une autre méthode d’intégration.", - "onboarding.scan.otp.error": "Le code que vous avez envoyé est incorrect ou une erreur s’est produite lors de l’envoi. Réessayez.", - "onboarding.scan.otp.failed": "Nombre de tentatives échouées : {{number}}", - "onboarding.scan.otp.subtitle": "Consultez le code à 6 caractères dans votre application mobile Uniswap", + "onboarding.scan.button": "Scannez avec l’application Uniswap", + "onboarding.scan.error": "Désolé, nous ne pouvons pas charger le code QR pour le moment. Veuillez essayer une autre méthode d’intégration.", + "onboarding.scan.otp.error": "Le code que vous avez soumis est incorrect ou une erreur s’est produite lors de la soumission. Veuillez réessayer.", + "onboarding.scan.otp.failed": "Tentatives échouées : {{number}}", + "onboarding.scan.otp.subtitle": "Vérifiez votre application mobile Uniswap pour le code à 6 caractères", "onboarding.scan.otp.title": "Saisissez le code à usage unique", "onboarding.scan.subtitle": "Scannez le code QR avec l’application mobile Uniswap pour commencer à importer votre wallet.", "onboarding.scan.title": "Importer un wallet depuis l’application", @@ -510,13 +503,13 @@ "onboarding.security.alert.biometrics.message.android": "Pour utiliser la biométrie, commencez par la configurer dans les paramètres", "onboarding.security.alert.biometrics.message.ios": "Pour utiliser {{biometricsMethod}}, autorisez l’accès dans les paramètres système", "onboarding.security.alert.biometrics.title.android": "La biométrie est désactivée", - "onboarding.security.alert.biometrics.title.ios": "La méthode {{biometricsMethod}} est désactivée", + "onboarding.security.alert.biometrics.title.ios": "{{biometricsMethod}} est désactivé", "onboarding.security.button.confirm.android": "Activer la biométrie", - "onboarding.security.button.confirm.ios": "Activer la méthode {{biometricsMethod}}", + "onboarding.security.button.confirm.ios": "Activer {{biometricsMethod}}", "onboarding.security.button.setup": "Configurer", "onboarding.security.subtitle.android": "Ajoutez une couche de sécurité supplémentaire en exigeant la biométrie pour envoyer des transactions.", "onboarding.security.subtitle.ios": "Ajoutez une couche de sécurité supplémentaire en exigeant la méthode {{biometricsMethod}} pour l’envoi de transactions.", - "onboarding.security.title": "Protèger votre wallet", + "onboarding.security.title": "Protégez votre wallet", "onboarding.selectWallets.error": "Impossible de charger les adresses", "onboarding.selectWallets.title.default": "Choisissez les wallets à importer", "onboarding.selectWallets.title.error": "Erreur lors de l’importation des wallets", @@ -535,10 +528,6 @@ "qrScanner.recipient.action.show": "Afficher mon code QR", "qrScanner.recipient.error.message": "Assurez-vous de scanner un code QR d’adresse Ethereum valide avant de réessayer.", "qrScanner.recipient.error.title": "Code QR non valide", - "qrScanner.recipient.input.placeholder": "Rechercher l’ENS ou l’adresse", - "qrScanner.recipient.label.send": "Envoyer", - "qrScanner.recipient.results.empty": "Aucun résultat trouvé", - "qrScanner.recipient.results.error": "L’adresse que vous avez saisie n’existe pas ou est mal orthographiée.", "qrScanner.request.message.unavailable": "Aucun message trouvé.", "qrScanner.request.method.default": "Demande de {{dappNameOrUrl}}", "qrScanner.request.method.signature": "Demande de signature de {{dappNameOrUrl}}", @@ -548,7 +537,7 @@ "qrScanner.status.connecting": "Connexion en cours…", "qrScanner.status.loading": "Chargement en cours…", "qrScanner.title": "Scanner un code QR", - "qrScanner.wallet.title": "Vous pouvez recevoir des jetons et des NFT sur Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast et BNB Chain.", + "qrScanner.wallet.title": "Vous pouvez recevoir des tokens et des NFT sur Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast et BNB Chain.", "scantastic.code.expired": "Expiré", "scantastic.code.subtitle": "Saisissez ce code dans l’extension Uniswap. Votre phrase de récupération sera chiffrée et transférée en toute sécurité.", "scantastic.code.timeRemaining.shorthand.hours": "Nouveau code dans {{hours}} h {{minutes}} m {{seconds}} s", @@ -562,7 +551,7 @@ "scantastic.confirmation.title": "Essayez-vous d’importer votre wallet ?", "scantastic.confirmation.warning": "Méfiez-vous des sites et applications usurpant l’identité d’Uniswap. Sinon, votre wallet pourrait être compromis.", "scantastic.error.encryption": "Échec de préparation de la phrase de récupération.", - "scantastic.error.noCode": "Aucun OTP reçu. Réessayez.", + "scantastic.error.noCode": "Aucun OTP reçu. Veuillez réessayer.", "scantastic.error.timeout.message": "Scannez à nouveau le code QR sur l’extension Uniswap pour continuer la synchronisation du wallet.", "scantastic.error.timeout.title": "Votre connexion a expiré", "scantastic.modal.ipMismatch.description": "Pour scanner ce QR code, votre téléphone doit être connecté au même réseau WiFi que votre ordinateur.", @@ -571,33 +560,37 @@ "send.button.send": "Envoyer", "send.gas.error.title": "N/A", "send.gas.networkCost.title": "Frais de réseau", - "send.input.token.balance.title": "Solde : {{balance}} {{symbol}}", + "send.input.token.balance.title": "Solde : {{balance}} {{symbol}}", + "send.recipient.header": "Sélectionner le destinataire", + "send.recipient.input.placeholder": "Rechercher l’ENS ou l’adresse", "send.recipient.previous_one": "1 transfert précédent", "send.recipient.previous_other": "{{count}} transferts précédents", + "send.recipient.results.empty": "Aucun résultat trouvé", + "send.recipient.results.error": "L’adresse que vous avez saisie n’existe pas ou est mal orthographiée.", "send.recipient.section.favorite": "Wallets favoris", "send.recipient.section.recent": "Récent", "send.recipient.section.search": "Résultats de recherche", "send.recipient.section.viewOnly": "Wallets en lecture seule", "send.recipient.section.yours": "Vos wallets", "send.recipient.warning.viewOnly.message": "N’envoyez des fonds vers ce wallet que si vous disposez de la phrase de récupération ou si vous connaissez le propriétaire du wallet.", - "send.recipient.warning.viewOnly.title": "Vous avez défini ceci en tant que wallet en lecture seule", + "send.recipient.warning.viewOnly.title": "Vous l’avez comme wallet en lecture seule", "send.recipientSelect.search.empty.message": "Lorsque vous envoyez des tokens à une adresse de wallet, ils apparaîtront ici", "send.recipientSelect.search.empty.title": "Aucun wallet enregistré", "send.review.modal.title": "Vous envoyez", "send.review.summary.button.title": "Confirmer l’envoi", - "send.review.summary.sending": "Envoi", + "send.review.summary.sending": "Envoi en cours", "send.search.empty.subtitle": "L’adresse que vous avez saisie n’existe pas ou est mal orthographiée.", "send.search.empty.title": "Aucun résultat trouvé", - "send.search.placeholder": "Rechercher l'ENS ou l'adresse", + "send.search.placeholder": "Rechercher l’ENS ou l’adresse", "send.status.fail.description": "Gardez à l’esprit que les frais de réseau sont toujours facturés en cas d’échec des transferts.", "send.status.failed.title": "Échec de l’envoi", "send.status.inProgress.description": "Nous vous informerons une fois votre transaction terminée.", - "send.status.inProgress.title": "Envoi", + "send.status.inProgress.title": "Envoi en cours", "send.status.success.description": "Vous avez envoyé {{currencyAmount}}{{tokenName}}{{fiatValue}} à {{recipient}}.", "send.status.success.title": "Envoi réussi !", "send.title": "Envoyer", "send.warning.blocked.default": "Ce wallet est bloqué", - "send.warning.blocked.modal.message": "Cette adresse est bloquée sur Uniswap Wallet car elle est associée à une ou plusieurs activités bloquées. Si vous pensez qu’il s’agit d’une erreur, envoyez un e-mail à compliance@uniswap.org.", + "send.warning.blocked.modal.message": "Cette adresse est bloquée sur Uniswap Wallet car elle est associée à une ou plusieurs activités bloquées. Si vous pensez qu’il s’agit d’une erreur, veuillez envoyer un e-mail à compliance@uniswap.org.", "send.warning.blocked.modal.title": "Adresse bloquée", "send.warning.blocked.recipient": "Le wallet du destinataire est bloqué", "send.warning.erc20.message": "Vous essayez d’envoyer des fonds à une adresse de token. L’envoi de cryptos à ce type d’adresse peut entraîner une perte permanente de fonds.", @@ -609,34 +602,37 @@ "send.warning.modal.button.cta.blocking": "OK", "send.warning.modal.button.cta.cancel": "Annuler", "send.warning.modal.button.cta.confirm": "Confirmer", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "Nom d'utilisateur", + "send.warning.newAddress.details.walletAddress": "Adresse du portefeuille", "send.warning.newAddress.message": "Vous n’avez jamais effectué de transaction avec cette adresse auparavant. Veuillez confirmer que l’adresse est correcte avant de continuer.", "send.warning.newAddress.title": "Nouvelle adresse", "send.warning.restore": "Restaurer votre wallet pour envoyer", - "send.warning.self.message": "Vous essayez d'envoyer des fonds vers votre portefeuille actuel. L’envoi de crypto à cette adresse entraînera des coûts de réseau inutiles.", + "send.warning.self.message": "Vous essayez d’envoyer des fonds vers votre wallet actuel. L’envoi de crypto à cette adresse entraînera des frais de réseau inutiles.", "send.warning.self.title": "Ceci est votre wallet actuel", - "send.warning.smartContract.message": "Vous êtes sur le point d’envoyer des tokens à un type d’adresse spécial : un contrat intelligent. Vérifiez à nouveau qu’il s’agit de l’adresse à laquelle vous avez l’intention d’envoyer. Si elle est erronée, vous risquez de perdre définitivement vos tokens.", + "send.warning.smartContract.message": "Vous êtes sur le point d’envoyer des tokens à un type d’adresse spécial : un contrat intelligent. Vérifiez à nouveau qu’il s’agit de l’adresse à laquelle vous aviez l’intention d’envoyer. Si elle est erronée, vous risquez de perdre définitivement vos tokens.", "send.warning.smartContract.title": "Est-ce une adresse de wallet ?", "send.warning.viewOnly.message": "Vous devez importer ce wallet via une phrase de récupération pour envoyer des actifs.", "send.warning.viewOnly.title": "Ce wallet est en lecture seule", "setting.recoveryPhrase.account.show": "Afficher la phrase de récupération", "setting.recoveryPhrase.action.hide": "Masquer la phrase de récupération", "setting.recoveryPhrase.remove": "Supprimer la phrase de récupération", - "setting.recoveryPhrase.remove.confirm.subtitle": "Je comprends qu'Uniswap Labs ne peut pas m'aider à récupérer mon portefeuille si je ne parviens pas à le faire.", + "setting.recoveryPhrase.remove.confirm.subtitle": "Je comprends qu’Uniswap Labs ne peut pas m’aider à récupérer mon wallet si je ne parviens pas à le faire.", "setting.recoveryPhrase.remove.confirm.title": "J’ai enregistré ma phrase de récupération", - "setting.recoveryPhrase.remove.initial.subtitle": "Assurez-vous d’avoir enregistré votre phrase de récupération. Sinon, vous perdrez l’accès à vos portefeuilles", + "setting.recoveryPhrase.remove.initial.subtitle": "Assurez-vous d’avoir enregistré votre phrase de récupération. Sinon, vous perdrez l’accès à vos wallets", "setting.recoveryPhrase.remove.initial.title": "Avant de continuer", "setting.recoveryPhrase.remove.password.error": "Mot de passe incorrect. Réessayer", "setting.recoveryPhrase.remove.subtitle": "Entrez votre mot de passe pour confirmer", "setting.recoveryPhrase.remove.title": "Vous êtes sur le point de supprimer votre phrase de récupération", "setting.recoveryPhrase.view.warning.message1": "Toute personne connaissant votre phrase de récupération peut accéder à votre wallet et à vos fonds", - "setting.recoveryPhrase.view.warning.message2": "Afficher ceci en privé", + "setting.recoveryPhrase.view.warning.message2": "Affichez ceci en privé", "setting.recoveryPhrase.view.warning.message3": "Ne partagez ceci avec personne", "setting.recoveryPhrase.view.warning.message4": "Ne le saisissez jamais sur des sites Web ou des applications", "setting.recoveryPhrase.view.warning.title": "Avant de continuer", "setting.recoveryPhrase.warning.screenshot.message": "Toute personne ayant accès à vos photos peut accéder à votre wallet. Nous vous recommandons plutôt d’écrire vos mots.", "setting.recoveryPhrase.warning.screenshot.title": "Les captures d’écran ne sont pas sécurisées", "setting.recoveryPhrase.warning.view.message": "Toute personne connaissant votre phrase de récupération peut accéder à votre wallet et à vos fonds.", - "setting.recoveryPhrase.warning.view.title": "Consultez ceci dans un lieu privé", + "setting.recoveryPhrase.warning.view.title": "Affichez ceci dans un lieu privé", "settings.action.feedback": "Partager vos commentaires", "settings.action.help": "Obtenir de l’aide", "settings.action.lock": "Verrouiller le wallet", @@ -662,11 +658,11 @@ "settings.setting.backup.create.description": "La définition d’un mot de passe permet de chiffrer la sauvegarde de votre phrase de récupération, ajoutant ainsi un niveau de protection supplémentaire si votre compte {{cloudProviderName}} est compromis.", "settings.setting.backup.create.title": "Sauvegarder sur {{cloudProviderName}}", "settings.setting.backup.delete.confirm.message": "Étant donné que ces wallets partagent une phrase de récupération, les sauvegardes des wallets ci-dessous seront également supprimées", - "settings.setting.backup.delete.confirm.title": "Confirmez-vous ?", + "settings.setting.backup.delete.confirm.title": "Êtes-vous sûr(e) ?", "settings.setting.backup.delete.warning": "Si vous supprimez votre sauvegarde {{cloudProviderName}}, vous ne pourrez récupérer votre wallet qu’avec une sauvegarde manuelle de votre phrase de récupération. Uniswap Labs ne peut pas récupérer vos actifs si vous perdez votre phrase de récupération.", - "settings.setting.backup.error.message.full": "Impossible de sauvegarder la phrase de récupération sur {{cloudProviderName}}. Assurez-vous d’avoir activé {{cloudProviderName}} et que de l’espace de stockage est disponible, puis réessayez.", + "settings.setting.backup.error.message.full": "Impossible de sauvegarder la phrase de récupération sur {{cloudProviderName}}. Veuillez vous assurer que vous avez activé {{cloudProviderName}} et que de l’espace de stockage est disponible, puis réessayez.", "settings.setting.backup.error.message.short": "Impossible de supprimer la sauvegarde", - "settings.setting.backup.error.title": "Erreur liée à {{cloudProviderName}}", + "settings.setting.backup.error.title": "Erreur {{cloudProviderName}}", "settings.setting.backup.modal.description": "Vous n’avez pas encore sauvegardé votre phrase de récupération sur {{cloudProviderName}}. Vous pourrez ainsi récupérer votre wallet simplement en vous connectant à {{cloudProviderName}} sur n’importe quel appareil.", "settings.setting.backup.modal.title": "Sauvegarder la phrase de récupération sur {{cloudProviderName}} ?", "settings.setting.backup.password.disclaimer": "Uniswap Labs ne stocke pas votre mot de passe et ne peut pas le récupérer, il est donc crucial que vous vous en souveniez.", @@ -677,18 +673,18 @@ "settings.setting.backup.password.strong": "Ceci est un mot de passe fort", "settings.setting.backup.password.weak": "Ceci est un mot de passe faible", "settings.setting.backup.recoveryPhrase.label": "Phrase de récupération", - "settings.setting.backup.selected": "Sauvegarde de {{cloudProviderName}}", + "settings.setting.backup.selected": "Sauvegarde {{cloudProviderName}}", "settings.setting.backup.status.action.delete": "Supprimer la sauvegarde", "settings.setting.backup.status.complete": "Sauvegardé sur {{cloudProviderName}}", "settings.setting.backup.status.description": "En sauvegardant votre phrase de récupération sur {{cloudProviderName}}, vous pourrez récupérer votre wallet simplement en vous connectant à votre compte {{cloudProviderName}} sur n’importe quel appareil.", "settings.setting.backup.status.inProgress": "Sauvegarde en cours sur {{cloudProviderName}}…", "settings.setting.backup.status.recoveryPhrase.backed": "Sauvegardé", - "settings.setting.backup.status.title": "Sauvegarde de {{cloudProviderName}}", + "settings.setting.backup.status.title": "Sauvegarde {{cloudProviderName}}", "settings.setting.beta.tooltip": "À venir", "settings.setting.biometrics.appAccess.subtitle.android": "Exiger la biométrie pour ouvrir l’application", "settings.setting.biometrics.appAccess.subtitle.ios": "Exiger la méthode {{biometricsMethod}} pour ouvrir l’application", "settings.setting.biometrics.appAccess.title": "Accès à l’application", - "settings.setting.biometrics.auth": "Authentifiez-vous", + "settings.setting.biometrics.auth": "Veuillez vous authentifier", "settings.setting.biometrics.off.message.android": "La biométrie est actuellement désactivée pour Uniswap Wallet. Vous pouvez l’activer dans les paramètres de votre système.", "settings.setting.biometrics.off.message.ios": "La méthode {{biometricsMethod}} est actuellement désactivée pour Uniswap Wallet. Vous pouvez l’activer dans les paramètres de votre système.", "settings.setting.biometrics.off.title.android": "La biométrie est désactivée", @@ -697,19 +693,18 @@ "settings.setting.biometrics.transactions.subtitle.android": "Exiger la biométrie pour effectuer des transactions", "settings.setting.biometrics.transactions.subtitle.ios": "Exiger la méthode {{biometricsMethod}} pour effectuer des transactions", "settings.setting.biometrics.transactions.title": "Transactions", - "settings.setting.biometrics.unavailable.message.android": "La biométrie n’est pas configurée sur votre appareil. Pour utiliser la biométrie, configurez-la d’abord dans Paramètres.", + "settings.setting.biometrics.unavailable.message.android": "La biométrie n’est pas configurée sur votre appareil. Pour utiliser la biométrie, configurez-la d’abord dans les paramètres.", "settings.setting.biometrics.unavailable.message.ios": "La méthode {{biometricsMethod}} n’est pas configurée sur votre appareil. Pour utiliser la méthode {{biometricsMethod}}, configurez-la d’abord dans les paramètres.", "settings.setting.biometrics.unavailable.title.android": "La biométrie n’est pas configurée", "settings.setting.biometrics.unavailable.title.ios": "La méthode {{biometricsMethod}} n’est pas configurée", "settings.setting.biometrics.warning.message.android": "Si vous n’activez pas la biométrie, toute personne ayant accès à votre appareil peut ouvrir Uniswap Wallet et effectuer des transactions.", "settings.setting.biometrics.warning.message.ios": "Si vous n’activez pas la méthode {{biometricsMethod}}, toute personne ayant accès à votre appareil peut ouvrir Uniswap Wallet et effectuer des transactions.", - "settings.setting.biometrics.warning.title": "Êtes-vous sûr ?", + "settings.setting.biometrics.warning.title": "Êtes-vous sûr(e) ?", "settings.setting.currency.title": "Devise locale", - "settings.setting.giveFeedback.title": "Partager vos commentaires", "settings.setting.helpCenter.title": "Centre d’aide", "settings.setting.language.button.navigate": "Accéder aux paramètres", "settings.setting.language.description.extension": "Uniswap utilise par défaut les paramètres de langue de votre système. Pour modifier votre langue préférée, accédez aux paramètres de votre système.", - "settings.setting.language.description.mobile": "Uniswap utilise par défaut les paramètres de langue de votre appareil. Pour changer votre langue préférée, allez sur \"Uniswap\" dans les paramètres de votre appareil et appuyez sur \"Langue\".", + "settings.setting.language.description.mobile": "Uniswap utilise par défaut les paramètres de langue de votre appareil. Pour changer votre langue préférée, allez sur « Uniswap » dans les paramètres de votre appareil et appuyez sur « Langue ».", "settings.setting.language.title": "Langue", "settings.setting.password.title": "Modifier le mot de passe", "settings.setting.privacy.analytics.description": "Nous utilisons des données d’utilisation anonymes pour améliorer votre expérience avec les produits Uniswap Labs. Lorsque cette option est désactivée, nous suivons uniquement les erreurs et l’utilisation essentielle.", @@ -723,7 +718,7 @@ "settings.setting.wallet.action.editProfile": "Modifier le profil", "settings.setting.wallet.action.remove": "Supprimer le wallet", "settings.setting.wallet.connections.title": "Gérer les connexions", - "settings.setting.wallet.editLabel.description": "Les libellés ne sont pas publics. Ils sont stockés en local et vous êtes la seule personne qui peut les voir.", + "settings.setting.wallet.editLabel.description": "Les étiquettes ne sont pas publiques. Elles sont stockées en local et visibles uniquement par vous.", "settings.setting.wallet.editLabel.save": "Sauvegarder les modifications", "settings.setting.wallet.label": "Pseudo", "settings.setting.wallet.notifications.title": "Notifications", @@ -732,14 +727,12 @@ "settings.version": "Version {{appVersion}}", "swap.button.max": "Max.", "swap.button.review": "Examiner", - "swap.button.submitting": "Soumission de l'échange...", + "swap.button.submitting": "Soumission de l’échange...", "swap.button.submitting.keep.open": "Ne pas fermer le wallet…", "swap.button.swap": "Échanger", "swap.button.unwrap": "Déballer", "swap.button.view": "Afficher la transaction", "swap.button.wrap": "Envelopper", - "swap.details.action.less": "Afficher moins", - "swap.details.action.more": "Afficher plus", "swap.details.feeOnTransfer": "Frais de {{tokenSymbol}}", "swap.details.newQuote.input": "Nouvelle entrée", "swap.details.newQuote.output": "Nouvelle sortie", @@ -768,14 +761,14 @@ "swap.settings.routingPreference.option.default.title": "Défaut", "swap.settings.routingPreference.option.v2.title": "Pools v2", "swap.settings.routingPreference.option.v3.title": "Pools v3", - "swap.settings.routingPreference.title": "Options d'échange", + "swap.settings.routingPreference.title": "Options d’échange", "swap.settings.slippage.control.auto": "Automatique", "swap.settings.slippage.control.title": "Taux de slippage maximum", "swap.settings.slippage.description": "Votre transaction sera annulée si le prix change plus que le pourcentage de slippage.", - "swap.settings.slippage.input.message": "Si le prix glisse encore, votre transaction sera annulée. Vous trouverez ci-dessous le montant minimum que vous êtes assuré de recevoir.", + "swap.settings.slippage.input.message": "Si le prix glisse encore, votre transaction sera annulée. Vous trouverez ci-dessous le montant minimum que vous êtes assuré(e) de recevoir.", "swap.settings.slippage.input.receive.title": "Recevez au moins", "swap.settings.slippage.output.message": "Si le prix glisse encore, votre transaction sera annulée. Vous trouverez ci-dessous le montant maximum que vous devrez dépenser.", - "swap.settings.slippage.output.spend.title": "Dépenser au maximum", + "swap.settings.slippage.output.spend.title": "Dépensez au maximum", "swap.settings.slippage.warning.max": "Saisissez une valeur inférieure à {{maxSlippageTolerance}}", "swap.settings.slippage.warning.message": "Le slippage est peut-être plus élevé que nécessaire", "swap.settings.slippage.warning.min": "Saisissez une valeur supérieure à 0", @@ -787,6 +780,7 @@ "swap.warning.insufficientBalance.button": "Pas assez de {{currencySymbol}}", "swap.warning.insufficientBalance.title": "Vous n’avez pas assez de {{currencySymbol}}", "swap.warning.insufficientGas.button": "Pas assez de {{currencySymbol}}", + "swap.warning.insufficientGas.button.buy": "Acheter {{ tokenSymbol }}", "swap.warning.insufficientGas.message.withNetwork": "Pas assez de <highlight>{{currencySymbol}} sur {{networkName}}</highlight> pour échanger", "swap.warning.insufficientGas.message.withoutNetwork": "Pas assez <highlight>{{currencySymbol}}</highlight> pour échanger", "swap.warning.insufficientGas.title": "Vous n’avez pas assez de {{currencySymbol}} pour couvrir les frais de réseau", @@ -794,23 +788,23 @@ "swap.warning.lowLiquidity.title": "Pas assez de liquidité", "swap.warning.networkFee.allow": "Autoriser {{ inputTokenSymbol }} (une fois)", "swap.warning.networkFee.highRelativeToValue": "Les frais de réseau dépassent 10 % de la valeur totale de votre transaction.", - "swap.warning.networkFee.message": "Il s’agit du coût de traitement de ta transaction sur la blockchain. Uniswap ne reçoit aucune part de ces frais.", + "swap.warning.networkFee.message": "Il s’agit du coût de traitement de votre transaction sur la blockchain. Uniswap ne reçoit aucune part de ces frais.", "swap.warning.networkFee.message.uniswapX": "Il s’agit du coût de traitement de votre transaction sur la blockchain. Uniswap ne reçoit aucune part de ces frais. <gradient>UniswapX</gradient> regroupe les sources de liquidités pour de meilleurs prix et des échanges sans frais de gaz.", "swap.warning.networkFee.wrap": "Enveloppement ETH", - "swap.warning.offline.message": "Votre connexion Internet a peut-être été interrompue ou le réseau est peut-être en panne. Vérifiez votre connexion à Internet, puis réessayez.", + "swap.warning.offline.message": "Votre connexion Internet a peut-être été interrompue ou le réseau est peut-être en panne. Veuillez vérifier votre connexion à Internet, puis réessayez.", "swap.warning.offline.title": "Vous êtes hors ligne", "swap.warning.priceImpact.message": "En raison du montant des liquidités en {{outputCurrencySymbol}} actuellement disponibles, plus vous essayez d’échanger de {{inputCurrencySymbol}}, moins vous recevrez de {{outputCurrencySymbol}}.", "swap.warning.priceImpact.title": "Impact sur les prix élevés ({{priceImpactValue}})", - "swap.warning.queuedOrder.appClosed": "Votre transaction n'a pas été soumise car vous avez fermé l'application.", - "swap.warning.queuedOrder.approvalFailed": "Votre transaction n'a pas été soumise car l'approbation du jeton a échoué.", - "swap.warning.queuedOrder.stale": "Votre transaction n'a pas été soumise car vous avez fermé l'application ou l'approbation a pris trop de temps.", + "swap.warning.queuedOrder.appClosed": "Votre transaction n’a pas été soumise car vous avez fermé l’application.", + "swap.warning.queuedOrder.approvalFailed": "Votre transaction n’a pas été soumise car l’approbation du token a échoué.", + "swap.warning.queuedOrder.stale": "Votre transaction n’a pas été soumise car vous avez fermé l’application ou l’approbation a pris trop de temps.", "swap.warning.queuedOrder.submissionFailed": "Un problème est survenu lors de la soumission de votre transaction.", "swap.warning.queuedOrder.title": "Échange annulé", - "swap.warning.queuedOrder.wrap.message": "L'ETH restera enveloppé en tant que WETH.", - "swap.warning.queuedOrder.wrapFailed": "La transaction n'a pas été envoyée, car la transaction d'emballage a échoué.", + "swap.warning.queuedOrder.wrap.message": "L’ETH restera enveloppé en tant que WETH.", + "swap.warning.queuedOrder.wrapFailed": "La transaction n’a pas été envoyée, car la transaction d’emballage a échoué.", "swap.warning.rateLimit.message": "Réessayez dans quelques minutes.", "swap.warning.rateLimit.title": "Limite de taux dépassée", - "swap.warning.router.message": "Votre connexion a peut-être été interrompue ou le réseau est peut-être en panne. Si le problème persiste, réessayez plus tard.", + "swap.warning.router.message": "Votre connexion a peut-être été interrompue ou le réseau est peut-être en panne. Si le problème persiste, veuillez réessayer plus tard.", "swap.warning.router.title": "Cet échange ne peut pas être finalisé pour le moment", "swap.warning.uniswapFee.message.default": "Des frais sont appliqués pour garantir la meilleure expérience avec Uniswap. Aucuns frais ne sont associés à cet échange.", "swap.warning.uniswapFee.message.included": "Des frais sont appliqués pour garantir la meilleure expérience avec Uniswap et ont déjà été pris en compte dans ce devis.", @@ -835,17 +829,19 @@ "token.safetyLevel.medium.header": "Attention", "token.safetyLevel.medium.message": "Ce token n’est pas négocié sur les principales bourses centralisées américaines. Effectuez toujours vos propres recherches avant de procéder à des échanges.", "token.safetyLevel.strong.header": "Avertissement", - "token.safetyLevel.strong.message": "Ce token n’est ni négocié sur les principales bourses centralisées américaines, ni fréquemment échangé sur Uniswap. Effectuez toujours vos propres recherches avant de procéder à des échanges.", + "token.safetyLevel.strong.message": "Ce token n’est pas négocié sur les principales bourses centralisées américaines ni fréquemment échangé sur Uniswap. Effectuez toujours vos propres recherches avant de procéder à des échanges.", "token.selector.search.error": "Impossible de charger les résultats de la recherche", "token.stats.fullyDilutedValuation": "Valorisation entièrement diluée", "token.stats.marketCap": "Capitalisation boursière", - "token.stats.priceHighYear": "Élevé (52W)", - "token.stats.priceLowYear": "Bas (52W)", + "token.stats.priceHighYear": "52W haute", + "token.stats.priceLowYear": "52W basse", "token.stats.section.about": "À propos de {{token}}", "token.stats.title": "Statistiques", "token.stats.translation.original": "Afficher l’original", "token.stats.translation.translate": "Traduire en {{language}}", "token.stats.volume": "Volume 24 h", + "token.zeroNativeBalance.description": "Pour obtenir {{ tokenSymbol }}, vous avez d’abord besoin de {{ nativeTokenSymbol }} pour payer les frais de réseau. Commencez par financer votre portefeuille avec {{ nativeTokenSymbol }}.", + "token.zeroNativeBalance.title": "Vous avez besoin {{ nativeTokenName }} ", "tokens.action.hide": "Masquer le token", "tokens.action.unhide": "Afficher le token", "tokens.hidden.label": "Masqué ({{numHidden}})", @@ -872,7 +868,7 @@ "tokens.nfts.list.none.description.default": "Transférez des NFT depuis un autre wallet pour commencer.", "tokens.nfts.list.none.description.external": "Lorsque ce wallet achète ou reçoit des NFT, ils apparaissent ici.", "tokens.nfts.list.none.title": "Pas encore de NFT", - "tokens.selector.button.choose": "Sélectionner le token", + "tokens.selector.button.choose": "Sélectionnez le token", "tokens.selector.button.clear": "Tout effacer", "tokens.selector.empty.buy.message": "Achetez des cryptos avec une carte ou une banque pour envoyer des tokens.", "tokens.selector.empty.buy.title": "Acheter des cryptos", @@ -892,17 +888,18 @@ "transaction.action.cancel.description": "Si vous annulez cette transaction avant qu’elle ne soit traitée par le réseau, vous paierez de nouveaux frais de réseau au lieu des frais d’origine.", "transaction.action.cancel.title": "Annuler cette transaction ?", "transaction.action.copy": "Copier l’ID de transaction", - "transaction.action.copyMoonPay": "Copier l’ID de transaction MoonPay", + "transaction.action.copyProvider": "Copier l’ID de transaction de {{providerName}}", "transaction.action.view": "Afficher {{tokenSymbol}}", "transaction.action.viewEtherscan": "Afficher sur {{blockExplorerName}}", - "transaction.action.viewMoonPay": "Afficher sur MoonPay", - "transaction.amount.unlimited": "Illimitée", + "transaction.amount.unlimited": "Illimité", "transaction.currency.unknown": "token inconnu", - "transaction.date": "Envoyée le {{date}}", + "transaction.date": "Soumis le {{date}}", "transaction.details.dappName": "Application", "transaction.details.from": "Depuis", "transaction.details.networkFee": "Frais de réseau", + "transaction.details.swapRate": "Taux", "transaction.details.transactionId": "Identifiant de transaction", + "transaction.details.uniswapFee": "Frais ({{ feePercent }}%)", "transaction.network.all": "Tous les réseaux", "transaction.networkCost.label": "Frais de réseau", "transaction.notification.error.cancel": "Impossible d’annuler la transaction", @@ -930,7 +927,7 @@ "transaction.status.mint.canceling": "Annulation de la frappe", "transaction.status.mint.failed": "Échec de la frappe", "transaction.status.mint.pending": "Frappe en cours", - "transaction.status.mint.success": "Frappé", + "transaction.status.mint.success": "Frappée", "transaction.status.mint.successDapp": "Frappée sur {{externalDappName}}", "transaction.status.purchase.canceled": "Achat annulé", "transaction.status.purchase.canceling": "Annulation de l’achat", @@ -965,12 +962,12 @@ "transaction.status.send.canceled": "Envoi annulé", "transaction.status.send.canceling": "Annulation de l’envoi", "transaction.status.send.failed": "Échec de l’envoi", - "transaction.status.send.pending": "Envoi", + "transaction.status.send.pending": "Envoi en cours", "transaction.status.send.success": "Envoyé", "transaction.status.send.successDapp": "Envoyé sur {{externalDappName}}", "transaction.status.swap.canceled": "Échange annulé", "transaction.status.swap.canceling": "Annulation de l’échange", - "transaction.status.swap.expired": "L'échange a expiré", + "transaction.status.swap.expired": "L’échange a expiré", "transaction.status.swap.failed": "Échec de l’échange", "transaction.status.swap.insufficientFunds": "Fonds insuffisants", "transaction.status.swap.pending": "Échange en cours", @@ -1000,37 +997,37 @@ "uniswapx.label": "UniswapX", "unitags.banner.button.claim": "Demander maintenant", "unitags.banner.subtitle": "Créez un profil Web3 personnalisé et partagez facilement votre adresse avec vos amis.", - "unitags.banner.title.compact": "<highlight>Demandez votre {{unitagDomain}} nom d’utilisateur</highlight> et créez votre profil personnalisable.", - "unitags.banner.title.full": "Demander votre nom d’utilisateur {{unitagDomain}}", + "unitags.banner.title.compact": "<highlight>Revendiquez votre nom d’utilisateur {{unitagDomain}}</highlight> et créez votre profil personnalisable.", + "unitags.banner.title.full": "Revendiquez votre nom d’utilisateur {{unitagDomain}}", "unitags.choosePhoto.option.cameraRoll": "Choisir dans la pellicule", - "unitags.choosePhoto.option.nft": "Choisis un NFT", + "unitags.choosePhoto.option.nft": "Choisir un NFT", "unitags.choosePhoto.option.remove": "Supprimer la photo de profil", "unitags.claim.confirmation.customize": "Personnaliser le profil", "unitags.claim.confirmation.description": "{{unitagAddress}} est prêt à envoyer et à recevoir des cryptos. Continuez à développer votre wallet en personnalisant votre profil Web3.", "unitags.claim.confirmation.success.long": "Vous avez compris !", - "unitags.claim.confirmation.success.short": "j’ai compris !", + "unitags.claim.confirmation.success.short": "compris !", "unitags.claim.error.addressLimit": "Vous avez déjà apporté le nombre maximum de modifications à votre nom d’utilisateur pour cette adresse", - "unitags.claim.error.appCheck": "Échec de revendication du nom d’utilisateur. Réessayez demain.", + "unitags.claim.error.appCheck": "Impossible de revendiquer le nom d’utilisateur. Veuillez réessayer demain.", "unitags.claim.error.avatar": "Impossible de définir l’avatar. Réessayez plus tard.", - "unitags.claim.error.default": "Échec de revendication du nom d’utilisateur. Réessayez plus tard.", + "unitags.claim.error.default": "Impossible de revendiquer le nom d’utilisateur. Réessayez plus tard.", "unitags.claim.error.deviceLimit": "Vous avez atteint le nombre maximum de noms d’utilisateur pouvant être actifs pour cet appareil", - "unitags.claim.error.ens": "Pour demander ce nom d’utilisateur, vous devez posséder l’ENS {{username}}.eth", + "unitags.claim.error.ens": "Pour revendiquer ce nom d’utilisateur, vous devez posséder l’ENS {{username}}.eth", "unitags.claim.error.ensMismatch": "Ce nom d’utilisateur n’est pas disponible actuellement.", - "unitags.claim.error.general": "Impossible de demander le nom d’utilisateur", + "unitags.claim.error.general": "Impossible de revendiquer le nom d’utilisateur", "unitags.claim.error.unavailable": "Ce nom d’utilisateur n’est pas disponible", "unitags.claim.error.unknown": "Erreur inconnue", "unitags.claim.username.default": "votre nom", - "unitags.delete.confirm.subtitle": "Vous êtes sur le point de supprimer votre nom d’utilisateur et les détails de votre profil personnalisables. Vous ne pourrez pas les récupérer.", - "unitags.delete.confirm.title": "Confirmez-vous ?", - "unitags.editProfile.placeholder": "nom d’utilisateur", + "unitags.delete.confirm.subtitle": "Vous êtes sur le point de supprimer votre nom d’utilisateur et les détails de votre profil personnalisables. Vous ne pourrez pas le récupérer.", + "unitags.delete.confirm.title": "Êtes-vous sûr(e) ?", + "unitags.editProfile.placeholder": "Nom d’utilisateur", "unitags.editUsername.button.confirm": "Sauvegarder les modifications", - "unitags.editUsername.confirm.subtitle": "Vous êtes sur le point de modifier votre nom d’utilisateur. Une fois que vous l’aurez modifié, vous ne pourrez plus jamais le revendiquer.", - "unitags.editUsername.confirm.title": "Confirmez-vous ?", + "unitags.editUsername.confirm.subtitle": "Vous êtes sur le point de changer votre nom d’utilisateur. Une fois que vous l’aurez modifié, vous ne pourrez plus jamais le revendiquer.", + "unitags.editUsername.confirm.title": "Êtes-vous sûr(e) ?", "unitags.editUsername.title": "Modifier le nom d’utilisateur", - "unitags.editUsername.warning.default": "Une fois que vous avez modifié votre nom d’utilisateur, vous ne pourrez plus jamais le demander. Vous ne pouvez le modifier que 2 fois.", - "unitags.editUsername.warning.max": "Vous avez atteint le nombre maximum de 2 modifications de nom d’utilisateur.", + "unitags.editUsername.warning.default": "Une fois que vous avez modifié votre nom d’utilisateur, vous ne pourrez plus jamais le revendiquer. Vous ne pouvez le changer que 2 fois.", + "unitags.editUsername.warning.max": "Vous avez atteint le nombre maximum de 2 changements de nom d’utilisateur.", "unitags.intro.features.ens": "Optimisé par les sous-domaines ENS", - "unitags.intro.features.free": "La demande est gratuite", + "unitags.intro.features.free": "La revendication est gratuite", "unitags.intro.features.profile": "Profils personnalisables", "unitags.intro.subtitle": "Dites adieu aux adresses 0x. Les noms d’utilisateur sont des noms lisibles qui facilitent l’envoi et la réception de cryptos.", "unitags.intro.title": "Présentation des noms d’utilisateur", @@ -1038,17 +1035,17 @@ "unitags.notification.delete.title": "Nom d’utilisateur supprimé", "unitags.notification.profile.error": "Impossible de mettre à jour le profil. Réessayez plus tard.", "unitags.notification.profile.title": "Profil mis à jour", - "unitags.notification.username.error": "Impossible de modifier le nom d’utilisateur. Réessayez plus tard.", + "unitags.notification.username.error": "Impossible de changer le nom d’utilisateur. Réessayez plus tard.", "unitags.notification.username.title": "Nom d’utilisateur modifié", "unitags.onboarding.claim.subtitle": "Il s’agit de votre nom unique auquel n’importe qui peut envoyer des cryptos.", "unitags.onboarding.claim.title.choose": "Choisissez votre nom d’utilisateur", - "unitags.onboarding.claim.title.claim": "Demander votre nom d’utilisateur", - "unitags.onboarding.claimPeriod.description": "Le nom d’utilisateur {{username}} est réservé pour une durée limitée. Importez le wallet qui possède l’ENS {{username}}.eth pour demander ce nom d’utilisateur, ou réessayez une fois la période de demade écoulée.", - "unitags.onboarding.claimPeriod.link": "Apprenez-en davantage sur notre <highlight>période de demande</highlight>.", - "unitags.onboarding.claimPeriod.title": "Période de demande de l’ENS", - "unitags.onboarding.info.description": "Les noms d’utilisateur transforment les adresses 0x complexes en noms lisibles. En demandant un nom d’utilisateur {{unitagDomain}}, vous pouvez facilement envoyer et recevoir des cryptos et créer un profil web3 public.", + "unitags.onboarding.claim.title.claim": "Revendiquez votre nom d’utilisateur", + "unitags.onboarding.claimPeriod.description": "Pour une durée limitée, le nom d’utilisateur {{username}} est réservé. Importez le wallet qui possède l’ENS {{username}}.eth pour revendiquer ce nom d’utilisateur, ou réessayez une fois la période de revendication écoulée.", + "unitags.onboarding.claimPeriod.link": "Apprenez-en davantage sur notre <highlight>période de revendication</highlight>.", + "unitags.onboarding.claimPeriod.title": "Période de revendication de l’ENS", + "unitags.onboarding.info.description": "Les noms d’utilisateur transforment les adresses 0x complexes en noms lisibles. En revendiquant un nom d’utilisateur {{unitagDomain}}, vous pouvez facilement envoyer et recevoir des cryptos et créer un profil Web3 public.", "unitags.onboarding.info.title": "Une adresse simplifiée", - "unitags.onboarding.profile.subtitle": "Téléchargez le votre ou restez fidèle à votre Unicon unique. Vous pourrez toujours modifier cela plus tard.", + "unitags.onboarding.profile.subtitle": "Téléchargez le vôtre ou restez fidèle à votre Unicon unique. Vous pourrez toujours modifier cela plus tard.", "unitags.onboarding.profile.title": "Choisissez une photo de profil", "unitags.profile.action.delete": "Supprimer le nom d’utilisateur", "unitags.profile.action.edit": "Modifier le nom d’utilisateur", @@ -1064,11 +1061,11 @@ "walletConnect.dapps.empty.description": "Connectez-vous à une application en scannant un code via WalletConnect", "walletConnect.dapps.manage.empty.title": "Aucune application connectée", "walletConnect.dapps.manage.title": "Gérer les connexions", - "walletConnect.error.connection.message": "Uniswap Wallet prend actuellement en charge {{chainNames}}. Utilisez uniquement \"{{dappName}}\" sur ces chaînes", + "walletConnect.error.connection.message": "Uniswap Wallet prend actuellement en charge {{chainNames}}. Veuillez utiliser uniquement \"{{dappName}}\" sur ces chaînes", "walletConnect.error.connection.title": "Erreur de connexion", - "walletConnect.error.general.message": "Un problème associé à WalletConnect est survenu. Réessayez", + "walletConnect.error.general.message": "Un problème est survenu avec WalletConnect. Veuillez réessayer", "walletConnect.error.general.title": "Erreur WalletConnect", - "walletConnect.error.scantastic.message": "Un problème associé à votre code QR est survenu. Réessayez", + "walletConnect.error.scantastic.message": "Un problème est survenu avec votre code QR. Veuillez réessayer", "walletConnect.error.scantastic.title": "Code QR non valide", "walletConnect.error.unsupported.message": "Assurez-vous de scanner un code QR WalletConnect, Ethereum ou Uniswap Extension valide avant de réessayer.", "walletConnect.error.unsupported.title": "Code QR non valide", @@ -1085,15 +1082,17 @@ "walletConnect.permissions.networks": "Réseaux", "walletConnect.permissions.option.transferAssets": "Transférer vos actifs sans consentement", "walletConnect.permissions.option.viewTokenBalances": "Consulter vos soldes de tokens", - "walletConnect.permissions.option.viewWalletAddress": "Afficher l’adresse de ton wallet", + "walletConnect.permissions.option.viewWalletAddress": "Afficher l’adresse de votre wallet", "walletConnect.permissions.title": "Autorisations du site", "walletConnect.request.button.scrollDown": "Faites défiler vers le bas pour signer", "walletConnect.request.button.sign": "Signer", "walletConnect.request.details.label.function": "Fonction", - "walletConnect.request.details.label.sending": "Envoi", + "walletConnect.request.details.label.sending": "Envoi en cours", + "walletConnect.request.details.label.token": "Token", + "walletConnect.request.details.label.tokens": "Tokens", "walletConnect.request.error.insufficientFunds": "Vous n’avez pas assez de fonds en {{currencySymbol}} pour finaliser cette transaction.", "walletConnect.request.error.network": "Erreur de connexion Internet ou réseau", "walletConnect.request.warning.general.message": "Attention : ce message peut entraîner un transfert d’actifs", "walletConnect.request.warning.message": "Pour pouvoir signer des messages ou des transactions, vous devrez importer la phrase de récupération du wallet.", - "walletConnect.request.warning.title": "Ce wallet est en mode visualisation seule" + "walletConnect.request.warning.title": "Ce wallet est en mode lecture seule" } diff --git a/packages/uniswap/src/i18n/locales/translations/ja-JP.json b/packages/uniswap/src/i18n/locales/translations/ja-JP.json index b12c42f45f2..92f81e9549e 100644 --- a/packages/uniswap/src/i18n/locales/translations/ja-JP.json +++ b/packages/uniswap/src/i18n/locales/translations/ja-JP.json @@ -7,7 +7,7 @@ "account.cloud.empty.title": "バックアップが見つかりませんでした", "account.cloud.error.backup.message": "権限なし、認証の中断、またはクラウド エラーのため、バックアップをインポートできませんでした", "account.cloud.error.backup.title": "バックアップのインポート中にエラーが発生しました", - "account.cloud.error.password.title": "無効なパスワードです。もう一度やり直してください。", + "account.cloud.error.password.title": "無効なパスワードです。もう一度お試しください。", "account.cloud.error.unavailable.button.cancel": "今は行わない", "account.cloud.error.unavailable.button.settings": "設定に移動する", "account.cloud.error.unavailable.message.android": "このデバイスで Google ドライブが有効になっている Google アカウントにログインしていることを確認して、もう一度お試しください。", @@ -30,7 +30,7 @@ "account.recoveryPhrase.education.part5": "リカバリフレーズを<highlight>書き留めて</highlight>おいて安全な場所に保存することで、手動でバックアップすることもできます。", "account.recoveryPhrase.education.part6": "リカバリフレーズを紛失するとウォレットを復元できなくなるため、<highlight>両方のタイプのバックアップ</highlight>を使用することをお勧めします。", "account.recoveryPhrase.error.invalid": "無効なフレーズです", - "account.recoveryPhrase.error.invalidWord": "無効な単語 ({{word}}) です", + "account.recoveryPhrase.error.invalidWord": "無効な単語 {{word}} です", "account.recoveryPhrase.error.phraseLength": "リカバリフレーズは 12 ~ 24 ワードである必要があります", "account.recoveryPhrase.error.wrong": "リカバリフレーズが間違っています", "account.recoveryPhrase.helpText.import": "リカバリフレーズを見つけるにはどうすればよいですか?", @@ -41,7 +41,7 @@ "account.recoveryPhrase.remove.import.description": "一度に保存できるリカバリフレーズは 1 つだけです。新しいリカバリフレーズのインポートを続けるには、現在のリカバリフレーズと関連ウォレットをこのデバイスから削除する必要があります。", "account.recoveryPhrase.remove.initial.description": "これにより、ウォレットがリカバリフレーズと共にこのデバイスから削除されます。", "account.recoveryPhrase.remove.initial.title": "<highlight>{{walletName}}</highlight> を削除中です", - "account.recoveryPhrase.remove.mnemonic.description": "{{walletName}} と同じリカバリフレーズを共有しています。リカバリフレーズは、残りのウォレットをすべて削除するまで引き続き保存されます。", + "account.recoveryPhrase.remove.mnemonic.description": "{{walletNames, list}} と同じリカバリフレーズを共有しています。リカバリフレーズは、残りのウォレットをすべて削除するまで引き続き保存されます。", "account.recoveryPhrase.subtitle.import": "リカバリフレーズの保存は、デバイスでのローカルに限られます。", "account.recoveryPhrase.subtitle.restoring": "以下にリカバリフレーズを入力するか、バックアップをもう一度検索してみてください。", "account.recoveryPhrase.title.import": "リカバリフレーズを入力してください", @@ -85,6 +85,8 @@ "account.wallet.watch.message": "表示専用ウォレットを追加すると、アプリを試したり、ウォレットを追跡したりできるようになります。資金のスワップや送金はできません。", "account.wallet.watch.placeholder": "ENS またはアドレス", "account.wallet.watch.title": "ウォレット アドレスを入力してください", + "common.action.go": "移動する", + "common.action.swipe": "スワイプ", "common.button.accept": "受け入れる", "common.button.back": "戻る", "common.button.buy": "購入する", @@ -123,16 +125,17 @@ "common.button.setup": "設定する", "common.button.share": "共有する", "common.button.show": "表示する", + "common.button.showLess": "表示内容を減らす", + "common.button.showMore": "表示内容を増やす", "common.button.sign": "署名する", "common.button.skip": "スキップする", "common.button.swap": "スワップする", - "common.button.tryAgain": "もう一度やり直してください", + "common.button.tryAgain": "もう一度お試しください", "common.button.understand": "わかりました", "common.button.view": "表示する", "common.button.yes": "はい", "common.card.error.description": "問題が発生しました", "common.card.error.title": "申し訳ございません。問題が発生しました。", - "common.endAdornment": "および", "common.error.general": "問題が発生しました。", "common.input.password.confirm": "パスワードを確認してください", "common.input.password.error.mismatch": "パスワードが一致しません", @@ -176,7 +179,7 @@ "currency.vnd": "ベトナム ドン", "dapp.request.approve.action": "承認する", "dapp.request.approve.fallbackTitle": "このサイトがトークンにアクセスすることを承認する", - "dapp.request.approve.helptext": "このサイトで、このトークンにアクセスしてトランザクションに使用することを許可します。このサイトを信頼できるかどうか確認してください。", + "dapp.request.approve.helptext": "このサイトで、このトークンにアクセスしてトランザクションに使用することを許可します。このサイトを信頼できることを確実にしてください。", "dapp.request.approve.label": "ウォレット", "dapp.request.approve.title": "{{tokenSymbol}} へのアクセスを承認する", "dapp.request.base.title": "トランザクション リクエスト", @@ -188,12 +191,12 @@ "dapp.request.permit2.header": "Permit2 に署名する", "dapp.request.reject.action": "すべて拒否", "dapp.request.reject.info": "<highlight>{{totalRequestCount}}</highlight> 件のトランザクション リクエストがあります", - "dapp.request.signature.containsUnrenderableCharacters": "このメッセージにはレンダリングできない文字が含まれています。このサイトを信頼できるかどうか確認してください。", + "dapp.request.signature.containsUnrenderableCharacters": "このメッセージにはレンダリングできない文字が含まれています。このサイトを信頼できることを確実にしてください。", "dapp.request.signature.error.712-spec-compliance": "SignTypedDataRequestContent は、EIP-712 仕様に準拠していない署名用データを受信しました。", "dapp.request.signature.header": "署名リクエスト", "dapp.request.signature.toggleDataView.raw": "生データを表示する", "dapp.request.signature.toggleDataView.readable": "元のデータを表示する", - "dapp.request.warning.notActive.message": "正しいものであることを確認してください", + "dapp.request.warning.notActive.message": "正しいものであることを確実にしてください", "dapp.request.warning.notActive.title": "これはお客様のアクティブなウォレットではありません", "errors.crash.message": "何かがクラッシュしました。", "errors.crash.restart": "アプリを再起動する", @@ -202,7 +205,7 @@ "explore.search.action.viewEtherscan": "{{blockExplorerName}} で表示する", "explore.search.empty.full": "<highlight>\"{{searchQuery}}\"</highlight> の結果が見つかりませんでした", "explore.search.error": "検索結果を読み込めませんでした", - "explore.search.label.ownedBy": "{{ownerAddress}} が所有しています", + "explore.search.label.ownedBy": "所有者: {{ownerAddress}}", "explore.search.placeholder": "トークンとウォレットを検索する", "explore.search.section.nft": "NFT コレクション", "explore.search.section.popularNFT": "人気の NFT コレクション", @@ -224,12 +227,12 @@ "explore.tokens.sort.label.priceDecrease": "価格の低下", "explore.tokens.sort.label.priceIncrease": "価格の上昇", "explore.tokens.sort.label.totalValueLocked": "TVL", - "explore.tokens.sort.label.volume": "ボリューム", + "explore.tokens.sort.label.volume": "取引量", "explore.tokens.sort.option.marketCap": "時価総額", "explore.tokens.sort.option.priceDecrease": "価格の低下 (24 時間)", "explore.tokens.sort.option.priceIncrease": "価格の上昇 (24 時間)", "explore.tokens.sort.option.totalValueLocked": "Uniswap TVL", - "explore.tokens.sort.option.volume": "Uniswap ボリューム (24 時間)", + "explore.tokens.sort.option.volume": "Uniswap 取引量 (24 時間)", "explore.tokens.top.title": "トップ トークン", "explore.wallets.favorite.action.add": "お気に入りのウォレット", "explore.wallets.favorite.action.edit": "お気に入りを編集する", @@ -242,12 +245,10 @@ "extension.connection.popupWithButton": "お客様のウォレットはこのサイトに接続されていません。", "extension.connection.titleConnected": "接続しました", "extension.connection.titleNotConnected": "接続されていません", - "extension.feedback.description": "機能のリクエスト、バグの報告など、改善できる点があればお知らせください。", - "extension.feedback.title": "ご意見をお聞かせください", "extension.lock.button.forgot": "パスワードを忘れましたか?", "extension.lock.button.reset": "ウォレットをリセット", "extension.lock.button.submit": "ロックを解除する", - "extension.lock.password.error": "パスワードが間違っています。もう一度やり直してください", + "extension.lock.password.error": "パスワードが間違っています。もう一度お試しください", "extension.lock.password.reset.initial.description": "Uniswap は、お客様のパスワード復元をサポートできません。12 語のリカバリフレーズを再入力してウォレットをリセットする必要があります。", "extension.lock.password.reset.initial.help": "リカバリフレーズはどこにありますか?", "extension.lock.password.reset.initial.title": "パスワードを忘れましたか", @@ -276,11 +277,12 @@ "fiatOnRamp.error.unsupported": "サポート対象外の地域です", "fiatOnRamp.error.usd": "購入に使用できるのは、米国ドルのみです", "fiatOnRamp.quote.advice": "トランザクションに関連する手数料を確認するには、プロバイダーのポータルに進んでください。", + "fiatOnRamp.quote.type.list": "{{optionsList}}、その他のオプション", "fiatOnRamp.quote.type.other": "その他のオプション", "fiatOnRamp.quote.type.recent": "最近使用したもの", "fiatOnRamp.region.placeholder": "国または地域で検索する", "fiatOnRamp.region.title": "お住まいの地域を選択してください", - "fiatOnRamp.summary.total": "{{cryptoAmount}} - 次に相当: {{fiatAmount}}", + "fiatOnRamp.summary.total": "{{cryptoAmount}} - 次に相当: {{fiatAmount}}", "forceUpgrade.action.confirm": "アプリを更新する", "forceUpgrade.action.recoveryPhrase": "リカバリフレーズを表示する", "forceUpgrade.description": "お使いの Uniswap ウォレットは古いバージョンで、重要なアップグレードがなされていません。アプリを更新しない場合、またはリカバリフレーズを書き留めていない場合は、アセットにアクセスできなくなります。", @@ -292,12 +294,9 @@ "home.activity.empty.title": "まだアクティビティがありません", "home.activity.error.load": "アクティビティを読み込めませんでした", "home.activity.title": "アクティビティ", - "home.banner.extension.confirm.beta": "ベータ版に参加する", - "home.banner.extension.confirm.default": "ダウンロード", - "home.banner.extension.message.beta": "ウェブブラウザーで Uniswap 拡張機能を最初に試してみましょう", - "home.banner.extension.message.default": "Chrome にダウンロードしてデスクトップからこのウォレットにアクセスしてください", - "home.banner.extension.title": "Uniswap 拡張機能が登場", "home.banner.offline": "オフライン モードです", + "home.explore.footer": "ここをタップすると、何千ものトークン、NFT などが探索できます", + "home.explore.title": "トークンを探索する", "home.extension.error": "アカウント読み込みエラー", "home.feed.empty.description": "お気に入りのウォレットでトランザクションがなされると、こちらに表示されます。", "home.feed.empty.title": "まだアクティビティがありません", @@ -308,12 +307,6 @@ "home.label.scan": "スキャンする", "home.label.send": "送金する", "home.label.swap": "スワップする", - "home.modal.getExtension.beta.step3": "3.アクセスするにはユーザー名を入力してください", - "home.modal.getExtension.beta.title": "Uniswap 拡張機能ベータ版に参加する", - "home.modal.getExtension.ga.step1": "1.Chrome デスクトップで <highlight>uniswap.org/ext</highlight> にアクセスしてください", - "home.modal.getExtension.ga.step2": "2.ブラウザーに Uniswap 拡張機能を追加する", - "home.modal.getExtension.ga.step3": "3.お使いの Uniswap モバイル アプリで QR コードをスキャンして、ウォレットをインポートしてください", - "home.modal.getExtension.ga.title": "Uniswap 拡張機能をダウンロードする", "home.nfts.title": "NFT", "home.tokens.empty.action.buy.description": "デビット カードまたは銀行口座で暗号通貨を購入します。", "home.tokens.empty.action.buy.title": "カードで暗号通貨を購入する", @@ -370,7 +363,7 @@ "notification.network.changed": "{{network}} に切り替えました", "notification.passwordChanged": "パスワードを変更しました", "notification.restore.success": "ウォレットを復元しました!", - "notification.send.network": "{{network}} で送信中です", + "notification.send.network": "{{network}} で送金中です", "notification.swap.network": "{{network}} でスワップ中です", "notification.swap.pending.swap": "スワップが保留中です", "notification.swap.pending.unwrap": "アンラップが保留中です", @@ -414,16 +407,16 @@ "onboarding.backup.manual.subtitle_one": "リカバリフレーズの {{count}} 番目の単語は何ですか?", "onboarding.backup.manual.subtitle_two": "リカバリフレーズの {{count}} 番目の単語は何ですか?", "onboarding.backup.manual.subtitle_few": "リカバリフレーズの {{count}} 番目の単語は何ですか?", - "onboarding.backup.manual.subtitle_other": "リカバリフレーズの {{count}}番目の単語は何ですか?", - "onboarding.backup.manual.title": "正しく記録できているか確認してみましょう", + "onboarding.backup.manual.subtitle_other": "リカバリフレーズの {{count}} 番目の単語は何ですか?", + "onboarding.backup.manual.title": "正しく記録できているようにしましょう", "onboarding.backup.option.cloud.description": "安全なパスワードでリカバリフレーズを暗号化してください", - "onboarding.backup.option.cloud.title": "{{cloudProviderName}} バックアップ", + "onboarding.backup.option.cloud.title": "{{cloudProviderName}}バックアップ", "onboarding.backup.option.manual.description": "リカバリフレーズを書き留めて安全な場所に保管してください", "onboarding.backup.option.manual.title": "手動バックアップ", "onboarding.backup.subtitle": "バックアップを使用すると、アプリを削除した場合やデバイスを紛失した場合にウォレットを復元できます", "onboarding.backup.title.existing": "ウォレットをバックアップする", "onboarding.backup.title.new": "バックアップ方法を選択してください", - "onboarding.backup.view.disclaimer": "リカバリーフレーズを紛失した場合、Uniswap Labsでは復元をお手伝いできないことを理解しています。", + "onboarding.backup.view.disclaimer": "リカバリーフレーズを紛失した場合、Uniswap Labsでは復元をサポートできないことを理解しています。", "onboarding.backup.view.subtitle.message1": "続行する前に以下をよくお読みください", "onboarding.backup.view.subtitle.message2": "ウォレットを復元するには、これら 12 個の秘密の言葉をすべて入力する必要があります。", "onboarding.backup.view.title": "リカバリフレーズを書き留めてください", @@ -440,15 +433,19 @@ "onboarding.complete.pin.description": "ピン アイコンをクリックして、Uniswap 拡張機能をツール バーに追加してください。", "onboarding.complete.pin.title": "Uniswap 拡張機能をピン留めする", "onboarding.complete.title": "準備完了です", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "モバイル アプリをダウンロードしてユーザー名を取得してください", - "onboarding.extension.getOnTheBetaWaitlist.title": "ベータ版の待機リストに登録する", "onboarding.extension.password.subtitle": "ウォレットのロックを解除してリカバリフレーズにアクセスするにはこれが必要です", "onboarding.extension.password.title.default": "パスワードを作成する", "onboarding.extension.password.title.reset": "パスワードをリセットする", - "onboarding.extension.unsupported.description": "Uniswap拡張機能は現在Chromeのみと互換性があります。", - "onboarding.extension.unsupported.title": "このブラウザはまだサポートされていません", + "onboarding.extension.unsupported.android.description": "Uniswap 拡張機能はデスクトップ版 Chrome とのみ互換性があります。", + "onboarding.extension.unsupported.android.title": "モバイル版Chromeはまだサポートされていません", + "onboarding.extension.unsupported.description": "Uniswap 拡張機能は現在 Chrome とのみ互換性があります。", + "onboarding.extension.unsupported.title": "このブラウザーはまだサポートされていません", + "onboarding.home.intro.fund.description": "暗号通貨を購入するか別のアカウントから送金してウォレットに資金を入金します。", + "onboarding.home.intro.fund.title": "最初のトークンを取得する", + "onboarding.home.intro.welcome.description": "ウォレットの設定を完了すると、数秒で交換を開始できます。", + "onboarding.home.intro.welcome.title": "Uniswapへようこそ", "onboarding.import.error.invalidWords_one": "1 個の単語が無効であるか、スペルが間違っています", - "onboarding.import.error.invalidWords_other": "{{count}} 個の単語が無効であるか、スペルが間違っています", + "onboarding.import.error.invalidWords_other": "{{count}}個の単語が無効であるか、スペルが間違っています", "onboarding.import.method.import.message": "別の暗号通貨ウォレットからリカバリフレーズを入力してください", "onboarding.import.method.import.title": "ウォレットをインポートする", "onboarding.import.method.restore.message.android": "バックアップしたウォレットを Google ドライブ アカウントに追加してください", @@ -459,26 +456,19 @@ "onboarding.import.onDeviceRecovery.subtitle": "どのウォレットにログインし直すかを選んでください。", "onboarding.import.onDeviceRecovery.title": "Uniswap へおかえりなさい", "onboarding.import.onDeviceRecovery.wallet.button": "リカバリフレーズを表示する", - "onboarding.import.onDeviceRecovery.wallet.count_one": "+1 つの他のウォレット", + "onboarding.import.onDeviceRecovery.wallet.count_one": "+1 個の他のウォレット", "onboarding.import.onDeviceRecovery.wallet.count_other": "+{{count}} 個の他のウォレット", - "onboarding.import.onDeviceRecovery.warning.caption": "他のウォレットもすべてバックアップしてあることを確認してください。復元したい場合は、リカバリフレーズまたは対応する iCloud バックアップが必要になります。", + "onboarding.import.onDeviceRecovery.warning.caption": "他のウォレットもすべてバックアップしてあるようにしてください。復元したい場合は、リカバリフレーズまたは対応する iCloud バックアップが必要になります。", "onboarding.import.onDeviceRecovery.warning.title": "よろしいですか?", "onboarding.import.title": "ウォレットの追加方法を選択してください", - "onboarding.importMnemonic.button.default": "私のリカバリフレーズの単語数は 12 個です", - "onboarding.importMnemonic.button.longPhrase": "私のリカバリフレーズはそれよりも長いです", + "onboarding.importMnemonic.button.default": "マイリカバリフレーズの単語数は 12 個です", + "onboarding.importMnemonic.button.longPhrase": "マイリカバリフレーズはそれよりも長いです", "onboarding.importMnemonic.error.invalidPhrase": "入力したフレーズは無効です", "onboarding.importMnemonic.subtitle": "12 個の単語で構成されるリカバリフレーズを入力するか、貼り付けてください", "onboarding.importMnemonic.title": "リカバリフレーズを入力してください", "onboarding.intro.button.alreadyHave": "私は既にウォレットを持っています", "onboarding.intro.mobileScan.button": "QRコードをスキャンしてインポートする", "onboarding.intro.mobileScan.title": "Uniswapアプリをお持ちですか?", - "onboarding.introBetaWaitlist.button.checkEligibility": "利用資格を確認する", - "onboarding.introBetaWaitlist.button.letsGo": "それでは、始めましょう", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "ベータ版の利用資格があるかどうかを確認するには、以下に <highlight>uni.eth</highlight> ユーザー名を入力してください。", - "onboarding.introBetaWaitlist.eligible.tagline": "ベータ版へようこそ。お客様は Uniswap 拡張機能を試す最初の人の 1 人です。", - "onboarding.introBetaWaitlist.eligible.title": "順番待ちリストの順番が来ました。", - "onboarding.introBetaWaitlist.ineligibleExplanation": "お客様はまだ順番待ちリストに記載されています。ご利用が可能になりましたら、Uniswap モバイル アプリで通知いたします。", - "onboarding.introBetaWaitlist.unitagPlaceholder": "ユーザー名", "onboarding.landing.button.add": "既存のウォレットを追加する", "onboarding.landing.button.create": "ウォレットを作成する", "onboarding.notification.permission.message": "通知を受け取るには、デバイスの設定で Uniswap ウォレットの通知をオンにしてください。", @@ -500,7 +490,7 @@ "onboarding.resetPassword.complete.title": "パスワードのリセット", "onboarding.scan.button": "Uniswap アプリでスキャンする", "onboarding.scan.error": "申し訳ございません。現在 QR コードを読み込むことができません。別のオンボーディング方法を試してください。", - "onboarding.scan.otp.error": "入力したコードが間違っているか、送信内容にエラーがありました。もう一度やり直してください。", + "onboarding.scan.otp.error": "入力したコードが間違っているか、送信内容にエラーがありました。もう一度お試しください。", "onboarding.scan.otp.failed": "失敗した試行回数: {{number}}", "onboarding.scan.otp.subtitle": "Uniswap モバイル アプリに届いている 6 文字のコードを確認してください", "onboarding.scan.otp.title": "ワンタイム コードを入力してください", @@ -510,7 +500,7 @@ "onboarding.security.alert.biometrics.message.android": "生体認証を使用するには、まず設定でセットアップしてください", "onboarding.security.alert.biometrics.message.ios": "{{biometricsMethod}} を使用するには、システムの設定でアクセスを許可してください", "onboarding.security.alert.biometrics.title.android": "生体認証が無効になっています", - "onboarding.security.alert.biometrics.title.ios": "{{biometricsMethod}} が無効になっています", + "onboarding.security.alert.biometrics.title.ios": "{{biometricsMethod}}が無効になっています", "onboarding.security.button.confirm.android": "生体認証を有効にする", "onboarding.security.button.confirm.ios": "{{biometricsMethod}} を有効にする", "onboarding.security.button.setup": "設定する", @@ -520,25 +510,21 @@ "onboarding.selectWallets.error": "アドレスを読み込めませんでした", "onboarding.selectWallets.title.default": "インポートするウォレットを選択してください", "onboarding.selectWallets.title.error": "ウォレットのインポート エラー", - "onboarding.termsOfService": "続行することによって、<highlightTerms>利用規約</highlightTerms>に同意すると共に、<highlightPrivacy>プライバシー ポリシー</highlightPrivacy>に同意したことになります", + "onboarding.termsOfService": "続行することによって、<highlightTerms> 利用規約</highlightTerms>に同意すると共に、<highlightPrivacy> プライバシー ポリシー</highlightPrivacy>に同意したことになります", "onboarding.tooltip.recoveryPhrase.trigger": "リカバリフレーズとは", "onboarding.wallet.continue": "安全に保管しましょう", "onboarding.wallet.defaultName": "ウォレット {{number}}", "onboarding.wallet.description.full": "これは、トークン、NFT、およびすべてのトランザクションのための個人的なスペースです。資金を安全に保つために設定を完了してください。", "onboarding.wallet.title": "新しいウォレットへようこそ", - "qrScanner.button.connections_one": "1 つのアプリが接続されています", + "qrScanner.button.connections_one": "1 個のアプリが接続されています", "qrScanner.button.connections_other": "{{count}} 個のアプリが接続されています", "qrScanner.error.camera.message": "コードをスキャンするには、システム設定でカメラへのアクセスを許可してください", "qrScanner.error.camera.title": "カメラが無効になっています", "qrScanner.error.none": "QR コードが見つかりませんでした", - "qrScanner.recipient.action.scan": "QR コードをスキャンする", + "qrScanner.recipient.action.scan": "QRコードをスキャン", "qrScanner.recipient.action.show": "QR コードを表示する", - "qrScanner.recipient.error.message": "有効なイーサリアム アドレス QR コードをスキャンしていることを確認してから再試行してください。", + "qrScanner.recipient.error.message": "有効なイーサリアム アドレス QR コードをスキャンしているようにしてから再試行してください。", "qrScanner.recipient.error.title": "無効な QR コードです", - "qrScanner.recipient.input.placeholder": "ENS またはアドレスを検索する", - "qrScanner.recipient.label.send": "送金する", - "qrScanner.recipient.results.empty": "結果が見つかりませんでした", - "qrScanner.recipient.results.error": "入力したアドレスは存在しないか、スペルが間違っています。", "qrScanner.request.message.unavailable": "メッセージが見つかりませんでした。", "qrScanner.request.method.default": "{{dappNameOrUrl}} からのリクエスト", "qrScanner.request.method.signature": "{{dappNameOrUrl}} からの署名リクエスト", @@ -547,7 +533,7 @@ "qrScanner.request.withoutAmount": "{{dappName}} にお客様の {{currencySymbol}} の使用を許可しますか?", "qrScanner.status.connecting": "接続中...", "qrScanner.status.loading": "読み込み中...", - "qrScanner.title": "QR コードをスキャンする", + "qrScanner.title": "QRコードをスキャン", "qrScanner.wallet.title": "Ethereum、Polygon、Arbitrum、Optimism、Base、ZKsync、Zora、Avalanche、Celo、Blast、BNB Chain でトークンと NFT を受け取ることができます。", "scantastic.code.expired": "期限切れ", "scantastic.code.subtitle": "このコードを Uniswap 拡張機能に入力してください。リカバリフレーズは安全に暗号化したうえで転送されます。", @@ -562,7 +548,7 @@ "scantastic.confirmation.title": "ウォレットをインポートしようとしていますか?", "scantastic.confirmation.warning": "Uniswap を偽装したサイトやアプリに注意してください。そうしないと、ウォレットが危険にさらされる可能性があります。", "scantastic.error.encryption": "シードフレーズの作成に失敗しました。", - "scantastic.error.noCode": "OTP が受信されませんでした。もう一度やり直してください。", + "scantastic.error.noCode": "OTP が受信されませんでした。もう一度お試しください。", "scantastic.error.timeout.message": "ウォレットの同期を続けるには、Uniswap 拡張機能の QR コードをもう一度スキャンしてください。", "scantastic.error.timeout.title": "接続がタイムアウトしました", "scantastic.modal.ipMismatch.description": "この QR コードをスキャンするには、携帯電話がコンピューターと同じ WiFi ネットワークに接続されている必要があります。", @@ -572,8 +558,12 @@ "send.gas.error.title": "該当なし", "send.gas.networkCost.title": "ネットワーク代", "send.input.token.balance.title": "残高: {{balance}} {{symbol}}", + "send.recipient.header": "受信者を選択", + "send.recipient.input.placeholder": "ENS またはアドレスを検索する", "send.recipient.previous_one": "以前の送金回数は、1 回でした", "send.recipient.previous_other": "以前の送金回数は、{{count}} 回でした", + "send.recipient.results.empty": "結果が見つかりませんでした", + "send.recipient.results.error": "入力したアドレスは存在しないか、スペルが間違っています。", "send.recipient.section.favorite": "お気に入りのウォレット", "send.recipient.section.recent": "最近の", "send.recipient.section.search": "検索結果", @@ -585,14 +575,14 @@ "send.recipientSelect.search.empty.title": "ウォレットが保存されていません", "send.review.modal.title": "送金しています", "send.review.summary.button.title": "送金を確認", - "send.review.summary.sending": "送金中です", + "send.review.summary.sending": "送金中", "send.search.empty.subtitle": "入力したアドレスは存在しないか、スペルが間違っています。", "send.search.empty.title": "結果が見つかりませんでした", "send.search.placeholder": "ENS またはアドレスを検索する", "send.status.fail.description": "失敗した送金に対してもネットワーク代が課金されることに注意してください。", "send.status.failed.title": "送金に失敗しました", "send.status.inProgress.description": "トランザクションが完了したらお知らせします。", - "send.status.inProgress.title": "送金中です", + "send.status.inProgress.title": "送金中", "send.status.success.description": "{{recipient}} に {{currencyAmount}}{{tokenName}}{{fiatValue}} を送金しました。", "send.status.success.title": "送金に成功しました。", "send.title": "送金する", @@ -605,15 +595,18 @@ "send.warning.insufficientFunds.message": "送金額を入力したため、{{currencySymbol}} 残高が減少しました", "send.warning.insufficientFunds.title": "{{currencySymbol}} が十分ではありません", "send.warning.insufficientGas.message.withNetwork": "送金する <highlight>{{currencySymbol}} が {{networkName}}</highlight> に十分にありません", - "send.warning.insufficientGas.message.withoutNetwork": "送金する <highlight>{{currencySymbol}}</highlight> が足りません", + "send.warning.insufficientGas.message.withoutNetwork": "送金する <highlight>{{currencySymbol}}</highlight> が十分ではありません", "send.warning.modal.button.cta.blocking": "わかりました", "send.warning.modal.button.cta.cancel": "キャンセルする", "send.warning.modal.button.cta.confirm": "確認する", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "ユーザー名", + "send.warning.newAddress.details.walletAddress": "ウォレットアドレス", "send.warning.newAddress.message": "このアドレスとはこれまでにトランザクションをしたことがありません。続行する前に、アドレスが正しいことを確認してください。", "send.warning.newAddress.title": "新しいアドレス", "send.warning.restore": "送金するにはウォレットを復元してください", "send.warning.self.message": "現在のウォレットに資金を送金しようとしています。このアドレスに暗号通貨を送信すると、不要なネットワーク代が発生します。", - "send.warning.self.title": "これはあなたの現在のウォレットです", + "send.warning.self.title": "これはお客様の現在のウォレットです", "send.warning.smartContract.message": "お客様は、特別な種類のアドレス (スマートコントラクト) にトークンを送金しようとしています。送金先のアドレスであることを再確認してください。アドレスが間違っていると、トークンが永久に失われる可能性があります。", "send.warning.smartContract.title": "これはウォレット アドレスですか?", "send.warning.viewOnly.message": "アセットを送金するには、リカバリフレーズを使ってこのウォレットをインポートする必要があります。", @@ -623,14 +616,14 @@ "setting.recoveryPhrase.remove": "リカバリフレーズを削除する", "setting.recoveryPhrase.remove.confirm.subtitle": "私は、ウォレットの復元に失敗した場合でも Uniswap Labs がウォレットの復元をサポートできないことを理解しています。", "setting.recoveryPhrase.remove.confirm.title": "リカバリフレーズを保存しました", - "setting.recoveryPhrase.remove.initial.subtitle": "リカバリフレーズが保存済みであることを確認してください。そうしないとウォレットにアクセスできなくなります", + "setting.recoveryPhrase.remove.initial.subtitle": "リカバリフレーズが保存済みであるようにしてください。そうしないとウォレットにアクセスできなくなります", "setting.recoveryPhrase.remove.initial.title": "続行する前に", - "setting.recoveryPhrase.remove.password.error": "パスワードが間違っています。もう一度やり直してください", + "setting.recoveryPhrase.remove.password.error": "パスワードが間違っています。もう一度お試しください", "setting.recoveryPhrase.remove.subtitle": "確認のためパスワードを入力してください", "setting.recoveryPhrase.remove.title": "リカバリフレーズを削除中です", "setting.recoveryPhrase.view.warning.message1": "お客様のリカバリフレーズを知っている人はだれでもお客様のウォレットと資金にアクセスできてしまいます", "setting.recoveryPhrase.view.warning.message2": "これはだれもいない場所で表示してください", - "setting.recoveryPhrase.view.warning.message3": "誰とも共有しないでください", + "setting.recoveryPhrase.view.warning.message3": "だれとも共有しないでください", "setting.recoveryPhrase.view.warning.message4": "ウェブサイトやアプリケーションに入力しないでください", "setting.recoveryPhrase.view.warning.title": "続行する前に", "setting.recoveryPhrase.warning.screenshot.message": "お客様の写真にアクセスできる人はだれでもお客様のウォレットにアクセスできてしまいます。代わりにご自身の単語を書き留めておくことをお勧めします。", @@ -640,9 +633,9 @@ "settings.action.feedback": "フィードバックを共有する", "settings.action.help": "サポートを求める", "settings.action.lock": "ウォレットをロックする", - "settings.action.privacy": "プライバシー ポリシー", + "settings.action.privacy": "プライバシーポリシー", "settings.action.terms": "利用規約", - "settings.footer": "Uniswap チームが🦄 \nていねいに作成しました", + "settings.footer": "Uniswap チーム濾が \nていねいに作成しました", "settings.screen.appearance.title": "アピアランス", "settings.section.about": "説明", "settings.section.preferences": "設定", @@ -650,7 +643,7 @@ "settings.section.support": "サポート", "settings.section.wallet.button.viewAll": "すべて表示する", "settings.section.wallet.button.viewLess": "表示内容を減らす", - "settings.section.wallet.label.viewOnly": "表示のみ", + "settings.section.wallet.label.viewOnly": "表示専用", "settings.section.wallet.title": "ウォレットの設定", "settings.setting.appearance.option.dark.subtitle": "常にダーク モードを使用する", "settings.setting.appearance.option.dark.title": "ダーク モード", @@ -664,7 +657,7 @@ "settings.setting.backup.delete.confirm.message": "これらのウォレットは同じリカバリフレーズを共有しているため、続行すると、これらのウォレットのバックアップが削除されます", "settings.setting.backup.delete.confirm.title": "よろしいですか?", "settings.setting.backup.delete.warning": "{{cloudProviderName}} バックアップを削除すると、ウォレットを復元できるのが、リカバリフレーズの手動バックアップによる方法のみとなります。お客様がリカバリフレーズを紛失した場合でも、Uniswap Labs はお客様のアセットを復元できません。", - "settings.setting.backup.error.message.full": "{{cloudProviderName}} にリカバリフレーズをバックアップできません。{{cloudProviderName}} が有効で、かつ利用可能なストレージ容量があることを確認して、もう一度やり直してください。", + "settings.setting.backup.error.message.full": "{{cloudProviderName}} にリカバリフレーズをバックアップできません。{{cloudProviderName}} が有効で、かつ利用可能なストレージ容量があるようにして、もう一度お試しください。", "settings.setting.backup.error.message.short": "バックアップを削除できません", "settings.setting.backup.error.title": "{{cloudProviderName}} エラー", "settings.setting.backup.modal.description": "リカバリフレーズを {{cloudProviderName}} にまだバックアップしていません。バックアップすると、どのデバイスからでも {{cloudProviderName}} にログインするだけでウォレットを復元できます。", @@ -675,7 +668,7 @@ "settings.setting.backup.password.placeholder.confirm": "パスワードを確認してください", "settings.setting.backup.password.placeholder.create": "パスワードを作成する", "settings.setting.backup.password.strong": "これは強いパスワードです", - "settings.setting.backup.password.weak": "これは弱いパスワードです", + "settings.setting.backup.password.weak": " これは弱いパスワードです", "settings.setting.backup.recoveryPhrase.label": "リカバリフレーズ", "settings.setting.backup.selected": "{{cloudProviderName}} バックアップ", "settings.setting.backup.status.action.delete": "バックアップを削除する", @@ -695,7 +688,7 @@ "settings.setting.biometrics.off.title.ios": "{{biometricsMethod}} がオフになっています", "settings.setting.biometrics.title": "生体認証", "settings.setting.biometrics.transactions.subtitle.android": "トランザクションを行うには生体認証が必要です", - "settings.setting.biometrics.transactions.subtitle.ios": "トランザクションには {{biometricsMethod}} が必要です", + "settings.setting.biometrics.transactions.subtitle.ios": "トランザクションを行うには {{biometricsMethod}} が必要です", "settings.setting.biometrics.transactions.title": "トランザクション", "settings.setting.biometrics.unavailable.message.android": "お使いのデバイスでは生体認証が設定されていません。生体認証を使用するには、まず設定でセットアップしてください。", "settings.setting.biometrics.unavailable.message.ios": "お使いのデバイスでは {{biometricsMethod}} が設定されていません。{{biometricsMethod}} を使用するには、まず設定でセットアップしてください。", @@ -705,11 +698,10 @@ "settings.setting.biometrics.warning.message.ios": "{{biometricsMethod}} をオンにしておかないと、デバイスにアクセスできる人はだれでも Uniswap ウォレットを開いてトランザクションができてしまいます。", "settings.setting.biometrics.warning.title": "よろしいですか?", "settings.setting.currency.title": "現地通貨", - "settings.setting.giveFeedback.title": "フィードバックを共有する", "settings.setting.helpCenter.title": "ヘルプ センター", "settings.setting.language.button.navigate": "設定に移動する", "settings.setting.language.description.extension": "Uniswap はデフォルトでシステムの言語設定を使用します。優先言語を変更するには、システム設定に移動してください。", - "settings.setting.language.description.mobile": "Uniswap はデフォルトで、お使いのデバイスの言語設定になっています。優先言語を変更するには、デバイス設定の「Uniswap」に移動し、「言語」をタップします。", + "settings.setting.language.description.mobile": "Uniswap はデフォルトでデバイスの言語設定を使用します。優先言語を変更するには、デバイス設定の「Uniswap」に移動し、「言語」をタップします。", "settings.setting.language.title": "言語", "settings.setting.password.title": "パスワードを変更する", "settings.setting.privacy.analytics.description": "Uniswap Labs 製品全体でのエクスペリエンスを向上させるために、匿名での使用状況データを使用しています。無効にすると、エラーと重要な使用状況のみが追跡されます。", @@ -738,8 +730,6 @@ "swap.button.unwrap": "アンラップする", "swap.button.view": "トランザクションを表示する", "swap.button.wrap": "ラップする", - "swap.details.action.less": "表示内容を減らす", - "swap.details.action.more": "表示内容を増やす", "swap.details.feeOnTransfer": "{{tokenSymbol}} 手数料", "swap.details.newQuote.input": "新規の入金", "swap.details.newQuote.output": "新規の出金", @@ -752,7 +742,7 @@ "swap.form.warning.output.fotFees": "{{fotCurrencySymbol}} のトークン手数料のため、スワップ金額は入力フィールドを使用してのみ入力できます", "swap.form.warning.output.fotFees.fallback": "トークン手数料のため、スワップ金額は入力フィールドを使用してのみ入力できます", "swap.form.warning.restore": "スワップするにはウォレットを復元してください", - "swap.header.viewOnly": "表示のみ", + "swap.header.viewOnly": "表示専用", "swap.hold.swap": "スワップするには長押しします", "swap.hold.tip": "ヒント: 長押しすると即時にスワップできます", "swap.hold.unwrap": "アンラップするには長押しします", @@ -780,42 +770,43 @@ "swap.settings.slippage.warning.message": "スリッページが必要以上に高くなる可能性があります", "swap.settings.slippage.warning.min": "0 より大きい値を入力してください", "swap.settings.title": "スワップの設定", - "swap.slippage.settings.title": "スリッページ設定", + "swap.slippage.settings.title": "スリッページの設定", "swap.warning.expectedFailure": "このトランザクションは失敗することが予想されます", "swap.warning.feeOnTransfer.message": "一部のトークンは売買時に手数料がかかります。手数料はトークン発行者が設定します。Uniswap はこれらの手数料をまったく受け取りません。", "swap.warning.feeOnTransfer.title": "追加手数料がかかる理由", - "swap.warning.insufficientBalance.button": "{{currencySymbol}} が十分ではない", + "swap.warning.insufficientBalance.button": "{{currencySymbol}} が十分ではありません", "swap.warning.insufficientBalance.title": "お客様の {{currencySymbol}} が十分でありません", - "swap.warning.insufficientGas.button": "{{currencySymbol}} が十分ではない", - "swap.warning.insufficientGas.message.withNetwork": "<highlight>{{currencySymbol}} は {{networkName}}</highlight> スワップを行うのに十分ではありません", - "swap.warning.insufficientGas.message.withoutNetwork": "<highlight>{{currencySymbol}}</highlight> はスワップを行うのに十分ではありません", + "swap.warning.insufficientGas.button": "{{currencySymbol}} が十分ではありません", + "swap.warning.insufficientGas.button.buy": "{{ tokenSymbol }} を購入する", + "swap.warning.insufficientGas.message.withNetwork": "スワップする <highlight>{{currencySymbol}} が {{networkName}}</highlight> に十分にありません", + "swap.warning.insufficientGas.message.withoutNetwork": "スワップする <highlight>{{currencySymbol}}</highlight> が十分ではありません", "swap.warning.insufficientGas.title": "ネットワーク代をカバーするのに十分な {{currencySymbol}} がありません", "swap.warning.lowLiquidity.message": "現在、これらのトークン間にはスワップを実行するのに十分な流動性がありません。後でもう一度試すか、別のトークンを選択してください。", - "swap.warning.lowLiquidity.title": "十分な流動性がない", - "swap.warning.networkFee.allow": "許可 {{ inputTokenSymbol }} (1回)", - "swap.warning.networkFee.highRelativeToValue": "ネットワーク代が合計取引額の 10% を超えています。", + "swap.warning.lowLiquidity.title": "十分な流動性がありません", + "swap.warning.networkFee.allow": "{{ inputTokenSymbol }} を許可する (1 回)", + "swap.warning.networkFee.highRelativeToValue": "ネットワーク代がトランザクション総額の 10% を超えています。", "swap.warning.networkFee.message": "これは、ブロックチェーン上でトランザクションを処理するためのコストです。Uniswap はこれらの手数料をまったく受け取りません。", "swap.warning.networkFee.message.uniswapX": "これは、ブロックチェーン上でトランザクションを処理するためのコストです。Uniswap はこれらの手数料をまったく受け取りません。<gradient>UniswapX</gradient> は流動性ソースを集約し、より良い価格とガス代無料のスワップを実現します。", - "swap.warning.networkFee.wrap": "ETHをラップする", - "swap.warning.offline.message": "インターネット接続が失われたか、ネットワークがダウンしている可能性があります。インターネット接続を確認して、もう一度やり直してください。", + "swap.warning.networkFee.wrap": "ETH をラップする", + "swap.warning.offline.message": "インターネット接続が失われたか、ネットワークがダウンしている可能性があります。インターネット接続を確認して、もう一度お試しください。", "swap.warning.offline.title": "オフラインです", "swap.warning.priceImpact.message": "現在利用可能な {{outputCurrencySymbol}} 流動性の量により、スワップする {{inputCurrencySymbol}} を増やせば、受け取る {{outputCurrencySymbol}} 量が少なくなります。", "swap.warning.priceImpact.title": "価格への大きな影響 ({{priceImpactValue}})", - "swap.warning.queuedOrder.appClosed": "アプリを閉じたため、取引は送信されませんでした。", - "swap.warning.queuedOrder.approvalFailed": "トークンの承認に失敗したため、取引は送信されませんでした。", - "swap.warning.queuedOrder.stale": "アプリを閉じたか、承認に時間がかかりすぎたため、取引は送信されませんでした。", - "swap.warning.queuedOrder.submissionFailed": "取引の送信中に問題が発生しました。", + "swap.warning.queuedOrder.appClosed": "アプリを閉じたため、トランザクションは送信されませんでした。", + "swap.warning.queuedOrder.approvalFailed": "トークンの承認に失敗したため、トランザクションは送信されませんでした。", + "swap.warning.queuedOrder.stale": "アプリを閉じたか、承認に時間がかかりすぎたため、トランザクションは送信されませんでした。", + "swap.warning.queuedOrder.submissionFailed": "トランザクションの送信中に問題が発生しました。", "swap.warning.queuedOrder.title": "スワップはキャンセルされました", - "swap.warning.queuedOrder.wrap.message": "ETHはWETHとしてラップされたままになります。", - "swap.warning.queuedOrder.wrapFailed": "ラップ取引が失敗したため、取引は送信されませんでした。", - "swap.warning.rateLimit.message": "数分後にもう一度やり直してください。", + "swap.warning.queuedOrder.wrap.message": "ETH は WETH としてラップされたままになります。", + "swap.warning.queuedOrder.wrapFailed": "ラップ トランザクションが失敗したため、トランザクションは送信されませんでした。", + "swap.warning.rateLimit.message": "数分後にもう一度お試しください。", "swap.warning.rateLimit.title": "レート制限を超えました", - "swap.warning.router.message": "接続が解除されたか、ネットワークがダウンしている可能性があります。問題が解決しない場合は、後でもう一度やり直してください。", + "swap.warning.router.message": "接続が失われたか、ネットワークがダウンしている可能性があります。問題が解決しない場合は、後でもう一度お試しください。", "swap.warning.router.title": "この取引は現在完了できません", "swap.warning.uniswapFee.message.default": "Uniswap で最高の体験を保証するために手数料が適用されます。このスワップには手数料はかかりません。", "swap.warning.uniswapFee.message.included": "手数料は Uniswap で最高の体験を保証するために適用され、この見積もりに既に組み込まれています。", "swap.warning.uniswapFee.title": "スワップ手数料", - "swap.warning.viewOnly.message": "トークンをスワップするには、リカバリフレーズを用いてこのウォレットをインポートする必要があります。", + "swap.warning.viewOnly.message": "トークンをスワップするには、リカバリフレーズを使ってこのウォレットをインポートする必要があります。", "token.balances.main": "お客様の残高", "token.balances.other": "他のネットワーク上の残高", "token.balances.viewOnly": "{{ownerAddress}} の残高", @@ -841,11 +832,13 @@ "token.stats.marketCap": "時価総額", "token.stats.priceHighYear": "52 週での最高値", "token.stats.priceLowYear": "52 週での最安値", - "token.stats.section.about": "{{token}} の説明", + "token.stats.section.about": "{{token}}} の説明", "token.stats.title": "統計", - "token.stats.translation.original": "原文を表示する", + "token.stats.translation.original": "オリジナルを表示する", "token.stats.translation.translate": "{{language}} に翻訳する", "token.stats.volume": "24 時間の取引量", + "token.zeroNativeBalance.description": "{{ tokenSymbol }} を取得するには、先にネットワーク代を支払うための {{ nativeTokenSymbol }} が必要です。まず {{ nativeTokenSymbol }} でウォレットに資金を入金してください。", + "token.zeroNativeBalance.title": "必要なのは {{ nativeTokenName }}", "tokens.action.hide": "トークンを非表示にする", "tokens.action.unhide": "トークンを表示する", "tokens.hidden.label": "非表示 ({{numHidden}})", @@ -854,7 +847,7 @@ "tokens.nfts.collection.label.items": "アイテム", "tokens.nfts.collection.label.owners": "所有者", "tokens.nfts.collection.label.priceFloor": "底値", - "tokens.nfts.collection.label.swapVolume": "ボリューム", + "tokens.nfts.collection.label.swapVolume": "取引量", "tokens.nfts.details.error.load.title": "NFT の詳細を読み込めませんでした", "tokens.nfts.details.network": "ネットワーク", "tokens.nfts.details.owner": "所有者:", @@ -892,17 +885,18 @@ "transaction.action.cancel.description": "このトランザクションがネットワークによって処理される前にキャンセルすると、元のネットワーク代ではなく新しいネットワーク代を支払うことになります。", "transaction.action.cancel.title": "このトランザクションをキャンセルしますか?", "transaction.action.copy": "トランザクション ID をコピーする", - "transaction.action.copyMoonPay": "MoonPay トランザクション ID をコピーする", + "transaction.action.copyProvider": "{{providerName}} トランザクション ID をコピーする", "transaction.action.view": "{{tokenSymbol}} を表示する", "transaction.action.viewEtherscan": "{{blockExplorerName}} で表示する", - "transaction.action.viewMoonPay": "MoonPay で表示する", "transaction.amount.unlimited": "無制限", "transaction.currency.unknown": "不明なトークン", "transaction.date": "送信日: {{date}}", "transaction.details.dappName": "アプリ", "transaction.details.from": "から", "transaction.details.networkFee": "ネットワーク代", + "transaction.details.swapRate": "レート", "transaction.details.transactionId": "トランザクション ID", + "transaction.details.uniswapFee": "手数料({{ feePercent }}%)", "transaction.network.all": "すべてのネットワーク", "transaction.networkCost.label": "ネットワーク代", "transaction.notification.error.cancel": "トランザクションをキャンセルできません", @@ -910,7 +904,7 @@ "transaction.priceImpact.label": "価格への影響", "transaction.status.approve.canceled": "承認をキャンセルしました", "transaction.status.approve.canceling": "承認をキャンセル中です", - "transaction.status.approve.failed": "承認に失敗しました", + "transaction.status.approve.failed": " 承認に失敗しました", "transaction.status.approve.pending": "承認中です", "transaction.status.approve.success": "承認しました", "transaction.status.approve.successDapp": "{{externalDappName}} で承認しました", @@ -925,7 +919,7 @@ "transaction.status.confirm.failed": "確認に失敗しました", "transaction.status.confirm.pending": "トランザクションが実行中です", "transaction.status.confirm.success": "トランザクションを確認しました", - "transaction.status.confirm.successDapp": "{{externalDappName}} でトランザクションを確認しました", + "transaction.status.confirm.successDapp": "トランザクションを {{externalDappName}} で確認しました", "transaction.status.mint.canceled": "発行をキャンセルしました", "transaction.status.mint.canceling": "発行をキャンセル中です", "transaction.status.mint.failed": "発行に失敗しました", @@ -965,7 +959,7 @@ "transaction.status.send.canceled": "送金をキャンセルしました", "transaction.status.send.canceling": "送金をキャンセル中です", "transaction.status.send.failed": "送金に失敗しました", - "transaction.status.send.pending": "送金中です", + "transaction.status.send.pending": "送金中", "transaction.status.send.success": "送金しました", "transaction.status.send.successDapp": "{{externalDappName}} で送金しました", "transaction.status.swap.canceled": "スワップをキャンセルしました", @@ -988,8 +982,8 @@ "transaction.status.wrap.pending": "ラップ中です", "transaction.status.wrap.success": "ラップしました", "transaction.status.wrap.successDapp": "{{externalDappName}} でラップしました", - "transaction.summary.received": "{{tokenAmountWithSymbol}} 受取先:{{recipientAddress}}", - "transaction.summary.sent": "{{tokenAmountWithSymbol}} 送金元:{{senderAddress}}", + "transaction.summary.received": "{{tokenAmountWithSymbol}} 受取先{{recipientAddress}}", + "transaction.summary.sent": "{{tokenAmountWithSymbol}} 送金元{{senderAddress}}", "transaction.warning.insufficientGas.modal.message": "このトランザクションのネットワーク代をカバーするには、{{tokenAmount}} {{tokenSymbol}} (<fiatTokenAmount/>) を {{networkName}} 上で増額する必要があります。", "transaction.warning.insufficientGas.modal.title.withNetwork": "{{networkName}} で {{tokenSymbol}} が十分ではありません", "transaction.warning.insufficientGas.modal.title.withoutNetwork": "{{tokenSymbol}} が十分ではありません", @@ -1000,19 +994,19 @@ "uniswapx.label": "UniswapX", "unitags.banner.button.claim": "今すぐ請求する", "unitags.banner.subtitle": "パーソナライズされた web3 プロフィールを作成し、アドレスを友人と簡単に共有できます。", - "unitags.banner.title.compact": "<highlight>{{unitagDomain}} ユーザー名</highlight>を請求し、カスタマイズ可能なプロフィールを作成します。", + "unitags.banner.title.compact": "<highlight>{{unitagDomain}}ユーザー名</highlight>を請求し、カスタマイズ可能なプロフィールを作成します。", "unitags.banner.title.full": "{{unitagDomain}} ユーザー名を請求する", "unitags.choosePhoto.option.cameraRoll": "カメラ ロールから選択する", "unitags.choosePhoto.option.nft": "NFT を選択する", "unitags.choosePhoto.option.remove": "プロフィール写真を削除する", "unitags.claim.confirmation.customize": "プロフィールをカスタマイズする", - "unitags.claim.confirmation.description": "{{unitagAddress}} で暗号通貨の送金と受け取りを行う準備ができています。web3 プロフィールをカスタマイズして、ウォレットの構築を続けてください。", + "unitags.claim.confirmation.description": "{{unitagAddress}} は暗号通貨の送受信の準備が整いました。web3 プロフィールをカスタマイズして、ウォレットの構築を続けてください。", "unitags.claim.confirmation.success.long": "了解しました!", "unitags.claim.confirmation.success.short": "わかりました!", "unitags.claim.error.addressLimit": "このアドレスのユーザー名に対する変更回数が既に上限に達しています", - "unitags.claim.error.appCheck": "ユーザー名を請求できませんでした。明日、もう一度やり直してください。", - "unitags.claim.error.avatar": "アバターを設定できませんでした。後でもう一度やり直してください。", - "unitags.claim.error.default": "ユーザー名を請求できませんでした。後でもう一度やり直してください。", + "unitags.claim.error.appCheck": "ユーザー名を請求できませんでした。明日、もう一度お試しください。", + "unitags.claim.error.avatar": "アバターを設定できませんでした。後でもう一度お試しください。", + "unitags.claim.error.default": "ユーザー名を請求できませんでした。後でもう一度お試しください。", "unitags.claim.error.deviceLimit": "このデバイスでアクティブにできるユーザー名の数の上限に達しました", "unitags.claim.error.ens": "このユーザー名を請求するには、{{username}}.eth ENS を所有している必要があります", "unitags.claim.error.ensMismatch": "このユーザー名は現在使用できません。", @@ -1034,11 +1028,11 @@ "unitags.intro.features.profile": "カスタマイズ可能なプロフィール", "unitags.intro.subtitle": "0x アドレスに別れを告げましょう。ユーザー名は、暗号通貨の送金と受け取りを容易にするための読みやすい名前です。", "unitags.intro.title": "ユーザー名の説明", - "unitags.notification.delete.error": "ユーザー名を削除できませんでした。後でもう一度やり直してください。", + "unitags.notification.delete.error": "ユーザー名を削除できませんでした。後でもう一度お試しください。", "unitags.notification.delete.title": "ユーザー名が削除されました", - "unitags.notification.profile.error": "プロフィールを更新できませんでした。後でもう一度やり直してください。", + "unitags.notification.profile.error": "プロフィールを更新できませんでした。後でもう一度お試しください。", "unitags.notification.profile.title": "プロフィールが更新されました", - "unitags.notification.username.error": "ユーザー名を変更できませんでした。後でもう一度やり直してください。", + "unitags.notification.username.error": "ユーザー名を変更できませんでした。後でもう一度お試しください。", "unitags.notification.username.title": "ユーザー名が変更されました", "unitags.onboarding.claim.subtitle": "これは、だれからでも暗号通貨を送金できる送金先となる一意の名前です。", "unitags.onboarding.claim.title.choose": "ユーザー名を選択する", @@ -1047,7 +1041,7 @@ "unitags.onboarding.claimPeriod.link": "<highlight>請求期間</highlight>について詳しくは、こちらをご覧ください。", "unitags.onboarding.claimPeriod.title": "ENS 請求期間", "unitags.onboarding.info.description": "ユーザー名で、複雑な 0x アドレスを読みやすい名前に変換できます。{{unitagDomain}} ユーザー名を請求することで、暗号通貨の送金や受け取りが簡単になり、公開 web3 プロフィールを構築できます。", - "unitags.onboarding.info.title": "簡略化されたアドレス", + "unitags.onboarding.info.title": "略化されたアドレス", "unitags.onboarding.profile.subtitle": "お客様自身のユニコンをアップロードするか、独自のユニコンを使用してください。これは後でいつでも変更できます。", "unitags.onboarding.profile.title": "プロフィール写真を選択する", "unitags.profile.action.delete": "ユーザー名を削除する", @@ -1059,18 +1053,18 @@ "unitags.username.error.max": "ユーザー名は {{number}} 文字以下にする必要があります", "unitags.username.error.min": "ユーザー名は {{number}} 文字以上にする必要があります", "unitags.username.error.uppercase": "ユーザー名には小文字と数字のみを含めることができます", - "uwulink.error.insufficientTokens": "{{chain}} で {{tokenSymbol}} が十分でありません", - "walletConnect.dapps.connection": "<highlight>接続しました:</highlight>{{dappNameOrUrl}}", + "uwulink.error.insufficientTokens": "{{chain}} で {{tokenSymbol}} が十分ではありません", + "walletConnect.dapps.connection": "<highlight>接続先:</highlight>{{dappNameOrUrl}}", "walletConnect.dapps.empty.description": "WalletConnect 経由でコードをスキャンしてアプリに接続してください", "walletConnect.dapps.manage.empty.title": "接続されているアプリはありません", "walletConnect.dapps.manage.title": "接続を管理する", "walletConnect.error.connection.message": "Uniswap ウォレットでは、現在 {{chainNames}} をサポートしています。これらのチェーンでは \"{{dappName}}\" のみを使用してください", "walletConnect.error.connection.title": "接続エラー", - "walletConnect.error.general.message": "WalletConnect に問題が発生しました。もう一度やり直してください", + "walletConnect.error.general.message": "WalletConnect に問題が発生しました。もう一度お試しください", "walletConnect.error.general.title": "WalletConnect エラー", - "walletConnect.error.scantastic.message": "QR コードに問題がありました。もう一度やり直してください", + "walletConnect.error.scantastic.message": "QR コードに問題がありました。もう一度お試しください", "walletConnect.error.scantastic.title": "無効な QR コードです", - "walletConnect.error.unsupported.message": "再試行する前に、有効なWalletConnect、Ethereumアドレス、またはUniswap Extension QRコードをスキャンしていることを確認してください。", + "walletConnect.error.unsupported.message": "再試行する前に、有効なWalletConnect、Ethereumアドレス、またはUniswap Extension QRコードをスキャンしているようにしてください。", "walletConnect.error.unsupported.title": "無効な QR コードです", "walletConnect.error.unsupportedV1.message": "WalletConnect v1 のサポートは終了しました。接続しようとしているアプリを WalletConnect v2 にアップグレードする必要があります。", "walletConnect.error.unsupportedV1.title": "無効な QR コードです", @@ -1090,9 +1084,11 @@ "walletConnect.request.button.scrollDown": "下へスクロールして署名してください", "walletConnect.request.button.sign": "署名する", "walletConnect.request.details.label.function": "関数", - "walletConnect.request.details.label.sending": "送金中です", - "walletConnect.request.error.insufficientFunds": "このトランザクションを完了するのに十分な {{currencySymbol}} がありません。", - "walletConnect.request.error.network": "インターネットまたはネットワーク接続エラー", + "walletConnect.request.details.label.sending": "送金中", + "walletConnect.request.details.label.token": "トークン", + "walletConnect.request.details.label.tokens": "トークン", + "walletConnect.request.error.insufficientFunds": "このトランザクションを完了するのに十分な {{currencySymbol}} がありません", + "walletConnect.request.error.network": " インターネットまたはネットワーク接続エラー", "walletConnect.request.warning.general.message": "注意: このメッセージによりアセットが送金される可能性があります", "walletConnect.request.warning.message": "メッセージまたはトランザクションに署名するには、ウォレットのリカバリフレーズをインポートする必要があります。", "walletConnect.request.warning.title": "このウォレットは表示専用モードです" diff --git a/packages/uniswap/src/i18n/locales/translations/pt-PT.json b/packages/uniswap/src/i18n/locales/translations/pt-PT.json index 81340f108fa..445229fc68a 100644 --- a/packages/uniswap/src/i18n/locales/translations/pt-PT.json +++ b/packages/uniswap/src/i18n/locales/translations/pt-PT.json @@ -8,17 +8,17 @@ "account.cloud.error.backup.message": "Falha na importação de backups devido à falta de permissões, interrupção da autorização ou devido a um erro na cloud", "account.cloud.error.backup.title": "Erro ao importar backups", "account.cloud.error.password.title": "Palavra-passe inválida. Tente novamente.", - "account.cloud.error.unavailable.button.cancel": "Não agora", + "account.cloud.error.unavailable.button.cancel": "Agora não", "account.cloud.error.unavailable.button.settings": "Ir para as definições", "account.cloud.error.unavailable.message.android": "Verifique se tem sessão iniciada numa conta Google com o Google Drive ativado neste dispositivo e tente novamente.", "account.cloud.error.unavailable.message.ios": "Verifique se tem sessão iniciada num ID Apple com o iCloud Drive ativado neste dispositivo e tente novamente.", "account.cloud.error.unavailable.title.android": "Google Drive não disponível", "account.cloud.error.unavailable.title.ios": "iCloud Drive não disponível", - "account.cloud.loading.title": "A procurar backups...", + "account.cloud.loading.title": "A procurar backups…", "account.cloud.lockout.time.hours_one": "Demasiadas tentativas. Tente novamente daqui a 1 hora.", "account.cloud.lockout.time.hours_other": "Demasiadas tentativas. Tente novamente daqui a {{count}} horas.", "account.cloud.lockout.time.minutes_one": "Demasiadas tentativas. Tente novamente daqui a 1 minuto.", - "account.cloud.lockout.time.minutes_other": "Demasiadas tentativas. Tente novamente em {{count}} minutos.", + "account.cloud.lockout.time.minutes_other": "Demasiadas tentativas. Tente novamente daqui a {{count}} minutos.", "account.cloud.password.input": "Introduzir palavra-passe", "account.cloud.password.recoveryPhrase": "Em vez disso, introduzir a frase de recuperação", "account.cloud.password.subtitle": "Esta palavra-passe é necessária para recuperar o backup da frase de recuperação a partir do {{cloudProviderName}}.", @@ -27,7 +27,7 @@ "account.recoveryPhrase.education.part2": "Pode <highlight>introduzir</highlight> a sua frase de recuperação num novo dispositivo <highlight>para restaurar a sua carteira</highlight> e o respetivo conteúdo.", "account.recoveryPhrase.education.part3": "Mas, se <highlight>perder a sua frase de recuperação</highlight>, <highlight>perderá o acesso</highlight> à sua carteira.", "account.recoveryPhrase.education.part4": "Em vez de memorizar a frase de recuperação, pode <highlight>fazer backup da mesma para o {{cloudProviderName}}</highlight> e protegê-la com uma palavra-passe.", - "account.recoveryPhrase.education.part5": "Também pode fazer manualmente backup da sua frase de recuperação ao <highlight>anotá-la</highlight> e guardá-la num local seguro.", + "account.recoveryPhrase.education.part5": "Também pode fazer manualmente backup da sua frase de recuperação <highlight>anotando-a</highlight> e guardando-a num local seguro.", "account.recoveryPhrase.education.part6": "Recomendamos a utilização de <highlight>ambos os tipos de backup</highlight>, porque se perder a sua frase de recuperação, não poderá restaurar a sua carteira.", "account.recoveryPhrase.error.invalid": "Frase inválida", "account.recoveryPhrase.error.invalidWord": "Palavra inválida: {{word}}", @@ -35,41 +35,41 @@ "account.recoveryPhrase.error.wrong": "Frase de recuperação incorreta", "account.recoveryPhrase.helpText.import": "Como é que encontro a minha frase de recuperação?", "account.recoveryPhrase.helpText.restoring": "Tentar pesquisar novamente", - "account.recoveryPhrase.input": "Escreva sua frase de recuperação", + "account.recoveryPhrase.input": "Escrever a frase de recuperação", "account.recoveryPhrase.remove.final.description": "Certifique-se de que escreveu a sua frase de recuperação ou que fez backup da mesma no {{cloudProviderName}}. <highlight>Caso contrário, não poderá aceder aos seus fundos.</highlight>", "account.recoveryPhrase.remove.final.title": "Está a remover a sua <highlight>frase de recuperação</highlight>", "account.recoveryPhrase.remove.import.description": "Só é possível armazenar uma frase de recuperação de cada vez. Para continuar a importar uma nova, terá de remover a frase de recuperação atual e quaisquer carteiras associadas deste dispositivo.", "account.recoveryPhrase.remove.initial.description": "Isto irá remover a sua carteira deste dispositivo juntamente com a sua frase de recuperação.", "account.recoveryPhrase.remove.initial.title": "Está a remover <highlight>{{walletName}}</highlight>", - "account.recoveryPhrase.remove.mnemonic.description": "Partilha a mesma frase de recuperação que {{walletName}}. A sua frase de recuperação permanecerá guardada até apagar todas as carteiras restantes.", + "account.recoveryPhrase.remove.mnemonic.description": "Esta carteira partilha a mesma frase de recuperação que {{walletNames, list}}. A sua frase de recuperação permanecerá guardada até eliminar todas as carteiras restantes.", "account.recoveryPhrase.subtitle.import": "A sua frase de recuperação só será guardada localmente no seu dispositivo.", - "account.recoveryPhrase.subtitle.restoring": "Introduza a sua frase de recuperação abaixo ou tente pesquisar backups novamente.", - "account.recoveryPhrase.title.import": "Introduzir a sua frase de recuperação", + "account.recoveryPhrase.subtitle.restoring": "Introduza a frase de recuperação abaixo ou tente pesquisar backups novamente.", + "account.recoveryPhrase.title.import": "Introduza a sua frase de recuperação", "account.recoveryPhrase.title.restoring": "Não foram encontrados backups", "account.wallet.action.copy": "Copiar o endereço da carteira", "account.wallet.action.report": "Reportar perfil", "account.wallet.action.settings": "Definições da carteira", "account.wallet.action.viewExplorer": "Ver em {{blockExplorerName}}", "account.wallet.button.add": "Adicionar carteira", - "account.wallet.button.addViewOnly": "Adicionar uma carteira apenas para visualização", + "account.wallet.button.addViewOnly": "Adicionar uma carteira só para visualização", "account.wallet.button.create": "Criar uma nova carteira", "account.wallet.button.import": "Importar uma nova carteira", "account.wallet.button.manage": "Gerir carteira", "account.wallet.button.remove": "Remover carteira", "account.wallet.button.restore": "Restaurar carteira", - "account.wallet.button.watch": "Observar uma carteira", + "account.wallet.button.watch": "Monitorizar uma carteira", "account.wallet.create.placeholder": "Carteira {{index}}", "account.wallet.edit.label.input.placeholder": "Etiqueta da carteira", "account.wallet.header.button.disabled.title": "Editar perfil", "account.wallet.header.button.title": "Editar etiqueta", "account.wallet.header.other": "As suas outras carteiras", - "account.wallet.header.viewOnly": "Carteiras só de visualização", + "account.wallet.header.viewOnly": "Carteiras só para visualização", "account.wallet.menu.copy.title": "Copiar o endereço da carteira", "account.wallet.menu.edit.title": "Editar etiqueta", "account.wallet.menu.remove.title": "Remover carteira", "account.wallet.remove.check": "Fiz backup da minha frase de recuperação e compreendo que a Uniswap Labs não me pode ajudar a recuperar as minhas carteiras se eu não fizer backup.", "account.wallet.remove.title": "Remover {{name}}", - "account.wallet.remove.viewOnly": "Pode sempre voltar a adicionar carteiras apenas para visualização ao introduzir o endereço da carteira.", + "account.wallet.remove.viewOnly": "Pode sempre voltar a adicionar carteiras só para visualização introduzindo os respetivos endereços.", "account.wallet.restore.description": "Uma vez que está a utilizar um novo dispositivo, terá de restaurar a frase de recuperação. Isto permitir-lhe-á trocar e enviar tokens.", "account.wallet.select.error": "Não foi possível carregar endereços", "account.wallet.select.loading.subtitle": "As suas carteiras serão apresentadas abaixo.", @@ -78,13 +78,15 @@ "account.wallet.select.title_one_other": "Selecionar carteiras a importar", "account.wallet.viewOnly.button": "Importar carteira", "account.wallet.viewOnly.description": "Para trocar, comprar, enviar e receber tokens, é necessário importar a frase de recuperação desta carteira.", - "account.wallet.viewOnly.title": "Esta carteira é apenas para visualização", + "account.wallet.viewOnly.title": "Esta carteira é só para visualização", "account.wallet.watch.error.alreadyImported": "Este endereço já foi importado", "account.wallet.watch.error.notFound": "Endereço não encontrado", "account.wallet.watch.error.smartContract": "O endereço é um contrato inteligente", - "account.wallet.watch.message": "Adicionar uma carteira apenas para visualização permite-lhe experimentar a aplicação ou monitorizar uma carteira. Não será possível trocar ou enviar fundos.", + "account.wallet.watch.message": "Adicionar uma carteira só para visualização permite-lhe experimentar a aplicação ou monitorizar uma carteira. Não será possível trocar ou enviar fundos.", "account.wallet.watch.placeholder": "ENS ou endereço", "account.wallet.watch.title": "Introduzir um endereço da carteira", + "common.action.go": "Ir", + "common.action.swipe": "Deslizar", "common.button.accept": "Aceitar", "common.button.back": "Voltar", "common.button.buy": "Comprar", @@ -107,7 +109,7 @@ "common.button.later": "Talvez mais tarde", "common.button.learn": "Saber mais", "common.button.next": "Seguinte", - "common.button.notNow": "Não agora", + "common.button.notNow": "Agora não", "common.button.ok": "OK", "common.button.paste": "Colar", "common.button.pay": "Pagar", @@ -117,12 +119,14 @@ "common.button.retry": "Repetir", "common.button.review": "Rever", "common.button.save": "Guardar", - "common.button.scrollDown": "Deslize para baixo", + "common.button.scrollDown": "Deslocar para baixo", "common.button.sell": "Vender", "common.button.send": "Enviar", "common.button.setup": "Configurar", "common.button.share": "Partilhar", "common.button.show": "Mostrar", + "common.button.showLess": "Mostrar menos", + "common.button.showMore": "Mostrar mais", "common.button.sign": "Assinar", "common.button.skip": "Ignorar", "common.button.swap": "Trocar", @@ -132,7 +136,6 @@ "common.button.yes": "Sim", "common.card.error.description": "Ocorreu um problema", "common.card.error.title": "Ops! Ocorreu um problema.", - "common.endAdornment": "e", "common.error.general": "Ocorreu um problema.", "common.input.password.confirm": "Confirmar palavra-passe", "common.input.password.error.mismatch": "As palavras-passe não coincidem", @@ -175,14 +178,14 @@ "currency.usd": "Dólar dos Estados Unidos", "currency.vnd": "Dong vietnamita", "dapp.request.approve.action": "Aprovar", - "dapp.request.approve.fallbackTitle": "Aprove este site para aceder aos tokens", - "dapp.request.approve.helptext": "Permitir que este site aceda e gaste este token para transações. Certifique-se de que confia neste site.", + "dapp.request.approve.fallbackTitle": "Aprovar este site para aceder aos tokens", + "dapp.request.approve.helptext": "Permita que este site aceda e gaste este token em transações. Certifique-se de que confia neste site.", "dapp.request.approve.label": "Carteira", "dapp.request.approve.title": "Aprovar o acesso a {{tokenSymbol}}", "dapp.request.base.title": "Pedido de transação", "dapp.request.connect.helptext": "Permita que este site consulte o endereço da sua carteira, o saldo e solicite aprovações de transações.", "dapp.request.connect.title": "Ligar ao site", - "dapp.request.fallback.calldata.label": "Dados em bruto", + "dapp.request.fallback.calldata.label": "Dados não tratados", "dapp.request.fallback.function.label": "Função", "dapp.request.permit2.description": "O Permit2 gere as aprovações de tokens em vários dapps.", "dapp.request.permit2.header": "Assinar Permit2", @@ -191,9 +194,9 @@ "dapp.request.signature.containsUnrenderableCharacters": "Esta mensagem contém caracteres que não podem ser renderizados. Certifique-se de que confia neste site.", "dapp.request.signature.error.712-spec-compliance": "O SignTypedDataRequestContent recebeu dados para assinatura que não estão em conformidade com a especificação EIP-712.", "dapp.request.signature.header": "Pedido de assinatura", - "dapp.request.signature.toggleDataView.raw": "Ver dados em bruto", + "dapp.request.signature.toggleDataView.raw": "Ver dados não tratados", "dapp.request.signature.toggleDataView.readable": "Ver dados originais", - "dapp.request.warning.notActive.message": "Certifique-se de que é a correta", + "dapp.request.warning.notActive.message": "Certificar-se de que é a correta", "dapp.request.warning.notActive.title": "Esta não é a sua carteira ativa", "errors.crash.message": "Ocorreu um erro.", "errors.crash.restart": "Reiniciar a aplicação", @@ -211,7 +214,7 @@ "explore.search.section.suggestedWallets": "Carteiras sugeridas", "explore.search.section.tokens": "Tokens", "explore.search.section.wallets": "Carteiras", - "explore.tokens.error": "Não foi possível carregar os tokens", + "explore.tokens.error": "Não foi possível carregar tokens", "explore.tokens.favorite.action.add": "Token favorito", "explore.tokens.favorite.action.edit": "Editar favoritos", "explore.tokens.favorite.action.remove": "Remover favorito", @@ -229,7 +232,7 @@ "explore.tokens.sort.option.priceDecrease": "Descida dos preços (24 h)", "explore.tokens.sort.option.priceIncrease": "Aumento dos preços (24 h)", "explore.tokens.sort.option.totalValueLocked": "TVL Uniswap", - "explore.tokens.sort.option.volume": "Volume Uniswap (24 h)", + "explore.tokens.sort.option.volume": "Volume Uniswap (24 h)", "explore.tokens.top.title": "Principais tokens", "explore.wallets.favorite.action.add": "Carteira favorita", "explore.wallets.favorite.action.edit": "Editar favoritos", @@ -242,13 +245,11 @@ "extension.connection.popupWithButton": "A sua carteira não está ligada a este site.", "extension.connection.titleConnected": "Ligado", "extension.connection.titleNotConnected": "Não ligado", - "extension.feedback.description": "Diga-nos como podemos melhorar: solicite funcionalidades, comunique um erro, etc.", - "extension.feedback.title": "Gostaríamos de receber a sua opinião", "extension.lock.button.forgot": "Esqueceu-se da palavra-passe?", "extension.lock.button.reset": "Repor a carteira", "extension.lock.button.submit": "Desbloquear", "extension.lock.password.error": "Palavra-passe incorreta. Tentar novamente", - "extension.lock.password.reset.initial.description": "A Uniswap não pode ajudar a recuperar a sua palavra-passe. Tem de repor a sua carteira ao introduzir novamente a frase de recuperação de 12 palavras.", + "extension.lock.password.reset.initial.description": "A Uniswap não pode ajudar a recuperar a sua palavra-passe. Tem de repor a sua carteira introduzindo novamente a frase de recuperação de 12 palavras.", "extension.lock.password.reset.initial.help": "Onde posso encontrar a minha frase de recuperação?", "extension.lock.password.reset.initial.title": "Esqueceu-se da palavra-passe?", "extension.lock.password.reset.speedbump.description": "Certifique-se de que tem a frase de recuperação de 12 palavras antes de repor a sua carteira. Caso contrário, não poderá recuperar os seus fundos.", @@ -266,8 +267,8 @@ "fiatOnRamp.button.chooseToken": "Escolher um token", "fiatOnRamp.checkout.title": "Finalização da compra com", "fiatOnRamp.connection.message": "A ligá-lo a {{serviceProvider}}", - "fiatOnRamp.connection.quote": "A comprar {{currencySymbol}} no valor de {{amount}}", - "fiatOnRamp.connection.terms": "Ao continuar, reconhece que estará sujeito aos Termos de Serviço e à Política de Privacidade em {{serviceProvider}}, como aplicável.", + "fiatOnRamp.connection.quote": "A comprar {{amount}} no valor de {{currencySymbol}}", + "fiatOnRamp.connection.terms": "Ao continuar, reconhece que estará sujeito aos Termos de Serviço e à Política de Privacidade em {{serviceProvider}}, conforme aplicável.", "fiatOnRamp.error.default": "Ocorreu um problema.", "fiatOnRamp.error.load": "Não foi possível carregar tokens para comprar", "fiatOnRamp.error.max": "Máximo de {{amount}}", @@ -276,6 +277,7 @@ "fiatOnRamp.error.unsupported": "Não suportado na região", "fiatOnRamp.error.usd": "Apenas disponível para compra em USD", "fiatOnRamp.quote.advice": "Será redirecionado para o portal do prestador de serviços para ver as tarifas associadas à sua transação.", + "fiatOnRamp.quote.type.list": "{{optionsList}} e outras opções", "fiatOnRamp.quote.type.other": "Outras opções", "fiatOnRamp.quote.type.recent": "Utilizado recentemente", "fiatOnRamp.region.placeholder": "Pesquisar por país ou região", @@ -292,12 +294,9 @@ "home.activity.empty.title": "Ainda sem atividade", "home.activity.error.load": "Não foi possível carregar a atividade", "home.activity.title": "Atividade", - "home.banner.extension.confirm.beta": "Aderir à Beta", - "home.banner.extension.confirm.default": "Descarregar", - "home.banner.extension.message.beta": "Seja o primeiro a experimentar a extensão Uniswap no seu browser", - "home.banner.extension.message.default": "Descarregar no Chrome para aceder a esta carteira a partir do seu computador", - "home.banner.extension.title": "A extensão Uniswap está aqui", "home.banner.offline": "Está no modo offline", + "home.explore.footer": "Tocar aqui para explorar milhares de tokens, NFT e muito mais", + "home.explore.title": "Explorar tokens", "home.extension.error": "Erro ao carregar contas", "home.feed.empty.description": "Quando as suas carteiras favoritas efetuam transações, estas são apresentadas aqui.", "home.feed.empty.title": "Ainda sem atividade", @@ -305,21 +304,15 @@ "home.feed.title": "Feed", "home.label.buy": "Comprar", "home.label.receive": "Receber", - "home.label.scan": "Ler", + "home.label.scan": "Digitalizar", "home.label.send": "Enviar", "home.label.swap": "Trocar", - "home.modal.getExtension.beta.step3": "3. Introduza o seu nome de utilizador para obter acesso", - "home.modal.getExtension.beta.title": "Participe na versão beta da extensão Uniswap", - "home.modal.getExtension.ga.step1": "1. Visite <highlight>uniswap.org/ext</highlight> no ambiente de trabalho do Chrome", - "home.modal.getExtension.ga.step2": "2. Adicionar a extensão Uniswap ao seu navegador", - "home.modal.getExtension.ga.step3": "3. Leia o código QR com a aplicação móvel Uniswap para importar a sua carteira", - "home.modal.getExtension.ga.title": "Descarregar a extensão Uniswap", - "home.nfts.title": "NFTs", + "home.nfts.title": "NFT", "home.tokens.empty.action.buy.description": "Comprar cripto com um cartão de débito ou uma conta bancária.", "home.tokens.empty.action.buy.title": "Comprar cripto com cartão", "home.tokens.empty.action.import.description": "Introduza a frase de recuperação desta carteira para começar a trocar e enviar.", "home.tokens.empty.action.import.title": "Importar carteira", - "home.tokens.empty.action.receive.description": "Financie a sua carteira ao transferir cripto de outra carteira ou conta.", + "home.tokens.empty.action.receive.description": "Financie a sua carteira transferindo cripto de outra carteira ou conta.", "home.tokens.empty.action.receive.title": "Receber cripto", "home.tokens.empty.description": "Quando esta carteira compra ou recebe tokens, estes são apresentados aqui.", "home.tokens.empty.title": "Ainda sem tokens", @@ -327,9 +320,9 @@ "home.tokens.error.load": "Não foi possível carregar saldos dos tokens", "home.tokens.title": "Tokens", "home.upsell.receive.cta": "De uma conta", - "home.upsell.receive.description": "Financie a sua carteira ao transferir cripto de outra carteira ou conta", + "home.upsell.receive.description": "Financie a sua carteira transferindo cripto de outra carteira ou conta", "home.upsell.receive.title": "Receber cripto", - "home.warning.viewOnly": "Esta é uma carteira apenas para visualização", + "home.warning.viewOnly": "Esta é uma carteira só para visualização", "language.chineseSimplified": "Chinês simplificado", "language.chineseTraditional": "Chinês tradicional", "language.dutch": "Neerlandês", @@ -354,7 +347,7 @@ "mobile.appRating.feedback.button.cancel": "Talvez mais tarde", "mobile.appRating.feedback.button.send": "Enviar comentários", "mobile.appRating.feedback.description": "Diga-nos como podemos melhorar a sua experiência", - "mobile.appRating.feedback.title": "Lamentamos ouvir isso.", + "mobile.appRating.feedback.title": "Lamentamos sabê-lo.", "mobile.appRating.title": "Está a gostar do Uniswap Wallet?", "notification.assetVisibility.hidden": "{{assetName}} oculto", "notification.assetVisibility.unhidden": "{{assetName}} visível", @@ -379,7 +372,7 @@ "notification.transaction.approve.fail": "Não foi possível aprovar {{currencySymbol}} para utilizar com {{address}}.", "notification.transaction.approve.success": "{{currencySymbol}} aprovado para utilizar com {{address}}.", "notification.transaction.pending": "Transação pendente", - "notification.transaction.swap.canceled": "Troca de {{inputCurrencySymbol}}-{{outputCurrencySymbol}} cancelada.", + "notification.transaction.swap.canceled": "Troca de {{inputCurrencySymbol}} por {{outputCurrencySymbol}} cancelada.", "notification.transaction.swap.expired": "Troca de {{inputCurrencyAmountWithSymbol}} por {{outputCurrencyAmountWithSymbol}} expirada.", "notification.transaction.swap.fail": "Falha ao trocar {{inputCurrencyAmountWithSymbol}} por {{outputCurrencyAmountWithSymbol}}.", "notification.transaction.swap.success": "{{inputCurrencyAmountWithSymbol}} trocado por {{outputCurrencyAmountWithSymbol}}.", @@ -440,13 +433,17 @@ "onboarding.complete.pin.description": "Clique no ícone do pino para adicionar a extensão Uniswap à sua barra de ferramentas.", "onboarding.complete.pin.title": "Fixar a extensão Uniswap", "onboarding.complete.title": "Está tudo pronto", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "Descarregue a aplicação para dispositivos móveis para reivindicar um nome de utilizador", - "onboarding.extension.getOnTheBetaWaitlist.title": "Inscreva-se na lista de espera Beta", "onboarding.extension.password.subtitle": "Irá necessitar disto para desbloquear a sua carteira e aceder à sua frase de recuperação", "onboarding.extension.password.title.default": "Criar palavra-passe", "onboarding.extension.password.title.reset": "Repor a sua palavra-passe", + "onboarding.extension.unsupported.android.description": "A extensão Uniswap só é compatível com o Chrome para computadores pessoais.", + "onboarding.extension.unsupported.android.title": "O Chrome para telemóveis não é suportado (ainda)", "onboarding.extension.unsupported.description": "De momento, a extensão Uniswap só é compatível com o Chrome.", "onboarding.extension.unsupported.title": "Este navegador não é suportado (ainda)", + "onboarding.home.intro.fund.description": "Financie a sua carteira comprando cripto ou transferindo-a de outra conta.", + "onboarding.home.intro.fund.title": "Obter o seu primeiro token", + "onboarding.home.intro.welcome.description": "Termine de configurar a sua carteira para começar a trocar em segundos.", + "onboarding.home.intro.welcome.title": "Bem-vindo ao Uniswap", "onboarding.import.error.invalidWords_one": "1 palavra inválida ou mal escrita", "onboarding.import.error.invalidWords_other": "{{count}} palavras inválidas ou mal escritas", "onboarding.import.method.import.message": "Introduzir a frase de recuperação a partir de outra carteira de cripto", @@ -462,23 +459,16 @@ "onboarding.import.onDeviceRecovery.wallet.count_one": "+1 outra carteira", "onboarding.import.onDeviceRecovery.wallet.count_other": "+{{count}} outras carteiras", "onboarding.import.onDeviceRecovery.warning.caption": "Certifique-se de que fez um backup de todas as outras carteiras. Se alguma vez os quiser restaurar, precisará das respetivas frases de recuperação ou dos backups correspondentes do iCloud.", - "onboarding.import.onDeviceRecovery.warning.title": "Tem certeza?", - "onboarding.import.title": "Escolher como adicionar a sua carteira", + "onboarding.import.onDeviceRecovery.warning.title": "Tem a certeza?", + "onboarding.import.title": "Escolha como pretende adicionar a sua carteira", "onboarding.importMnemonic.button.default": "A minha frase de recuperação tem 12 palavras", "onboarding.importMnemonic.button.longPhrase": "A minha frase de recuperação é mais longa", "onboarding.importMnemonic.error.invalidPhrase": "A frase que introduziu é inválida", "onboarding.importMnemonic.subtitle": "Escreva ou cole a sua frase de recuperação de 12 palavras", "onboarding.importMnemonic.title": "Introduza a sua frase de recuperação", "onboarding.intro.button.alreadyHave": "Já tenho uma carteira", - "onboarding.intro.mobileScan.button": "Ler código QR para importar", + "onboarding.intro.mobileScan.button": "Digitalizar código QR para importar", "onboarding.intro.mobileScan.title": "Tem a aplicação Uniswap?", - "onboarding.introBetaWaitlist.button.checkEligibility": "Verificar a elegibilidade", - "onboarding.introBetaWaitlist.button.letsGo": "Vamos lá", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "Introduza o seu nome de utilizador <highlight>uni.eth</highlight> abaixo para verificar se é elegível para o Beta.", - "onboarding.introBetaWaitlist.eligible.tagline": "Bem-vindo à versão Beta - é um dos primeiros a experimentar a extensão Uniswap.", - "onboarding.introBetaWaitlist.eligible.title": "Saiu da lista de espera!", - "onboarding.introBetaWaitlist.ineligibleExplanation": "Ainda está na lista de espera. Será notificado na aplicação para dispositivos móveis Uniswap quando se tornar elegível!", - "onboarding.introBetaWaitlist.unitagPlaceholder": "nome de utilizador", "onboarding.landing.button.add": "Adicionar uma carteira existente", "onboarding.landing.button.create": "Criar uma carteira", "onboarding.notification.permission.message": "Para receber notificações, ative as notificações da carteira Uniswap nas definições do seu dispositivo.", @@ -498,19 +488,19 @@ "onboarding.resetPassword.complete.safety": "Saiba mais sobre a segurança das carteiras", "onboarding.resetPassword.complete.subtitle": "Utilize a sua nova palavra-passe para desbloquear a sua carteira.", "onboarding.resetPassword.complete.title": "Redefinir a palavra-passe", - "onboarding.scan.button": "Ler com a aplicação Uniswap", + "onboarding.scan.button": "Digitalizar com a aplicação Uniswap", "onboarding.scan.error": "Lamentamos, mas não é possível carregar o código QR neste momento. Tente outro método de integração.", "onboarding.scan.otp.error": "O código que submeteu está incorreto ou ocorreu um erro na submissão. Tente novamente.", "onboarding.scan.otp.failed": "Tentativas falhadas: {{number}}", "onboarding.scan.otp.subtitle": "Consulte a sua aplicação para dispositivos móveis Uniswap para obter o código de 6 caracteres", "onboarding.scan.otp.title": "Introduzir código de utilização única", - "onboarding.scan.subtitle": "Leia o código QR com a aplicação para dispositivos móveis Uniswap para começar a importar a sua carteira.", + "onboarding.scan.subtitle": "Digitalize o código QR com a aplicação para dispositivos móveis Uniswap para começar a importar a sua carteira.", "onboarding.scan.title": "Importar a carteira a partir da aplicação", "onboarding.scan.wifi": "Ligue o seu telemóvel à mesma rede Wi-Fi que o seu computador.", "onboarding.security.alert.biometrics.message.android": "Para utilizar a autenticação biométrica, configure-a primeiro nas definições", "onboarding.security.alert.biometrics.message.ios": "Para utilizar {{biometricsMethod}}, permita o acesso nas definições do sistema", "onboarding.security.alert.biometrics.title.android": "A autenticação biométrica está desativada", - "onboarding.security.alert.biometrics.title.ios": "{{biometricsMethod}} desativado", + "onboarding.security.alert.biometrics.title.ios": "O método {{biometricsMethod}} está desativado", "onboarding.security.button.confirm.android": "Ativar autenticação biométrica", "onboarding.security.button.confirm.ios": "Ativar {{biometricsMethod}}", "onboarding.security.button.setup": "Configurar", @@ -524,63 +514,63 @@ "onboarding.tooltip.recoveryPhrase.trigger": "O que é uma frase de recuperação?", "onboarding.wallet.continue": "Vamos mantê-la em segurança", "onboarding.wallet.defaultName": "Carteira {{number}}", - "onboarding.wallet.description.full": "Este é o seu espaço pessoal para tokens, NFTs e todas as suas transações. Conclua a sua configuração para manter os seus fundos seguros.", + "onboarding.wallet.description.full": "Este é o seu espaço pessoal para tokens, NFT e todas as suas transações. Conclua a sua configuração para manter os seus fundos seguros.", "onboarding.wallet.title": "Damos-lhe as boas-vindas à sua nova carteira", "qrScanner.button.connections_one": "1 aplicação ligada", "qrScanner.button.connections_other": "{{count}} aplicações ligadas", - "qrScanner.error.camera.message": "Para ler um código, permita o acesso à câmara nas definições do sistema", + "qrScanner.error.camera.message": "Para digitalizar um código, permita o acesso à câmara nas definições do sistema", "qrScanner.error.camera.title": "A câmara está desativada", "qrScanner.error.none": "Nenhum código QR encontrado", - "qrScanner.recipient.action.scan": "Ler um código QR", + "qrScanner.recipient.action.scan": "Digitalizar um código QR", "qrScanner.recipient.action.show": "Mostrar o meu código QR", - "qrScanner.recipient.error.message": "Certifique-se de que está a ler um código QR de endereço Ethereum válido antes de tentar novamente.", + "qrScanner.recipient.error.message": "Certifique-se de que está a digitalizar um código QR de endereço Ethereum válido antes de tentar novamente.", "qrScanner.recipient.error.title": "Código QR inválido", - "qrScanner.recipient.input.placeholder": "Pesquisar ENS ou endereço", - "qrScanner.recipient.label.send": "Enviar", - "qrScanner.recipient.results.empty": "Não foram encontrados resultados", - "qrScanner.recipient.results.error": "O endereço que escreveu não existe ou contém erros.", "qrScanner.request.message.unavailable": "Nenhuma mensagem encontrada.", "qrScanner.request.method.default": "Pedido de {{dappNameOrUrl}}", "qrScanner.request.method.signature": "Pedido de assinatura de {{dappNameOrUrl}}", "qrScanner.request.method.transaction": "Pedido de transação de {{dappNameOrUrl}}", "qrScanner.request.withAmount": "Permitir que {{dappName}} utilize até<highlight> {{amount}} </highlight>{{currencySymbol}}?", "qrScanner.request.withoutAmount": "Permitir que {{dappName}} utilize {{currencySymbol}}?", - "qrScanner.status.connecting": "A ligar...", - "qrScanner.status.loading": "A carregar...", - "qrScanner.title": "Ler um código QR", - "qrScanner.wallet.title": "Pode receber tokens e NFTs em Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast e BNB Chain.", + "qrScanner.status.connecting": "A ligar…", + "qrScanner.status.loading": "A carregar…", + "qrScanner.title": "Digitalizar um código QR", + "qrScanner.wallet.title": "Pode receber tokens e NFT em Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast e BNB Chain.", "scantastic.code.expired": "Expirado", "scantastic.code.subtitle": "Introduza este código na extensão Uniswap. A sua frase de recuperação será encriptada e transferida em segurança.", - "scantastic.code.timeRemaining.shorthand.hours": "Novo código em {{hours}}h {{minutes}}m {{seconds}}s", - "scantastic.code.timeRemaining.shorthand.minutes": "Novo código em {{minutes}}m {{seconds}}s", + "scantastic.code.timeRemaining.shorthand.hours": "Novo código em {{hours}}h, {{minutes}}m e {{seconds}}s", + "scantastic.code.timeRemaining.shorthand.minutes": "Novo código em {{minutes}}m e {{seconds}}s", "scantastic.code.timeRemaining.shorthand.seconds": "Novo código em {{seconds}}s", "scantastic.code.title": "Código de utilização única Uniswap", "scantastic.confirmation.button.continue": "Sim, continuar", "scantastic.confirmation.label.browser": "Navegador", "scantastic.confirmation.label.device": "Dispositivo", - "scantastic.confirmation.subtitle": "Continue apenas se estiver a ler um código QR da extensão Uniswap num dispositivo fiável.", + "scantastic.confirmation.subtitle": "Continue apenas se estiver a digitalizar um código QR da extensão Uniswap num dispositivo fiável.", "scantastic.confirmation.title": "Está a tentar importar a sua carteira?", - "scantastic.confirmation.warning": "Cuidado com os sites e aplicações que se fazem passar pelo Uniswap. Caso contrário, a sua carteira pode ficar comprometida.", + "scantastic.confirmation.warning": "Cuidado com os sites e aplicações que se fazem passar pelo Uniswap. Caso contrário, a sua carteira poderá ficar comprometida.", "scantastic.error.encryption": "Falha na preparação da frase de recuperação.", "scantastic.error.noCode": "Nenhum código de utilização única (OTP) recebido. Tente novamente.", - "scantastic.error.timeout.message": "Leia o código QR na extensão Uniswap novamente para continuar a sincronizar a sua carteira.", + "scantastic.error.timeout.message": "Digitalize o código QR na extensão Uniswap novamente para continuar a sincronizar a sua carteira.", "scantastic.error.timeout.title": "A sua ligação expirou", - "scantastic.modal.ipMismatch.description": "Para ler este código QR, o telemóvel tem de estar ligado à mesma rede Wi-Fi que o computador.", + "scantastic.modal.ipMismatch.description": "Para digitalizar este código QR, o telemóvel tem de estar ligado à mesma rede Wi-Fi que o computador.", "scantastic.modal.ipMismatch.title": "Mudar a sua rede WiFi", "send.button.review": "Rever transferência", "send.button.send": "Enviar", "send.gas.error.title": "N/A", "send.gas.networkCost.title": "Custo da rede", "send.input.token.balance.title": "Saldo: {{balance}} {{symbol}}", + "send.recipient.header": "Selecionar destinatário", + "send.recipient.input.placeholder": "Pesquisar ENS ou endereço", "send.recipient.previous_one": "1 transferência anterior", "send.recipient.previous_other": "{{count}} transferências anteriores", + "send.recipient.results.empty": "Não foram encontrados resultados", + "send.recipient.results.error": "O endereço que escreveu não existe ou contém erros.", "send.recipient.section.favorite": "Carteiras favoritas", "send.recipient.section.recent": "Recentes", "send.recipient.section.search": "Resultados da pesquisa", - "send.recipient.section.viewOnly": "Carteiras só de visualização", + "send.recipient.section.viewOnly": "Carteiras só para visualização", "send.recipient.section.yours": "As suas carteiras", "send.recipient.warning.viewOnly.message": "Só envie fundos para esta carteira se tiver a frase de recuperação ou se conhecer o proprietário da carteira.", - "send.recipient.warning.viewOnly.title": "Tem isto como uma carteira apenas para visualização", + "send.recipient.warning.viewOnly.title": "Tem isto como uma carteira só para visualização", "send.recipientSelect.search.empty.message": "Quando enviar tokens para um endereço de carteira, eles aparecerão aqui", "send.recipientSelect.search.empty.title": "Nenhuma carteira guardada", "send.review.modal.title": "Está a enviar", @@ -609,6 +599,9 @@ "send.warning.modal.button.cta.blocking": "OK", "send.warning.modal.button.cta.cancel": "Cancelar", "send.warning.modal.button.cta.confirm": "Confirmar", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "Nome de utilizador", + "send.warning.newAddress.details.walletAddress": "Endereço da carteira", "send.warning.newAddress.message": "Não efetuou transações com este endereço anteriormente. Confirme se o endereço está correto antes de continuar.", "send.warning.newAddress.title": "Novo endereço", "send.warning.restore": "Restaurar a sua carteira para enviar", @@ -617,7 +610,7 @@ "send.warning.smartContract.message": "Está prestes a enviar tokens para um tipo de endereço especial: um contrato inteligente. Verifique novamente se é o endereço para o qual pretende enviar. Se estiver incorreto, os seus tokens podem perder-se para sempre.", "send.warning.smartContract.title": "Este é um endereço de carteira?", "send.warning.viewOnly.message": "É necessário importar esta carteira através da frase de recuperação para enviar ativos.", - "send.warning.viewOnly.title": "Esta carteira é apenas para visualização", + "send.warning.viewOnly.title": "Esta carteira é só para visualização", "setting.recoveryPhrase.account.show": "Mostrar frase de recuperação", "setting.recoveryPhrase.action.hide": "Ocultar frase de recuperação", "setting.recoveryPhrase.remove": "Remover frase de recuperação", @@ -630,7 +623,7 @@ "setting.recoveryPhrase.remove.title": "Está a remover a sua frase de recuperação", "setting.recoveryPhrase.view.warning.message1": "Qualquer pessoa que saiba a sua frase de recuperação pode aceder à sua carteira e aos seus fundos", "setting.recoveryPhrase.view.warning.message2": "Veja a frase de recuperação em privado", - "setting.recoveryPhrase.view.warning.message3": "Não partilhe com ninguém", + "setting.recoveryPhrase.view.warning.message3": "Não a partilhe com ninguém", "setting.recoveryPhrase.view.warning.message4": "Nunca a introduza em sites ou aplicações", "setting.recoveryPhrase.view.warning.title": "Antes de continuar", "setting.recoveryPhrase.warning.screenshot.message": "Qualquer pessoa que tenha acesso às suas fotografias pode aceder à sua carteira. Em vez disso, recomendamos que anote as palavras.", @@ -642,7 +635,7 @@ "settings.action.lock": "Bloquear carteira", "settings.action.privacy": "Política de privacidade", "settings.action.terms": "Termos do serviço", - "settings.footer": "Com toda a dedicação, \nEquipa Uniswap 🦄", + "settings.footer": "Com toda a dedicação, a \nEquipa Uniswap 濾", "settings.screen.appearance.title": "Aparência", "settings.section.about": "Acerca de", "settings.section.preferences": "Preferências", @@ -650,7 +643,7 @@ "settings.section.support": "Suporte", "settings.section.wallet.button.viewAll": "Ver tudo", "settings.section.wallet.button.viewLess": "Ver menos", - "settings.section.wallet.label.viewOnly": "Apenas para visualização", + "settings.section.wallet.label.viewOnly": "Só para visualização", "settings.section.wallet.title": "Definições da carteira", "settings.setting.appearance.option.dark.subtitle": "Utilizar sempre o modo escuro", "settings.setting.appearance.option.dark.title": "Modo escuro", @@ -662,7 +655,7 @@ "settings.setting.backup.create.description": "A definição de uma palavra-passe encriptará o backup da frase de recuperação, o que acrescenta um nível extra de proteção se a sua conta {{cloudProviderName}} for comprometida.", "settings.setting.backup.create.title": "Fazer backup para {{cloudProviderName}}", "settings.setting.backup.delete.confirm.message": "Uma vez que estas carteiras partilham uma frase de recuperação, eliminará também os backups destas carteiras abaixo", - "settings.setting.backup.delete.confirm.title": "Tem certeza?", + "settings.setting.backup.delete.confirm.title": "Tem a certeza?", "settings.setting.backup.delete.warning": "Se eliminar o backup do {{cloudProviderName}}, só poderá recuperar a sua carteira com um backup manual da sua frase de recuperação. A Uniswap Labs não pode recuperar os seus ativos se perder a sua frase de recuperação.", "settings.setting.backup.error.message.full": "Não é possível efetuar o backup da frase de recuperação para o {{cloudProviderName}}. Certifique-se de que tem o {{cloudProviderName}} ativado com espaço de armazenamento disponível e tente novamente.", "settings.setting.backup.error.message.short": "Não é possível eliminar o backup", @@ -671,7 +664,7 @@ "settings.setting.backup.modal.title": "Fazer backup da frase de recuperação para o {{cloudProviderName}}?", "settings.setting.backup.password.disclaimer": "A Uniswap Labs não guarda a sua palavra-passe e não a pode recuperar, pelo que é crucial que se lembre dela.", "settings.setting.backup.password.error.mismatch": "As palavras-passe não coincidem", - "settings.setting.backup.password.medium": "Esta é uma palavra-passe média", + "settings.setting.backup.password.medium": "Esta é uma palavra-passe de segurança média", "settings.setting.backup.password.placeholder.confirm": "Confirmar palavra-passe", "settings.setting.backup.password.placeholder.create": "Criar palavra-passe", "settings.setting.backup.password.strong": "Esta é uma palavra-passe forte", @@ -685,8 +678,8 @@ "settings.setting.backup.status.recoveryPhrase.backed": "Backup feito", "settings.setting.backup.status.title": "Backup do {{cloudProviderName}}", "settings.setting.beta.tooltip": "Em breve", - "settings.setting.biometrics.appAccess.subtitle.android": "Solicitar autenticação biométrica para abrir aplicação", - "settings.setting.biometrics.appAccess.subtitle.ios": "Solicitar {{biometricsMethod}} para abrir aplicação", + "settings.setting.biometrics.appAccess.subtitle.android": "Solicitar autenticação biométrica para abrir a aplicação", + "settings.setting.biometrics.appAccess.subtitle.ios": "Solicitar {{biometricsMethod}} para abrir a aplicação", "settings.setting.biometrics.appAccess.title": "Acesso à aplicação", "settings.setting.biometrics.auth": "Efetue a autenticação", "settings.setting.biometrics.off.message.android": "A autenticação biométrica está atualmente desativada para a carteira Uniswap. Pode ativá-la nas definições do sistema.", @@ -698,17 +691,16 @@ "settings.setting.biometrics.transactions.subtitle.ios": "Solicitar {{biometricsMethod}} para efetuar transações", "settings.setting.biometrics.transactions.title": "Transações", "settings.setting.biometrics.unavailable.message.android": "A autenticação biométrica não está configurada no seu dispositivo. Para utilizar a autenticação biométrica, configure-a primeiro nas Definições.", - "settings.setting.biometrics.unavailable.message.ios": "{{biometricsMethod}} não está configurado no seu dispositivo. Para utilizar {{biometricsMethod}}, configure-o primeiro nas Definições.", + "settings.setting.biometrics.unavailable.message.ios": "O método {{biometricsMethod}} não está configurado no seu dispositivo. Para utilizar o método {{biometricsMethod}}, configure-o primeiro nas Definições.", "settings.setting.biometrics.unavailable.title.android": "A biometria não está configurada", - "settings.setting.biometrics.unavailable.title.ios": "{{biometricsMethod}} não está configurado", + "settings.setting.biometrics.unavailable.title.ios": "O método {{biometricsMethod}} não está configurado", "settings.setting.biometrics.warning.message.android": "Se não ativar a biometria, qualquer pessoa que tenha acesso ao seu dispositivo pode abrir a carteira Uniswap e efetuar transações.", "settings.setting.biometrics.warning.message.ios": "Se não ativar {{biometricsMethod}}, qualquer pessoa que obtenha acesso ao seu dispositivo pode abrir a carteira Uniswap e fazer transações.", - "settings.setting.biometrics.warning.title": "Tem certeza?", + "settings.setting.biometrics.warning.title": "Tem a certeza?", "settings.setting.currency.title": "Moeda local", - "settings.setting.giveFeedback.title": "Partilhar comentários", "settings.setting.helpCenter.title": "Centro de ajuda", "settings.setting.language.button.navigate": "Ir para as definições", - "settings.setting.language.description.extension": "O Uniswap é predefinido para as definições de idioma do sistema. Para alterar o seu idioma preferido, aceda às definições do sistema.", + "settings.setting.language.description.extension": "O Uniswap é predefinido de acordo com as definições de idioma do sistema. Para alterar o seu idioma preferido, aceda às definições do sistema.", "settings.setting.language.description.mobile": "A Uniswap assume a predefinição das definições de idioma do seu dispositivo. Para alterar o seu idioma preferido, vá a \"Uniswap\" nas definições do seu dispositivo e toque em \"Idioma\".", "settings.setting.language.title": "Idioma", "settings.setting.password.title": "Alterar palavra-passe", @@ -723,24 +715,22 @@ "settings.setting.wallet.action.editProfile": "Editar perfil", "settings.setting.wallet.action.remove": "Remover carteira", "settings.setting.wallet.connections.title": "Gerir ligações", - "settings.setting.wallet.editLabel.description": "Os rótulos não são públicos. São armazenados localmente e só estão visíveis ao utilizador.", + "settings.setting.wallet.editLabel.description": "Os rótulos não são públicos. São armazenados localmente e só estão visíveis para o utilizador.", "settings.setting.wallet.editLabel.save": "Guardar alterações", - "settings.setting.wallet.label": "Pseudónimo", + "settings.setting.wallet.label": "Alcunha", "settings.setting.wallet.notifications.title": "Notificações", "settings.setting.wallet.preferences.title": "Preferências da carteira", "settings.title": "Definições", "settings.version": "Versão {{appVersion}}", "swap.button.max": "Máximo", "swap.button.review": "Rever", - "swap.button.submitting": "A submeter troca...", - "swap.button.submitting.keep.open": "Manter a carteira aberta...", + "swap.button.submitting": "A submeter troca…", + "swap.button.submitting.keep.open": "Manter a carteira aberta…", "swap.button.swap": "Trocar", "swap.button.unwrap": "Desembrulhar", "swap.button.view": "Ver transação", "swap.button.wrap": "Embrulhar", - "swap.details.action.less": "Mostrar menos", - "swap.details.action.more": "Mostrar mais", - "swap.details.feeOnTransfer": "tarifa de {{tokenSymbol}}", + "swap.details.feeOnTransfer": "Tarifa de {{tokenSymbol}}", "swap.details.newQuote.input": "Nova entrada", "swap.details.newQuote.output": "Nova saída", "swap.details.rate": "Taxa", @@ -749,17 +739,17 @@ "swap.form.balance": "Saldo", "swap.form.header": "Trocar", "swap.form.slippage": "{{slippageTolerancePercent}} de deslizamento", - "swap.form.warning.output.fotFees": "Devido à tarifa do token de {{fotCurrencySymbol}}, os montantes de troca só podem ser introduzidos através o campo de introdução", + "swap.form.warning.output.fotFees": "Devido à tarifa do token de {{fotCurrencySymbol}}, os montantes de troca só podem ser introduzidos utilizando o campo de entrada", "swap.form.warning.output.fotFees.fallback": "Devido às tarifas de token, os montantes de troca só podem ser introduzidos utilizando o campo de entrada", "swap.form.warning.restore": "Restaurar a sua carteira para trocar", - "swap.header.viewOnly": "Apenas para visualização", - "swap.hold.swap": "Manter para trocar", - "swap.hold.tip": "Sugestão: Manter para troca instantânea", - "swap.hold.unwrap": "Manter para desembrulhar", - "swap.hold.wrap": "Manter para embrulhar", + "swap.header.viewOnly": "Só para visualização", + "swap.hold.swap": "Manter premido para trocar", + "swap.hold.tip": "Sugestão: manter premido para trocar instantaneamente", + "swap.hold.unwrap": "Manter premido para desembrulhar", + "swap.hold.wrap": "Manter premido para embrulhar", "swap.request.title.full": "Trocar {{inputCurrencySymbol}} → {{outputCurrencySymbol}}", "swap.request.title.short": "Trocar tokens", - "swap.review.summary": "Está a efetuar uma troca", + "swap.review.summary": "Está a trocar", "swap.settings.protection.description": "Com a proteção de troca ativada, as suas transações de Ethereum estarão protegidas contra ataques sanduíche, com reduzidas probabilidades de falha.", "swap.settings.protection.subtitle.supported": "Rede {{chainName}}", "swap.settings.protection.subtitle.unavailable": "Não disponível em {{chainName}}", @@ -772,34 +762,35 @@ "swap.settings.slippage.control.auto": "Automático", "swap.settings.slippage.control.title": "Deslizamento máximo", "swap.settings.slippage.description": "A sua transação será revertida se o preço mudar mais do que a percentagem de deslizamento.", - "swap.settings.slippage.input.message": "Se o preço derrapar ainda mais, a sua transação será revertida. Abaixo consta o montante mínimo que garantidamente receberá.", + "swap.settings.slippage.input.message": "Se o preço deslizar ainda mais, a sua transação será revertida. Abaixo consta o montante mínimo que garantidamente receberá.", "swap.settings.slippage.input.receive.title": "Receber pelo menos", - "swap.settings.slippage.output.message": "Se o preço derrapar ainda mais, a sua transação será revertida. Abaixo consta o montante máximo que terá de gastar.", + "swap.settings.slippage.output.message": "Se o preço deslizar ainda mais, a sua transação será revertida. Abaixo consta o montante máximo que terá de gastar.", "swap.settings.slippage.output.spend.title": "Gastar no máximo", "swap.settings.slippage.warning.max": "Introduzir um valor inferior a {{maxSlippageTolerance}}", "swap.settings.slippage.warning.message": "O deslizamento pode ser superior ao necessário", "swap.settings.slippage.warning.min": "Introduzir um valor superior a 0", "swap.settings.title": "Definições de troca", "swap.slippage.settings.title": "Definições de deslizamento", - "swap.warning.expectedFailure": "Esta transação deve falhar", + "swap.warning.expectedFailure": "Esta transação deverá falhar", "swap.warning.feeOnTransfer.message": "Alguns tokens cobram uma tarifa quando são comprados ou vendidos, que é definida pelo emissor do token. A Uniswap não recebe qualquer parte destas tarifas.", - "swap.warning.feeOnTransfer.title": "Por que existe uma tarifa adicional?", + "swap.warning.feeOnTransfer.title": "Porque existe uma tarifa adicional?", "swap.warning.insufficientBalance.button": "{{currencySymbol}} insuficientes", "swap.warning.insufficientBalance.title": "Não tem {{currencySymbol}} suficientes", "swap.warning.insufficientGas.button": "{{currencySymbol}} insuficientes", + "swap.warning.insufficientGas.button.buy": "Comprar {{ tokenSymbol }}", "swap.warning.insufficientGas.message.withNetwork": "Não há <highlight>{{currencySymbol}} suficiente em {{networkName}}</highlight> para trocar", "swap.warning.insufficientGas.message.withoutNetwork": "Não há <highlight>{{currencySymbol}}</highlight> suficiente para trocar", - "swap.warning.insufficientGas.title": "Não tem {{currencySymbol}} suficientes para cobrir o custo de rede", + "swap.warning.insufficientGas.title": "Não tem {{currencySymbol}} suficientes para cobrir o custo da rede", "swap.warning.lowLiquidity.message": "Atualmente, não existe liquidez suficiente entre estes tokens para efetuar uma troca. Tente novamente mais tarde ou selecione outro token.", "swap.warning.lowLiquidity.title": "Liquidez insuficiente", "swap.warning.networkFee.allow": "Permitir {{ inputTokenSymbol }} (uma vez)", "swap.warning.networkFee.highRelativeToValue": "O custo da rede excede 10% do valor total da transação.", - "swap.warning.networkFee.message": "Este é o custo para processar a sua transação na blockchain. A Uniswap não recebe qualquer parte destas tarifas.", - "swap.warning.networkFee.message.uniswapX": "Este é o custo para processar a sua transação na blockchain. A Uniswap não recebe qualquer parte destas tarifas. O <gradient>UniswapX</gradient> agrega fontes de liquidez para obter melhores preços e trocas sem Gas.", + "swap.warning.networkFee.message": "Este é o custo de processamento da sua transação na blockchain. A Uniswap não recebe qualquer parte destas tarifas.", + "swap.warning.networkFee.message.uniswapX": "Este é o custo de processamento da sua transação na blockchain. A Uniswap não recebe qualquer parte destas tarifas. O <gradient>UniswapX</gradient> agrega fontes de liquidez para obter melhores preços e trocas sem Gas.", "swap.warning.networkFee.wrap": "Embrulhar ETH", "swap.warning.offline.message": "Pode ter perdido a ligação à Internet ou a rede pode estar em baixo. Verifique a sua ligação à Internet e tente novamente.", "swap.warning.offline.title": "Está offline", - "swap.warning.priceImpact.message": "Devido ao montante de liquidez de {{outputCurrencySymbol}} atualmente disponível, quanto mais {{inputCurrencySymbol}} tentar trocar, menos {{outputCurrencySymbol}} irá receber.", + "swap.warning.priceImpact.message": "Devido ao montante de liquidez em {{outputCurrencySymbol}} atualmente disponível, quanto mais {{inputCurrencySymbol}} tentar trocar, menos {{outputCurrencySymbol}} irá receber.", "swap.warning.priceImpact.title": "Impacto elevado no preço ({{priceImpactValue}})", "swap.warning.queuedOrder.appClosed": "A sua transação não foi submetida porque fechou a aplicação.", "swap.warning.queuedOrder.approvalFailed": "A sua transação não foi submetida porque a aprovação do token falhou.", @@ -821,7 +812,7 @@ "token.balances.viewOnly": "Saldo de {{ownerAddress}}", "token.error.unknown": "Token desconhecido", "token.links.title": "Ligações", - "token.links.twitter": "Twitter", + "token.links.twitter": "X", "token.links.website": "Site", "token.priceExplorer.error.description": "Ocorreu um problema.", "token.priceExplorer.error.title": "Não foi possível carregar o gráfico de preços", @@ -845,7 +836,9 @@ "token.stats.title": "Estatísticas", "token.stats.translation.original": "Mostrar original", "token.stats.translation.translate": "Traduzir para {{language}}", - "token.stats.volume": "Volume em 24h", + "token.stats.volume": "Volume em 24 h", + "token.zeroNativeBalance.description": "Para obter {{ tokenSymbol }}, precisa de {{ nativeTokenSymbol }} para pagar o custo da rede. Comece por financiar a sua carteira com {{ nativeTokenSymbol }}.", + "token.zeroNativeBalance.title": "Necessita de {{ nativeTokenName }} ", "tokens.action.hide": "Ocultar token", "tokens.action.unhide": "Mostrar token", "tokens.hidden.label": "Ocultos ({{numHidden}})", @@ -861,25 +854,25 @@ "tokens.nfts.details.price": "Preço atual", "tokens.nfts.details.recentPrice": "Último preço de venda", "tokens.nfts.details.traits": "Características", - "tokens.nfts.empty.description": "Não foram encontrados NFTs", + "tokens.nfts.empty.description": "Não foram encontrados NFT", "tokens.nfts.error.unavailable": "Conteúdo não disponível", "tokens.nfts.hidden.action.hide": "Ocultar NFT", "tokens.nfts.hidden.action.unhide": "Mostrar NFT", "tokens.nfts.hidden.label": "Ocultos ({{numHidden}})", "tokens.nfts.link.collection": "Site da coleção", - "tokens.nfts.list.error.load.title": "Não foi possível carregar NFTs", + "tokens.nfts.list.error.load.title": "Não foi possível carregar os NFT", "tokens.nfts.list.none.button": "Receber NFTs", - "tokens.nfts.list.none.description.default": "Para começar, transfira NFTs de outra carteira.", - "tokens.nfts.list.none.description.external": "Quando esta carteira compra ou recebe NFTs, estes são apresentados aqui.", - "tokens.nfts.list.none.title": "Ainda sem NFTs", + "tokens.nfts.list.none.description.default": "Para começar, transfira os NFT de outra carteira.", + "tokens.nfts.list.none.description.external": "Quando esta carteira compra ou recebe NFT, estes são apresentados aqui.", + "tokens.nfts.list.none.title": "Ainda sem NFT", "tokens.selector.button.choose": "Selecionar token", "tokens.selector.button.clear": "Limpar tudo", - "tokens.selector.empty.buy.message": "Compre cripto com um cartão ou transferência bancária para enviar tokens.", + "tokens.selector.empty.buy.message": "Compre cripto com um cartão ou por transferência bancária para enviar tokens.", "tokens.selector.empty.buy.title": "Comprar cripto", "tokens.selector.empty.receive.message": "Transferir tokens de uma bolsa centralizada ou de outra carteira para enviar tokens.", "tokens.selector.empty.receive.title": "Receber tokens", "tokens.selector.empty.title": "Ainda sem tokens", - "tokens.selector.error.load": "Não foi possível carregar os tokens", + "tokens.selector.error.load": "Não foi possível carregar tokens", "tokens.selector.search.empty": "Não foram encontrados resultados para <highlight>{{searchText}}</highlight>", "tokens.selector.search.placeholder": "Pesquisar tokens", "tokens.selector.section.favorite": "Favoritos", @@ -892,17 +885,18 @@ "transaction.action.cancel.description": "Se cancelar esta transação antes de ser processada pela rede, pagará um novo custo de rede em vez do custo original.", "transaction.action.cancel.title": "Cancelar esta transação?", "transaction.action.copy": "Copiar ID da transação", - "transaction.action.copyMoonPay": "Copiar ID da transação MoonPay", + "transaction.action.copyProvider": "Copiar ID da transação {{providerName}}", "transaction.action.view": "Ver {{tokenSymbol}}", "transaction.action.viewEtherscan": "Ver em {{blockExplorerName}}", - "transaction.action.viewMoonPay": "Ver no MoonPay", "transaction.amount.unlimited": "Ilimitado", "transaction.currency.unknown": "token desconhecido", "transaction.date": "Enviado em {{date}}", "transaction.details.dappName": "Aplicação", "transaction.details.from": "De", "transaction.details.networkFee": "Custo da rede", + "transaction.details.swapRate": "Taxa", "transaction.details.transactionId": "ID da transação", + "transaction.details.uniswapFee": "Tarifa ({{ feePercent }}%)", "transaction.network.all": "Todas as redes", "transaction.networkCost.label": "Custo da rede", "transaction.notification.error.cancel": "Não foi possível cancelar a transação", @@ -916,7 +910,7 @@ "transaction.status.approve.successDapp": "Aprovado em {{externalDappName}}", "transaction.status.buy.canceled": "Compra cancelada", "transaction.status.buy.canceling": "A cancelar compra", - "transaction.status.buy.failed": "Falha ao efetuar compra", + "transaction.status.buy.failed": "Falha ao comprar", "transaction.status.buy.pending": "A comprar", "transaction.status.buy.success": "Comprado", "transaction.status.buy.successDapp": "Comprado em {{externalDappName}}", @@ -935,7 +929,7 @@ "transaction.status.purchase.canceled": "Compra cancelada", "transaction.status.purchase.canceling": "A cancelar compra", "transaction.status.purchase.failed": "Falha ao efetuar compra", - "transaction.status.purchase.failedOn": "Falha ao efetuar a compra em {{serviceProvider}}", + "transaction.status.purchase.failedOn": "Falha na compra em {{serviceProvider}}", "transaction.status.purchase.pending": "A comprar", "transaction.status.purchase.pendingOn": "A comprar em {{serviceProvider}}", "transaction.status.purchase.success": "Comprado", @@ -952,13 +946,13 @@ "transaction.status.receive.successFrom": "Recebido de {{serviceProvider}}", "transaction.status.revoke.canceled": "Revogação cancelada", "transaction.status.revoke.canceling": "A cancelar revogação", - "transaction.status.revoke.failed": "Falha ao revogar", + "transaction.status.revoke.failed": "Falha na revogação", "transaction.status.revoke.pending": "A revogar", "transaction.status.revoke.success": "Revogado", "transaction.status.revoke.successDapp": "Revogado em {{externalDappName}}", "transaction.status.sell.canceled": "Venda cancelada", "transaction.status.sell.canceling": "A cancelar venda", - "transaction.status.sell.failed": "Falha ao efetuar venda", + "transaction.status.sell.failed": "Falha na venda", "transaction.status.sell.pending": "A vender", "transaction.status.sell.success": "Vendido", "transaction.status.sell.successDapp": "Vendido em {{externalDappName}}", @@ -990,7 +984,7 @@ "transaction.status.wrap.successDapp": "Embrulhado em {{externalDappName}}", "transaction.summary.received": "{{tokenAmountWithSymbol}} para {{recipientAddress}}", "transaction.summary.sent": "{{tokenAmountWithSymbol}} de {{senderAddress}}", - "transaction.warning.insufficientGas.modal.message": "Necessita de ~{{tokenAmount}} {{tokenSymbol}} (<fiatTokenAmount/>) em {{networkName}} para cobrir o custo de rede desta transação.", + "transaction.warning.insufficientGas.modal.message": "Necessita de ≈{{tokenAmount}} {{tokenSymbol}} (<fiatTokenAmount/>) em {{networkName}} para cobrir o custo de rede desta transação.", "transaction.warning.insufficientGas.modal.title.withNetwork": "{{tokenSymbol}} insuficientes em {{networkName}}", "transaction.warning.insufficientGas.modal.title.withoutNetwork": "{{tokenSymbol}} insuficientes", "transaction.watcher.error.cancel": "Não foi possível cancelar a transação", @@ -1001,14 +995,14 @@ "unitags.banner.button.claim": "Reivindicar agora", "unitags.banner.subtitle": "Crie um perfil web3 personalizado e partilhe facilmente o seu endereço com amigos.", "unitags.banner.title.compact": "<highlight>Reivindique o seu nome de utilizador {{unitagDomain}}</highlight> e crie o seu perfil personalizável.", - "unitags.banner.title.full": "Reivindique o seu nome de utilizador {{unitagDomain}}", + "unitags.banner.title.full": "Reivindicar o seu nome de utilizador {{unitagDomain}}", "unitags.choosePhoto.option.cameraRoll": "Escolher a partir das imagens da câmara", "unitags.choosePhoto.option.nft": "Escolher um NFT", "unitags.choosePhoto.option.remove": "Remover imagem do perfil", "unitags.claim.confirmation.customize": "Personalizar perfil", - "unitags.claim.confirmation.description": "{{unitagAddress}} está pronto para enviar e receber cripto. Continue a construir a sua carteira ao personalizar o seu perfil web3.", + "unitags.claim.confirmation.description": "{{unitagAddress}} está pronto para enviar e receber cripto. Continue a construir a sua carteira personalizando o seu perfil web3.", "unitags.claim.confirmation.success.long": "Pronto!", - "unitags.claim.confirmation.success.short": "Já está!", + "unitags.claim.confirmation.success.short": "já está!", "unitags.claim.error.addressLimit": "Já efetuou o número máximo de alterações ao seu nome de utilizador para este endereço", "unitags.claim.error.appCheck": "Não foi possível reivindicar o nome de utilizador. Tente novamente amanhã.", "unitags.claim.error.avatar": "Não foi possível definir o avatar. Tente novamente mais tarde.", @@ -1019,13 +1013,13 @@ "unitags.claim.error.general": "Não é possível reivindicar o nome de utilizador", "unitags.claim.error.unavailable": "Este nome de utilizador não está disponível", "unitags.claim.error.unknown": "Erro desconhecido", - "unitags.claim.username.default": "seunome", + "unitags.claim.username.default": "oseunome", "unitags.delete.confirm.subtitle": "Está prestes a eliminar o seu nome de utilizador e os dados personalizáveis do seu perfil. Não será possível reivindicá-lo.", - "unitags.delete.confirm.title": "Tem certeza?", + "unitags.delete.confirm.title": "Tem a certeza?", "unitags.editProfile.placeholder": "nome de utilizador", "unitags.editUsername.button.confirm": "Guardar alterações", "unitags.editUsername.confirm.subtitle": "Está prestes a alterar o seu nome de utilizador. Depois de o alterar, nunca mais poderá reivindicá-lo.", - "unitags.editUsername.confirm.title": "Tem certeza?", + "unitags.editUsername.confirm.title": "Tem a certeza?", "unitags.editUsername.title": "Editar nome de utilizador", "unitags.editUsername.warning.default": "Depois de alterar o seu nome de utilizador, nunca mais poderá reivindicá-lo. Só é possível alterá-lo 2 vezes.", "unitags.editUsername.warning.max": "Atingiu o número máximo de 2 alterações do nome de utilizador.", @@ -1046,7 +1040,7 @@ "unitags.onboarding.claimPeriod.description": "Durante um período limitado, o nome de utilizador {{username}} está reservado. Importe a carteira que é titular de {{username}}.eth do ENS para reivindicar este nome de utilizador ou tente novamente após o período de reivindicação.", "unitags.onboarding.claimPeriod.link": "Saiba mais sobre o nosso <highlight>período de reivindicação</highlight>.", "unitags.onboarding.claimPeriod.title": "Período de reivindicação do ENS", - "unitags.onboarding.info.description": "Os nomes de utilizador transformam os endereços 0x complexos em nomes legíveis. Ao reivindicar um nome de utilizador {{unitagDomain}}, poderá facilmente enviar e receber cripto e criar um perfil web3 público.", + "unitags.onboarding.info.description": "Os nomes de utilizador transformam os endereços hexadecimais complexos em nomes legíveis. Ao reivindicar um nome de utilizador {{unitagDomain}}, poderá facilmente enviar e receber cripto e criar um perfil web3 público.", "unitags.onboarding.info.title": "Um endereço simplificado", "unitags.onboarding.profile.subtitle": "Carregue o seu próprio Unicon ou mantenha o seu Unicon exclusivo. Pode sempre alterá-lo mais tarde.", "unitags.onboarding.profile.title": "Escolher uma fotografia de perfil", @@ -1054,17 +1048,17 @@ "unitags.profile.action.edit": "Editar nome de utilizador", "unitags.profile.bio.label": "Biografia", "unitags.profile.bio.placeholder": "Escreva uma biografia para o seu perfil", - "unitags.profile.links.twitter": "Twitter", + "unitags.profile.links.twitter": "X", "unitags.username.error.chars": "Os nomes de utilizador só podem conter letras e números", "unitags.username.error.max": "Os nomes de utilizador não podem ter mais de {{number}} carateres", "unitags.username.error.min": "Os nomes de utilizador devem ter, pelo menos, {{number}} carateres", "unitags.username.error.uppercase": "Os nomes de utilizador só podem conter letras minúsculas e números", "uwulink.error.insufficientTokens": "{{tokenSymbol}} insuficientes em {{chain}}", "walletConnect.dapps.connection": "<highlight>Ligado a </highlight>{{dappNameOrUrl}}", - "walletConnect.dapps.empty.description": "Ligar a uma aplicação ao ler um código através do WalletConnect", + "walletConnect.dapps.empty.description": "Ligar a uma aplicação lendo um código através do WalletConnect", "walletConnect.dapps.manage.empty.title": "Nenhuma aplicação ligada", "walletConnect.dapps.manage.title": "Gerir ligações", - "walletConnect.error.connection.message": "A carteira Uniswap é atualmente compatível com {{chainNames}}. Utilize apenas \"{{dappName}}\" nestas cadeias", + "walletConnect.error.connection.message": "A carteira Uniswap suporta atualmente {{chainNames}}. Utilize apenas \"{{dappName}}\" nestas cadeias", "walletConnect.error.connection.title": "Erro de ligação", "walletConnect.error.general.message": "Ocorreu um problema com o WalletConnect. Tente novamente", "walletConnect.error.general.title": "Erro no WalletConnect", @@ -1074,26 +1068,28 @@ "walletConnect.error.unsupported.title": "Código QR inválido", "walletConnect.error.unsupportedV1.message": "O WalletConnect v1 já não é suportado. A aplicação à qual se está a tentar ligar tem de ser atualizada para o WalletConnect v2.", "walletConnect.error.unsupportedV1.title": "Código QR inválido", - "walletConnect.error.uwu.scan": "Ocorreu um problema com a leitura deste código QR.", + "walletConnect.error.uwu.scan": "Ocorreu um problema com a digitalização deste código QR.", "walletConnect.error.uwu.title": "Erro de ligação UwU", "walletConnect.error.uwu.unsupported": "Este código QR não é suportado.", "walletConnect.pending.button.connect": "Ligar", - "walletConnect.pending.button.scrollDown": "Deslocar para baixo para ligar", + "walletConnect.pending.button.scrollDown": "Deslocar para baixo para se ligar", "walletConnect.pending.switchAccount": "Mudar de conta", "walletConnect.pending.switchNetwork": "Mudar de rede", - "walletConnect.pending.title": "Ligar a {{dappName}}", + "walletConnect.pending.title": "Ligar-se a {{dappName}}", "walletConnect.permissions.networks": "Redes", "walletConnect.permissions.option.transferAssets": "Transferir os seus ativos sem consentimento", "walletConnect.permissions.option.viewTokenBalances": "Ver os saldos dos seus tokens", - "walletConnect.permissions.option.viewWalletAddress": "Ver o endereço da carteira", + "walletConnect.permissions.option.viewWalletAddress": "Ver o endereço da sua carteira", "walletConnect.permissions.title": "Permissões do site", - "walletConnect.request.button.scrollDown": "Desloque-se para baixo para assinar", + "walletConnect.request.button.scrollDown": "Deslocar-se para baixo para assinar", "walletConnect.request.button.sign": "Assinar", "walletConnect.request.details.label.function": "Função", "walletConnect.request.details.label.sending": "A enviar", + "walletConnect.request.details.label.token": "Token", + "walletConnect.request.details.label.tokens": "Tokens", "walletConnect.request.error.insufficientFunds": "Não tem {{currencySymbol}} suficientes para concluir esta transação.", "walletConnect.request.error.network": "Erro de ligação à Internet ou à rede", - "walletConnect.request.warning.general.message": "Atenção: esta mensagem pode transferir ativos", + "walletConnect.request.warning.general.message": "Atenção: esta mensagem poderá transferir ativos", "walletConnect.request.warning.message": "Para assinar mensagens ou transações, terá de importar a frase de recuperação da carteira.", - "walletConnect.request.warning.title": "Esta carteira está no modo só de visualização" + "walletConnect.request.warning.title": "Esta carteira está no modo só para visualização" } diff --git a/packages/uniswap/src/i18n/locales/translations/zh-CN.json b/packages/uniswap/src/i18n/locales/translations/zh-CN.json index 414afdd2d63..1268a3f0642 100644 --- a/packages/uniswap/src/i18n/locales/translations/zh-CN.json +++ b/packages/uniswap/src/i18n/locales/translations/zh-CN.json @@ -41,7 +41,7 @@ "account.recoveryPhrase.remove.import.description": "您一次只能存储一个恢复短语。要继续导入新的恢复短语,您需要从此设备删除您当前的恢复短语和任何相关的钱包。", "account.recoveryPhrase.remove.initial.description": "这样将从此设备删除您的钱包和恢复短语。", "account.recoveryPhrase.remove.initial.title": "您正在删除 <highlight>{{walletName}}</highlight>", - "account.recoveryPhrase.remove.mnemonic.description": "它与 {{walletName}} 共用相同的恢复短语。将一直存储您的恢复短语,直到您删除所有剩余的钱包为止。", + "account.recoveryPhrase.remove.mnemonic.description": "它与 {{walletNames, list}} 共用相同的恢复短语。将一直存储您的恢复短语,直到您删除所有剩余的钱包为止。", "account.recoveryPhrase.subtitle.import": "您的恢复短语将仅本地存储在您的设备上。", "account.recoveryPhrase.subtitle.restoring": "在下面输入您的恢复短语或重试搜索备份。", "account.recoveryPhrase.title.import": "输入您的恢复短语", @@ -85,6 +85,8 @@ "account.wallet.watch.message": "通过添加仅供查看的钱包,可试用该应用或跟踪钱包。但无法兑换或发送资金。", "account.wallet.watch.placeholder": "ENS 或地址", "account.wallet.watch.title": "输入钱包地址", + "common.action.go": "开始", + "common.action.swipe": "滑动", "common.button.accept": "接受", "common.button.back": "返回", "common.button.buy": "购买", @@ -123,6 +125,8 @@ "common.button.setup": "设置", "common.button.share": "分享", "common.button.show": "显示", + "common.button.showLess": "收起", + "common.button.showMore": "展开", "common.button.sign": "签名", "common.button.skip": "跳过", "common.button.swap": "兑换", @@ -132,7 +136,6 @@ "common.button.yes": "是的", "common.card.error.description": "出现了问题", "common.card.error.title": "糟糕!出现了问题。", - "common.endAdornment": "和", "common.error.general": "出现了问题。", "common.input.password.confirm": "确认密码", "common.input.password.error.mismatch": "密码不匹配", @@ -175,8 +178,8 @@ "currency.usd": "美元", "currency.vnd": "越南盾", "dapp.request.approve.action": "批准", - "dapp.request.approve.fallbackTitle": "批准此站点访问令牌", - "dapp.request.approve.helptext": "允许此站点访问并使用该令牌进行交易。确保您信任此网站。", + "dapp.request.approve.fallbackTitle": "批准此站点访问代币", + "dapp.request.approve.helptext": "允许此站点访问并使用该代币进行交易。确保您信任此网站。", "dapp.request.approve.label": "钱包", "dapp.request.approve.title": "批准访问 {{tokenSymbol}}", "dapp.request.base.title": "交易请求", @@ -242,8 +245,6 @@ "extension.connection.popupWithButton": "您的钱包未连接到此网站。", "extension.connection.titleConnected": "已连接", "extension.connection.titleNotConnected": "未连接", - "extension.feedback.description": "告诉我们如何改进 — 申请功能、报告错误或任何其他情况。", - "extension.feedback.title": "我们想要获得您的反馈", "extension.lock.button.forgot": "忘记密码?", "extension.lock.button.reset": "重置钱包", "extension.lock.button.submit": "解锁", @@ -276,6 +277,7 @@ "fiatOnRamp.error.unsupported": "地区不支持", "fiatOnRamp.error.usd": "只能以美元购买", "fiatOnRamp.quote.advice": "您将前往提供商的门户查看交易的相关费用。", + "fiatOnRamp.quote.type.list": "{{optionsList}}以及其他选项", "fiatOnRamp.quote.type.other": "其他选项", "fiatOnRamp.quote.type.recent": "最近使用", "fiatOnRamp.region.placeholder": "按国家或地区搜索", @@ -292,12 +294,9 @@ "home.activity.empty.title": "还没有活动", "home.activity.error.load": "未能加载活动", "home.activity.title": "活动", - "home.banner.extension.confirm.beta": "体验 Beta 版", - "home.banner.extension.confirm.default": "下载", - "home.banner.extension.message.beta": "抢先在 Web 浏览器上试用 Uniswap 扩展程序", - "home.banner.extension.message.default": "下载 Chrome 以从桌面访问此钱包", - "home.banner.extension.title": "这里是 Uniswap 扩展程序", "home.banner.offline": "您处于离线模式", + "home.explore.footer": "点击此处探索数千种代币、NFT 等", + "home.explore.title": "浏览代币", "home.extension.error": "加载账户时出错", "home.feed.empty.description": "当您收藏的钱包进行交易时,交易将出现在此处。", "home.feed.empty.title": "还没有活动", @@ -305,15 +304,9 @@ "home.feed.title": "订阅源", "home.label.buy": "购买", "home.label.receive": "接收", - "home.label.scan": "扫描", + "home.label.scan": "扫码", "home.label.send": "发送", "home.label.swap": "兑换", - "home.modal.getExtension.beta.step3": "3.输入您的用户名以获取访问权限", - "home.modal.getExtension.beta.title": "体验 Uniswap 扩展程序 Beta 版", - "home.modal.getExtension.ga.step1": "1.在 Chrome 桌面版上访问 <highlight>uniswap.org/ext</highlight>", - "home.modal.getExtension.ga.step2": "2.将 Uniswap 扩展程序添加到您的浏览器", - "home.modal.getExtension.ga.step3": "3.使用 Uniswap 移动应用扫描二维码以导入您的钱包", - "home.modal.getExtension.ga.title": "下载 Uniswap 扩展程序", "home.nfts.title": "NFT", "home.tokens.empty.action.buy.description": "用借记卡或银行账户购买加密货币。", "home.tokens.empty.action.buy.title": "用卡购买加密货币", @@ -440,13 +433,17 @@ "onboarding.complete.pin.description": "单击图钉图标将 Uniswap 扩展程序添加到您的工具栏。", "onboarding.complete.pin.title": "固定 Uniswap 扩展程序", "onboarding.complete.title": "一切就绪", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "下载移动应用程序以获取用户名", - "onboarding.extension.getOnTheBetaWaitlist.title": "加入 Beta 版候补名单", "onboarding.extension.password.subtitle": "需要此项才能将钱包解锁和访问恢复短语", "onboarding.extension.password.title.default": "创建密码", "onboarding.extension.password.title.reset": "重置您的密码", + "onboarding.extension.unsupported.android.description": "Uniswap 扩展程序仅与桌面上的 Chrome 兼容。", + "onboarding.extension.unsupported.android.title": "移动版 Chrome 尚不受支持", "onboarding.extension.unsupported.description": "Uniswap 扩展程序目前仅与 Chrome 兼容。", "onboarding.extension.unsupported.title": "该浏览器尚不受支持", + "onboarding.home.intro.fund.description": "购买加密货币或从其他账户转移加密货币,为您的钱包充值。", + "onboarding.home.intro.fund.title": "获取你的第一个代币", + "onboarding.home.intro.welcome.description": "完成钱包设置后,几秒钟内即可开始交换。", + "onboarding.home.intro.welcome.title": "欢迎来到 Uniswap", "onboarding.import.error.invalidWords_one": "有 1 个单词无效或拼写错误", "onboarding.import.error.invalidWords_other": "有 {{count}} 个单词无效或拼写错误", "onboarding.import.method.import.message": "从另一个加密货币钱包中输入您的恢复短语", @@ -472,13 +469,6 @@ "onboarding.intro.button.alreadyHave": "我已有钱包", "onboarding.intro.mobileScan.button": "扫描二维码导入", "onboarding.intro.mobileScan.title": "有 Uniswap 应用程序吗?", - "onboarding.introBetaWaitlist.button.checkEligibility": "检查资格", - "onboarding.introBetaWaitlist.button.letsGo": "我们开始吧", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "在下面输入您的 <highlight>uni.eth</highlight> 用户名,查看您是否有资格体验 Beta 版。", - "onboarding.introBetaWaitlist.eligible.tagline": "欢迎使用 Beta 版 — 您是首批试用 Uniswap 扩展程序的用户之一。", - "onboarding.introBetaWaitlist.eligible.title": "您已从候补名单中移除!", - "onboarding.introBetaWaitlist.ineligibleExplanation": "您仍在候补名单上。当您符合资格时,我们会在 Uniswap 移动应用程序中通知您!", - "onboarding.introBetaWaitlist.unitagPlaceholder": "用户名", "onboarding.landing.button.add": "添加现有钱包", "onboarding.landing.button.create": "创建钱包", "onboarding.notification.permission.message": "要接收通知,请在您设备的设置中开启 Uniswap 钱包的通知。", @@ -535,10 +525,6 @@ "qrScanner.recipient.action.show": "显示我的二维码", "qrScanner.recipient.error.message": "确保您扫描的以太坊地址二维码有效,然后再重试。", "qrScanner.recipient.error.title": "二维码无效", - "qrScanner.recipient.input.placeholder": "搜索 ENS 或地址", - "qrScanner.recipient.label.send": "发送", - "qrScanner.recipient.results.empty": "未找到结果", - "qrScanner.recipient.results.error": "您键入的地址不存在,或者拼写错误。", "qrScanner.request.message.unavailable": "未找到消息。", "qrScanner.request.method.default": "请求来自 {{dappNameOrUrl}}", "qrScanner.request.method.signature": "签名请求来自 {{dappNameOrUrl}}", @@ -572,8 +558,12 @@ "send.gas.error.title": "不适用", "send.gas.networkCost.title": "网络费用", "send.input.token.balance.title": "余额:{{balance}} {{symbol}}", + "send.recipient.header": "选择接受者", + "send.recipient.input.placeholder": "搜索 ENS 或地址", "send.recipient.previous_one": "以前有 1 笔转账", "send.recipient.previous_other": "以前有 {{count}} 笔转账", + "send.recipient.results.empty": "未找到结果", + "send.recipient.results.error": "您键入的地址不存在,或者拼写错误。", "send.recipient.section.favorite": "收藏的钱包", "send.recipient.section.recent": "最近", "send.recipient.section.search": "搜索结果", @@ -609,6 +599,9 @@ "send.warning.modal.button.cta.blocking": "确定", "send.warning.modal.button.cta.cancel": "取消", "send.warning.modal.button.cta.confirm": "确认", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "用户名", + "send.warning.newAddress.details.walletAddress": "钱包地址", "send.warning.newAddress.message": "您以前未与此地址进行过交易。请确认地址正确无误后再继续。", "send.warning.newAddress.title": "新地址", "send.warning.restore": "将您的钱包复原以发送", @@ -705,7 +698,6 @@ "settings.setting.biometrics.warning.message.ios": "如果您不开启 {{biometricsMethod}},任何有权访问您设备的人均可以打开 Uniswap 钱包进行交易。", "settings.setting.biometrics.warning.title": "是否确定?", "settings.setting.currency.title": "当地货币", - "settings.setting.giveFeedback.title": "分享反馈", "settings.setting.helpCenter.title": "帮助中心", "settings.setting.language.button.navigate": "前往设置", "settings.setting.language.description.extension": "Uniswap 默认采用您的系统语言设置。要更改您的首选语言,请转到系统设置。", @@ -738,8 +730,6 @@ "swap.button.unwrap": "拆封", "swap.button.view": "查看交易", "swap.button.wrap": "封装", - "swap.details.action.less": "收起", - "swap.details.action.more": "展开", "swap.details.feeOnTransfer": "{{tokenSymbol}} 费用", "swap.details.newQuote.input": "新输入", "swap.details.newQuote.output": "新输出", @@ -782,27 +772,28 @@ "swap.settings.title": "兑换设置", "swap.slippage.settings.title": "滑点设置", "swap.warning.expectedFailure": "这笔交易预计将失败", - "swap.warning.feeOnTransfer.message": "在购买或出售某些代币时收取由代币发行者设置的手续费。Uniswap 不从这些手续费分成。", + "swap.warning.feeOnTransfer.message": "在购买或出售某些代币时收取由代币发行者设置的费用。Uniswap 不从这些费用分成。", "swap.warning.feeOnTransfer.title": "为什么有额外费用?", "swap.warning.insufficientBalance.button": "{{currencySymbol}} 不足", "swap.warning.insufficientBalance.title": "您的 {{currencySymbol}} 不足", "swap.warning.insufficientGas.button": "{{currencySymbol}} 不足", + "swap.warning.insufficientGas.button.buy": "买 {{ tokenSymbol }}", "swap.warning.insufficientGas.message.withNetwork": "<highlight>{{currencySymbol}}({{networkName}}</highlight> 上)不足,无法兑换", "swap.warning.insufficientGas.message.withoutNetwork": "<highlight>{{currencySymbol}}</highlight> 不足,无法兑换", "swap.warning.insufficientGas.title": "您的 {{currencySymbol}} 不足以支付网络费用", "swap.warning.lowLiquidity.message": "当前这些代币之间流动性不足,无法执行兑换。请稍后重试或选择其他代币。", "swap.warning.lowLiquidity.title": "流动性不足", - "swap.warning.networkFee.allow": "允许 {{ inputTokenSymbol }} (一次)", - "swap.warning.networkFee.highRelativeToValue": "网络费用超过您交易总价值的 10%。", - "swap.warning.networkFee.message": "这是在区块链上处理您的交易的费用。Uniswap 不从这些手续费分成。", - "swap.warning.networkFee.message.uniswapX": "这是在区块链上处理您的交易的费用。Uniswap 不从这些手续费分成。<gradient>UniswapX</gradient> 聚合流动资金来源以获得更好的价格和燃料免费兑换。", + "swap.warning.networkFee.allow": "允许 {{ inputTokenSymbol }}(一次)", + "swap.warning.networkFee.highRelativeToValue": "网络费用超过您交易总价值的 10%。", + "swap.warning.networkFee.message": "这是在区块链上处理您的交易的费用。Uniswap 不从这些费用分成。", + "swap.warning.networkFee.message.uniswapX": "这是在区块链上处理您的交易的费用。Uniswap 不从这些费用分成。<gradient>UniswapX</gradient> 聚合流动资金来源以获得更好的价格和燃料免费兑换。", "swap.warning.networkFee.wrap": "封装 ETH", "swap.warning.offline.message": "您可能失去了互联网连接或网络可能出现故障。请检查您的互联网连接,然后重试。", "swap.warning.offline.title": "您已离线", "swap.warning.priceImpact.message": "由于 {{outputCurrencySymbol}} 当前可用的流动性数量,您尝试兑换的 {{inputCurrencySymbol}} 越多,您收到的 {{outputCurrencySymbol}} 就越少。", "swap.warning.priceImpact.title": "高价格影响 ({{priceImpactValue}})", "swap.warning.queuedOrder.appClosed": "由于您关闭了应用程序,因此您的交易未提交。", - "swap.warning.queuedOrder.approvalFailed": "由于令牌批准失败,您的交易未提交。", + "swap.warning.queuedOrder.approvalFailed": "由于代币批准失败,您的交易未提交。", "swap.warning.queuedOrder.stale": "由于您关闭了应用程序或审批时间过长,您的交易未提交。", "swap.warning.queuedOrder.submissionFailed": "提交交易时出现问题。", "swap.warning.queuedOrder.title": "兑换已取消", @@ -812,9 +803,9 @@ "swap.warning.rateLimit.title": "超出费率限制", "swap.warning.router.message": "您可能失去了连接或网络可能出现故障。如果问题仍然存在,请稍后重试。", "swap.warning.router.title": "目前无法完成这笔交易", - "swap.warning.uniswapFee.message.default": "收取费用是为了确保您获得 Uniswap 的最佳体验。此兑换无关联的手续费。", - "swap.warning.uniswapFee.message.included": "收取手续费是为了确保获得 Uniswap 的最佳体验,并且已计入此报价中。", - "swap.warning.uniswapFee.title": "兑换手续费", + "swap.warning.uniswapFee.message.default": "收取费用是为了确保您获得 Uniswap 的最佳体验。此兑换无关联的费用。", + "swap.warning.uniswapFee.message.included": "收取费用是为了确保获得 Uniswap 的最佳体验,并且已计入此报价中。", + "swap.warning.uniswapFee.title": "兑换费用", "swap.warning.viewOnly.message": "您需要通过恢复短语导入此钱包才能兑换代币。", "token.balances.main": "您的余额", "token.balances.other": "其他网络上的余额", @@ -846,6 +837,8 @@ "token.stats.translation.original": "显示原文", "token.stats.translation.translate": "翻译为 {{language}}", "token.stats.volume": "24 小时交易量", + "token.zeroNativeBalance.description": "要获得 {{ tokenSymbol }},您首先需要 {{ nativeTokenSymbol }} 支付网络费用。首先通过 {{ nativeTokenSymbol }} 为您的钱包充值。", + "token.zeroNativeBalance.title": "你需要 {{ nativeTokenName }}", "tokens.action.hide": "隐藏代币", "tokens.action.unhide": "取消隐藏代币", "tokens.hidden.label": "隐藏 ({{numHidden}})", @@ -892,17 +885,18 @@ "transaction.action.cancel.description": "如果您在网络处理这笔交易之前取消,则您将支付新的网络费用而非原有费用。", "transaction.action.cancel.title": "是否取消这笔交易?", "transaction.action.copy": "复制交易 ID", - "transaction.action.copyMoonPay": "复制 MoonPay 交易 ID", + "transaction.action.copyProvider": "复制 {{providerName}} 交易ID", "transaction.action.view": "查看 {{tokenSymbol}}", "transaction.action.viewEtherscan": "在 {{blockExplorerName}} 上查看", - "transaction.action.viewMoonPay": "在 MoonPay 上查看", "transaction.amount.unlimited": "无限", "transaction.currency.unknown": "未知的代币", "transaction.date": "{{date}} 提交", "transaction.details.dappName": "应用程序", "transaction.details.from": "从", "transaction.details.networkFee": "网络费用", + "transaction.details.swapRate": "费率", "transaction.details.transactionId": "交易 ID", + "transaction.details.uniswapFee": "费用({{ feePercent }}%)", "transaction.network.all": "所有网络", "transaction.networkCost.label": "网络费用", "transaction.notification.error.cancel": "无法取消交易", @@ -1091,6 +1085,8 @@ "walletConnect.request.button.sign": "签名", "walletConnect.request.details.label.function": "函数", "walletConnect.request.details.label.sending": "正在发送", + "walletConnect.request.details.label.token": "代币", + "walletConnect.request.details.label.tokens": "代币", "walletConnect.request.error.insufficientFunds": "您的 {{currencySymbol}} 不足,无法完成这笔交易。", "walletConnect.request.error.network": "互联网或网络连接错误", "walletConnect.request.warning.general.message": "请注意:此消息可能会转移资产", diff --git a/packages/uniswap/src/i18n/locales/translations/zh-TW.json b/packages/uniswap/src/i18n/locales/translations/zh-TW.json index cc221daa08c..fc8289c8fbe 100644 --- a/packages/uniswap/src/i18n/locales/translations/zh-TW.json +++ b/packages/uniswap/src/i18n/locales/translations/zh-TW.json @@ -41,7 +41,7 @@ "account.recoveryPhrase.remove.import.description": "您一次只能儲存一個恢復短語。若要繼續匯入新的恢復短語,您需要移除此裝置現有的恢復短語和任何相關錢包。", "account.recoveryPhrase.remove.initial.description": "若執行此操作,將會從裝置中移除您的錢包以及恢復短語。", "account.recoveryPhrase.remove.initial.title": "您即將移除 <highlight>{{walletName}}</highlight>", - "account.recoveryPhrase.remove.mnemonic.description": "該錢包與 {{walletName}} 共用相同的恢復短語。您的恢復短語將保留至您刪除所有剩餘錢包為止。", + "account.recoveryPhrase.remove.mnemonic.description": "該錢包與 {{walletNames, list}} 共用相同的恢復短語。您的恢復短語將保留至您刪除所有剩餘錢包為止。", "account.recoveryPhrase.subtitle.import": "您的恢復短語只會以本機形式儲存在裝置上。", "account.recoveryPhrase.subtitle.restoring": "請在下方輸入您的恢復短語,或嘗試再次搜尋備份。", "account.recoveryPhrase.title.import": "輸入您的恢復短語", @@ -85,6 +85,8 @@ "account.wallet.watch.message": "新增僅供檢視的錢包,可讓您試用應用程式或追蹤錢包。您無法兌換或傳送資金。", "account.wallet.watch.placeholder": "ENS 或地址", "account.wallet.watch.title": "輸入錢包地址", + "common.action.go": "前往", + "common.action.swipe": "滑動", "common.button.accept": "接受", "common.button.back": "返回", "common.button.buy": "購買", @@ -123,6 +125,8 @@ "common.button.setup": "設定", "common.button.share": "分享", "common.button.show": "展示", + "common.button.showLess": "顯示較少", + "common.button.showMore": "顯示更多", "common.button.sign": "簽署", "common.button.skip": "跳過", "common.button.swap": "兌換", @@ -132,7 +136,6 @@ "common.button.yes": "是的", "common.card.error.description": "發生錯誤", "common.card.error.title": "糟糕!發生錯誤。", - "common.endAdornment": "和", "common.error.general": "發生錯誤。", "common.input.password.confirm": "確認密碼", "common.input.password.error.mismatch": "密碼不相符", @@ -242,8 +245,6 @@ "extension.connection.popupWithButton": "您的錢包未連線到此網站。", "extension.connection.titleConnected": "已連線", "extension.connection.titleNotConnected": "未連線", - "extension.feedback.description": "告訴我們如何改進——請求功能、報告錯誤或其他任何事情。", - "extension.feedback.title": "我們很樂意收到您的回饋", "extension.lock.button.forgot": "忘記密碼?", "extension.lock.button.reset": "重設錢包", "extension.lock.button.submit": "解鎖", @@ -276,6 +277,7 @@ "fiatOnRamp.error.unsupported": "非支援地區", "fiatOnRamp.error.usd": "只能以美元購買", "fiatOnRamp.quote.advice": "您將繼續前往提供者的入口網站查看交易相關費用。", + "fiatOnRamp.quote.type.list": "{{optionsList}},以及其他選項", "fiatOnRamp.quote.type.other": "其他選項", "fiatOnRamp.quote.type.recent": "最近使用過", "fiatOnRamp.region.placeholder": "依國家或地區搜尋", @@ -288,19 +290,16 @@ "forceUpgrade.title": "更新應用程式以繼續", "home.activity.empty.button": "接收代幣或 NFT", "home.activity.empty.description.default": "當您核准、交易或轉移代幣或 NFT 時,此處會顯示您的交易。", - "home.activity.empty.description.external": "當此錢包進行交易時,會顯示在此處。", + "home.activity.empty.description.external": "當此錢包進行交易時,交易會顯示在這裡。", "home.activity.empty.title": "尚無活動", "home.activity.error.load": "無法載入活動", "home.activity.title": "活動", - "home.banner.extension.confirm.beta": "加入 Beta 版", - "home.banner.extension.confirm.default": "下載", - "home.banner.extension.message.beta": "在您的網頁瀏覽器上搶先體驗 Uniswap Extension", - "home.banner.extension.message.default": "在 Chrome 上下載以從桌面存取此錢包", - "home.banner.extension.title": "由此處取得 Uniswap Extension", "home.banner.offline": "您處於離線模式", + "home.explore.footer": "點擊此處,探索數千種代幣、NFT 等", + "home.explore.title": "探索代幣", "home.extension.error": "載入帳戶時發生錯誤", "home.feed.empty.description": "當您的常用錢包進行交易時,此處會顯示交易。", - "home.feed.empty.title": "尚無活動", + "home.feed.empty.title": "尚未有活動", "home.feed.error": "無法載入活動", "home.feed.title": "動態消息", "home.label.buy": "購買", @@ -308,12 +307,6 @@ "home.label.scan": "掃描", "home.label.send": "傳送", "home.label.swap": "兌換", - "home.modal.getExtension.beta.step3": "3.輸入您的使用者名稱以取得存取權限", - "home.modal.getExtension.beta.title": "加入 Uniswap Extension Beta 版", - "home.modal.getExtension.ga.step1": "1.請透過 Chrome 桌機版造訪 <highlight>uniswap.org/ext</highlight>", - "home.modal.getExtension.ga.step2": "2.將 Uniswap Extension 新增到您的瀏覽器", - "home.modal.getExtension.ga.step3": "3.使用 Uniswap 行動應用程式掃描 QR 碼,以匯入錢包", - "home.modal.getExtension.ga.title": "下載 Uniswap Extension", "home.nfts.title": "NFT", "home.tokens.empty.action.buy.description": "使用簽帳金融卡或銀行帳戶購買加密貨幣。", "home.tokens.empty.action.buy.title": "用卡片購買加密貨幣", @@ -440,13 +433,17 @@ "onboarding.complete.pin.description": "點擊圖釘圖示,將 Uniswap Extension 新增到工具列。", "onboarding.complete.pin.title": "釘選 Uniswap Extension", "onboarding.complete.title": "您已準備就緒", - "onboarding.extension.getOnTheBetaWaitlist.subtitle": "下載行動應用程式,以註冊使用者名稱", - "onboarding.extension.getOnTheBetaWaitlist.title": "進入 Beta 版候補名單", "onboarding.extension.password.subtitle": "解鎖錢包和存取恢復短語時,需要使用此資料", "onboarding.extension.password.title.default": "建立密碼", "onboarding.extension.password.title.reset": "重設您的密碼", + "onboarding.extension.unsupported.android.description": "Uniswap 擴充功能僅與桌面版 Chrome 相容。", + "onboarding.extension.unsupported.android.title": "行動裝置上的 Chrome 尚不受支援", "onboarding.extension.unsupported.description": "Uniswap 擴充功能目前僅與 Chrome 相容。", "onboarding.extension.unsupported.title": "尚不支援此瀏覽器", + "onboarding.home.intro.fund.description": "透過購買加密貨幣或從其他帳戶轉帳來為您的錢包充值。", + "onboarding.home.intro.fund.title": "獲取您的第一個代幣", + "onboarding.home.intro.welcome.description": "完成錢包設定後即可在幾秒鐘內開始兌換。", + "onboarding.home.intro.welcome.title": "歡迎來到 Uniswap", "onboarding.import.error.invalidWords_one": "有 1 個字詞無效或拼錯", "onboarding.import.error.invalidWords_other": "有 {{count}} 個字詞無效或拼錯", "onboarding.import.method.import.message": "輸入另一個加密貨幣錢包的恢復短語", @@ -472,13 +469,6 @@ "onboarding.intro.button.alreadyHave": "我已經有錢包了", "onboarding.intro.mobileScan.button": "掃描 QR 碼以匯入", "onboarding.intro.mobileScan.title": "有 Uniswap 應用程式嗎?", - "onboarding.introBetaWaitlist.button.checkEligibility": "檢查資格", - "onboarding.introBetaWaitlist.button.letsGo": "開始吧", - "onboarding.introBetaWaitlist.checkEligibilityInstructions": "請在下方輸入您的 <highlight>uni.eth</highlight> 使用者名稱,確認您是否有資格參加 Beta 版。", - "onboarding.introBetaWaitlist.eligible.tagline": "歡迎使用 Beta 版 — 您是最早嘗試 Uniswap Extension 的使用者之一。", - "onboarding.introBetaWaitlist.eligible.title": "您已從候補名單中移除!", - "onboarding.introBetaWaitlist.ineligibleExplanation": "您仍在候補名單上。當您符合資格時,我們將在 Uniswap 行動應用程式中通知您!", - "onboarding.introBetaWaitlist.unitagPlaceholder": "使用者名稱", "onboarding.landing.button.add": "新增現有錢包", "onboarding.landing.button.create": "創建錢包", "onboarding.notification.permission.message": "若要接收通知,請在裝置設定中開啟 Uniswap Wallet 的通知。", @@ -535,10 +525,6 @@ "qrScanner.recipient.action.show": "顯示我的 QR 碼", "qrScanner.recipient.error.message": "請確認您掃描的是有效的以太坊地址 QR 代碼,然後再試一次。", "qrScanner.recipient.error.title": "QR 碼無效", - "qrScanner.recipient.input.placeholder": "搜尋 ENS 或地址", - "qrScanner.recipient.label.send": "傳送", - "qrScanner.recipient.results.empty": "找不到結果", - "qrScanner.recipient.results.error": "您輸入的地址不存在或拼字錯誤。", "qrScanner.request.message.unavailable": "找不到訊息。", "qrScanner.request.method.default": "請求來自 {{dappNameOrUrl}}", "qrScanner.request.method.signature": "簽名請求來自 {{dappNameOrUrl}}", @@ -572,8 +558,12 @@ "send.gas.error.title": "N/A", "send.gas.networkCost.title": "網路費用", "send.input.token.balance.title": "餘額:{{balance}} {{symbol}}", + "send.recipient.header": "選擇收件人", + "send.recipient.input.placeholder": "搜尋 ENS 或地址", "send.recipient.previous_one": "1 筆先前的轉帳", "send.recipient.previous_other": "{{count}} 筆先前的轉帳", + "send.recipient.results.empty": "找不到結果", + "send.recipient.results.error": "您輸入的地址不存在或拼字錯誤。", "send.recipient.section.favorite": "常用錢包", "send.recipient.section.recent": "最近的", "send.recipient.section.search": "搜尋結果", @@ -609,6 +599,9 @@ "send.warning.modal.button.cta.blocking": "確認", "send.warning.modal.button.cta.cancel": "取消", "send.warning.modal.button.cta.confirm": "確認", + "send.warning.newAddress.details.ENS": "ENS", + "send.warning.newAddress.details.username": "使用者名稱", + "send.warning.newAddress.details.walletAddress": "錢包地址", "send.warning.newAddress.message": "您不曾使用此地址進行交易。請先確認地址正確,然後再繼續。", "send.warning.newAddress.title": "新地址", "send.warning.restore": "復原錢包以傳送", @@ -642,7 +635,7 @@ "settings.action.lock": "鎖定錢包", "settings.action.privacy": "隱私權政策", "settings.action.terms": "服務條款", - "settings.footer": "用愛打造,\nUniswap 團隊🦄", + "settings.footer": "用愛打造,\nUniswap 團隊 🦄", "settings.screen.appearance.title": "外觀", "settings.section.about": "關於", "settings.section.preferences": "喜好設定", @@ -703,9 +696,8 @@ "settings.setting.biometrics.unavailable.title.ios": "未設定 {{biometricsMethod}}", "settings.setting.biometrics.warning.message.android": "如果沒有啟用生物特徵辨識功能,任何有權存取您裝置的人都可以開啟 Uniswap Wallet 並進行交易。", "settings.setting.biometrics.warning.message.ios": "如果沒有啟用 {{biometricsMethod}},任何有權存取您裝置的人都可以開啟 Uniswap Wallet 並進行交易。", - "settings.setting.biometrics.warning.title": "確定繼續?", + "settings.setting.biometrics.warning.title": "您確定要繼續嗎?", "settings.setting.currency.title": "當地貨幣", - "settings.setting.giveFeedback.title": "分享意見回饋", "settings.setting.helpCenter.title": "說明中心", "settings.setting.language.button.navigate": "前往設定", "settings.setting.language.description.extension": "Uniswap 預設為您的系統語言設定。若要變更您的慣用語言,請前往系統設定。", @@ -738,8 +730,6 @@ "swap.button.unwrap": "拆封", "swap.button.view": "檢視交易", "swap.button.wrap": "封裝", - "swap.details.action.less": "顯示較少", - "swap.details.action.more": "顯示更多", "swap.details.feeOnTransfer": "{{tokenSymbol}} 費用", "swap.details.newQuote.input": "新輸入", "swap.details.newQuote.output": "新輸出", @@ -787,6 +777,7 @@ "swap.warning.insufficientBalance.button": "{{currencySymbol}} 不足", "swap.warning.insufficientBalance.title": "您的 {{currencySymbol}} 不足", "swap.warning.insufficientGas.button": "{{currencySymbol}} 不足", + "swap.warning.insufficientGas.button.buy": "買 {{ tokenSymbol }}", "swap.warning.insufficientGas.message.withNetwork": "<highlight>{{networkName}}</highlight> 上的 {{currencySymbol}} 不足,無法兌換", "swap.warning.insufficientGas.message.withoutNetwork": "<highlight>{{currencySymbol}}</highlight>不足,無法兌換", "swap.warning.insufficientGas.title": "您的 {{currencySymbol}} 不足,無法支付網路費用", @@ -846,6 +837,8 @@ "token.stats.translation.original": "顯示原始內容", "token.stats.translation.translate": "翻譯成 {{language}}", "token.stats.volume": "24 小時成交量", + "token.zeroNativeBalance.description": "若要取得 {{ tokenSymbol }},您需要 {{ nativeTokenSymbol }} 以支付網路費用。先使用 {{ nativeTokenSymbol }} 為您的錢包加值。", + "token.zeroNativeBalance.title": "你需要 {{ nativeTokenName }}", "tokens.action.hide": "隱藏代幣", "tokens.action.unhide": "取消隱藏代幣", "tokens.hidden.label": "隱藏 ({{numHidden}})", @@ -892,17 +885,18 @@ "transaction.action.cancel.description": "如果您在網路處理該交易之前取消該交易,您將支付新的網路費用,而不是原來的網路費用。", "transaction.action.cancel.title": "是否取消此交易?", "transaction.action.copy": "複製交易識別碼", - "transaction.action.copyMoonPay": "複製 MoonPay 交易識別碼", + "transaction.action.copyProvider": "複製 {{providerName}} 交易ID", "transaction.action.view": "檢視 {{tokenSymbol}}", "transaction.action.viewEtherscan": "在 {{blockExplorerName}} 上檢視", - "transaction.action.viewMoonPay": "在 MoonPay 上檢視", "transaction.amount.unlimited": "無限", "transaction.currency.unknown": "未知代幣", "transaction.date": "於 {{date}} 提交", "transaction.details.dappName": "應用程式", "transaction.details.from": "從", "transaction.details.networkFee": "網路費用", + "transaction.details.swapRate": "費率", "transaction.details.transactionId": "交易識別碼", + "transaction.details.uniswapFee": "費用 ({{ feePercent }}%)", "transaction.network.all": "所有網路", "transaction.networkCost.label": "網路費用", "transaction.notification.error.cancel": "無法取消交易", @@ -1091,6 +1085,8 @@ "walletConnect.request.button.sign": "簽署", "walletConnect.request.details.label.function": "功能", "walletConnect.request.details.label.sending": "正在傳送", + "walletConnect.request.details.label.token": "代幣", + "walletConnect.request.details.label.tokens": "代幣", "walletConnect.request.error.insufficientFunds": "您的 {{currencySymbol}} 不足,無法完成此交易。", "walletConnect.request.error.network": "網際網路或網路連線錯誤", "walletConnect.request.warning.general.message": "注意:此訊息可能會轉移資產", diff --git a/apps/web/src/i18n/locales/source/en-US.json b/packages/uniswap/src/i18n/locales/web-source/en-US.json similarity index 80% rename from apps/web/src/i18n/locales/source/en-US.json rename to packages/uniswap/src/i18n/locales/web-source/en-US.json index cf520dad564..1134bd149f9 100644 --- a/apps/web/src/i18n/locales/source/en-US.json +++ b/packages/uniswap/src/i18n/locales/web-source/en-US.json @@ -1,5 +1,5 @@ { - "account.authHeader.claimReward": "Claim {{ amount }} reward", + "account.authHeader.claimReward": "Claim {{amount}} reward", "account.drawer.gitVersion": "Version: ", "account.drawer.modal.body": "Safely store and swap tokens with the Uniswap app. Available on iOS and Android.", "account.drawer.modal.dont": "Don’t have a Uniswap wallet?", @@ -7,54 +7,59 @@ "account.drawer.spamToggle": "Hide unknown tokens & NFTs", "account.porfolio.activity.cancelledBelow": "This order was canceled because your balance went below the input amount.", "account.portfolio.activity.signLimit": "This order will not fill because your balance went below the input amount. Increase your balance to fix.", - "account.transactionSummary.addLiquidity": "Add {{ baseSymbol }}/{{ quoteSymbol }} V3 liquidity", - "account.transactionSummary.addLiquidityv2": "Add <base /> and <quote /> to Uniswap V2", - "account.transactionSummary.approve": "Approve {{sym}}", - "account.transactionSummary.claimFor": "Claim <currency /> for {{name}}", - "account.transactionSummary.claimReward": "Claim UNI reward for", - "account.transactionSummary.collectFees": "Collect {{ symbol0 }}/{{ symbol1 }} fees", - "account.transactionSummary.createAddLiquidity": "Create pool and add {{ baseSymbol }}/{{ quoteSymbol }} V3 liquidity", - "account.transactionSummary.createPool": "Create {{ baseSymbol }}/{{ quoteSymbol }} V3 pool", - "account.transactionSummary.decision.abstain": "Vote to abstain on proposal {{ proposalKey }} with reason "{{ reason: info.reason }}"", - "account.transactionSummary.decision.against": "Vote against proposal {{ proposalKey }} with reason "{{ reason: info.reason }}",", - "account.transactionSummary.decision.for": "Vote for proposal {{ proposalKey }} with reason "{{ reason: info.reason }}"", - "account.transactionSummary.delegateSummary": "Delegate voting power to {{ name }}", + "account.transactionSummary.addLiquidity": "Add {{baseSymbol}}/{{quoteSymbol}} V3 liquidity", + "account.transactionSummary.addLiquidityv2": "Add <baseAmountAndToken /> and <quoteAmountAndToken /> to Uniswap V2", + "account.transactionSummary.approve": "Approve {{tokenSymbol}}", + "account.transactionSummary.claimFor": "Claim <currency /> for {{username}}", + "account.transactionSummary.claimReward": "Claim UNI reward for {{username}}", + "account.transactionSummary.collectFees": "Collect {{symbol0}}/{{symbol1}} fees", + "account.transactionSummary.createAddLiquidity": "Create pool and add {{baseSymbol}}/{{quoteSymbol}} V3 liquidity", + "account.transactionSummary.createPool": "Create {{baseSymbol}}/{{quoteSymbol}} V3 pool", + "account.transactionSummary.decision.abstain": "Vote to abstain on proposal {{proposalKey}} with reason "{{reason}}"", + "account.transactionSummary.decision.against": "Vote against proposal {{proposalKey}} with reason "{{reason}}"", + "account.transactionSummary.decision.for": "Vote for proposal {{proposalKey}} with reason "{{reason: info.reason}}"", + "account.transactionSummary.delegateSummary": "Delegate voting power to {{username}}", "account.transactionSummary.depositLiquidity": "Deposit liquidity", - "account.transactionSummary.executeProposal": "Execute proposal {{ proposalKey }}.", - "account.transactionSummary.migrateLiquidity": "Migrate {{ baseSymbol }}/{{ quoteSymbol }} liquidity to V3", - "account.transactionSummary.queueProposal": "Queue proposal {{ proposalKey }}.", + "account.transactionSummary.executeProposal": "Execute proposal {{proposalKey}}.", + "account.transactionSummary.migrateLiquidity": "Migrate {{baseSymbol}}/{{quoteSymbol}} liquidity to V3", + "account.transactionSummary.queueProposal": "Queue proposal {{proposalKey}}.", "account.transactionSummary.removeLiquiditySummary": "Remove <base /> and <quote />", - "account.transactionSummary.revoke": "Revoke {{sym}}", + "account.transactionSummary.revoke": "Revoke {{tokenSymbol}}", "account.transactionSummary.sendSummary": "Sent <amount /> to {{recipient}}", "account.transactionSummary.submitProposal": "Submit new proposal", "account.transactionSummary.swapExactIn": "Swap exactly <amount1 /> for <amount2 />", "account.transactionSummary.swapExactOut": "Swap <amount1 /> for exactly <amount2 />", "account.transactionSummary.unwrapTo": "Unwrap <amount /> to {{symbol}}", - "account.transactionSummary.vote.abstain": "Vote to abstain on proposal {{ proposalKey }}", - "account.transactionSummary.vote.against": "Vote against proposal {{ proposalKey }}", - "account.transactionSummary.vote.for": "Vote for proposal {{ proposalKey }}", + "account.transactionSummary.vote.abstain": "Vote to abstain on proposal {{proposalKey}}", + "account.transactionSummary.vote.against": "Vote against proposal {{proposalKey}}", + "account.transactionSummary.vote.for": "Vote for proposal {{proposalKey}}", "account.transactionSummary.withdrawLiquidity": "Withdraw deposited liquidity", "account.transactionSummary.wrapTo": "Wrap <amount /> to {{symbol}}", "activity.pending": "{{pendingActivityCount}} Pending", + "activity.transaction.receive.descriptor": "{{amountWithSymbol}} from {{walletAddress}}", + "activity.transaction.send.descriptor": "{{amountWithSymbol}} to {{walletAddress}}", + "activity.transaction.swap.descriptor": "{{amountWithSymbolA}} for {{amountWithSymbolB}}", + "activity.transaction.swap.descriptor.formatted": "<amountWithSymbolA /> for <amountWithSymbolB />", + "activity.transaction.tokens.descriptor": "{{amountWithSymbolA}} and {{amountWithSymbolB}}", + "activity.transaction.tokens.descriptor.formatted": "<amountWithSymbolA /> and <amountWithSymbolB />", "addLiquidity.shareOfPool": "Share of pool", "addressInput.recipient": "Recipient", - "analytics.allow.message": "We use anonymized data to enhance your experience with Uniswap Labs products.", "analytics.allow": "Allow analytics", + "analytics.allow.message": "We use anonymized data to enhance your experience with Uniswap Labs products.", "burn.input.enterAPercent.error": "Enter a percent", - "chain.here": "here.", "chart.candlestick": "Candlestick", "chart.error.pools": "Unable to display historical data for the current pool.", "chart.error.tokens": "Unable to display historical data for the current token.", "chart.line": "Line chart", "chart.missingData": "Missing chart data", - "chart.price.high": "High", - "chart.price.low": "Low", - "chart.price.open": "Open", + "chart.price.label.close": "Close", + "chart.price.label.high": "High", + "chart.price.label.low": "Low", + "chart.price.label.open": "Open", "chart.settings.unavailable.label": "This setting is unavailable for the current chart", "claim.thanks": "Thanks for being part of the Uniswap community <heart />", "claim.uni.arrived": "UNI has arrived", "common.accept": "Accept", - "common.acknowledge": "I understand", "common.activity": "Activity", "common.add.label": "Add", "common.add.liquidity.cancelled": "Add liquidity cancelled", @@ -72,7 +77,6 @@ "common.amount.label": "Amount", "common.amountDeposited.label": "{{amount}} Deposited", "common.amountInput.placeholder": "Input amount", - "common.analytics": "Analytics", "common.app": "App", "common.approval.cancelled": "Approval cancelled", "common.approval.failed": "Approval failed", @@ -82,12 +86,11 @@ "common.approveSpend": "Approve {{symbol}} spending", "common.approving": "Approving", "common.automatic": "Auto", - "common.availableIn": "Uniswap available in: ", + "common.availableIn": "Uniswap available in: <locale />", "common.availableOnIOSAndroid": "Available on iOS and Android", "common.availableOnIOSAndroidChrome": "Available on iOS, Android, and Chrome", - "common.blocked.activities": "blocked activities", - "common.blocked.ifError": "If you believe this is an error, please send an email including your address to ", - "common.blocked.reason": "This address is blocked on the Uniswap Labs interface because it is associated with one or more", + "common.blocked.ifError": "If you believe this is an error, please send an email including your address to <emailAddress />", + "common.blocked.reason": "This address is blocked on the Uniswap Labs interface because it is associated with one or more <link>blocked activities</link>.", "common.blockedAddress": "Blocked address", "common.blog": "Blog", "common.borrow.cancelled": "Borrow cancelled", @@ -95,18 +98,25 @@ "common.borrowed": "Borrowed", "common.borrowing": "Borrowing", "common.bought": "Bought", - "common.brandAssets": "Brand Assets", + "common.brandAssets": "Brand assets", "common.burn.cancelled": "Burn cancelled", "common.burn.failed": "Burn failed", "common.burned": "Burned", "common.burning": "Burning", + "common.button.back": "Back", + "common.button.cancel": "Cancel", + "common.button.close": "Close", + "common.button.continue": "Continue", + "common.button.learn": "Learn more", + "common.button.paste": "Paste", + "common.button.retry": "Retry", + "common.button.understand": "I understand", "common.buy.cancelled": "Buy cancelled", "common.buy.failed": "Buy failed", "common.buy.label": "Buy", "common.buyAndSell": "Buy and sell on Uniswap", "common.buying": "Buying", "common.by": "By", - "common.cancel.button": "Cancel", "common.cancel.failed": "Cancel failed", "common.cancellation.cancelled": "Cancellation cancelled", "common.cancellationSubmitted": "Cancellation submitted", @@ -114,19 +124,17 @@ "common.cancelled": "Cancelled", "common.cancelling": "Cancelling", "common.cancelOrder": "Cancel order", - "common.cantTradeTokens": "You can’t trade these tokens using the Uniswap App.", + "common.card.error.description": "Something went wrong", + "common.card.error.title": "Oops! Something went wrong.", "common.careers": "Careers", - "common.caution.label": "Caution", "common.chartType": "Chart type", - "common.checkNetwork": "Check network status", + "common.checkNetwork": "Check network status <link>here.</link>", "common.chromeExtension": "Extension", "common.claim.cancelled": "Claim cancelled", "common.claim.failed": "Claim failed", "common.claimed": "Claimed", "common.claiming": "Claiming", - "common.claimUni": "Claim UNI token", "common.claimUnis": "Claim your UNI tokens", - "common.clearAll": "Clear all", "common.close": "Close", "common.closed": "Closed", "common.collect.button": "Collect", @@ -134,8 +142,6 @@ "common.collect.fees.failed": "Collect fees failed", "common.collected.fees": "Collected fees", "common.collecting.fees": "Collecting fees", - "common.collection": "collection", - "common.collections": "collections", "common.company": "Company", "common.confirm": "Confirm", "common.confirmCancellation": "Confirm cancellation", @@ -151,19 +157,16 @@ "common.connectToChain.button": "Connect to {{chainName}}", "common.connectWallet.button": "Connect wallet", "common.contactUs.button": "Contact us", - "common.continue.button": "Continue", "common.contractInteraction": "Contract Interaction", "common.copied": "Copied", - "common.copy.button": "Copy", "common.copyLink.button": "Copy link", "common.create.pool.cancelled": "Create pool cancelled", "common.create.pool.failed": "Create pool failed", "common.created.pool": "Created pool", "common.creating.pool": "Creating pool", - "common.currency.amount": "${{amount}}", "common.currency": "Currency", - "common.currentPrice.label": "Current price:", "common.currentPrice": "Current price", + "common.currentPrice.label": "Current price:", "common.custom": "Custom", "common.dataOutdated": "Data may be outdated", "common.defaultTradeOptions": "Default trade options", @@ -184,12 +187,12 @@ "common.detected": "Detected", "common.developers": "Developers", "common.dismiss": "Dismiss", + "common.displaySettings": "Display settings", "common.dnsRegistrar": "DNS Registrar", "common.download": "Download", "common.downloadUniswap": "Download Uniswap", "common.downloadUniswapApp": "Download the Uniswap app", "common.edit.button": "Edit", - "common.endAdornment": "and", "common.error.label": "Error", "common.error.request": "Sorry, an error occured while processing your request. If you request support, be sure to copy the details of this error.", "common.error.somethingWrong": "Something went wrong!", @@ -197,8 +200,8 @@ "common.errorConnecting.error": "Error connecting", "common.errorLoadingData.error": "Error loading data", "common.ethereumNameService": "Ethereum Name Service", - "common.etherscan.link": "View on Etherscan", "common.etherscan": "Etherscan", + "common.etherscan.link": "View on Etherscan", "common.ethRegistrarController": "ETH Registrar Controller", "common.execute.cancelled": "Execute cancelled", "common.execute.failed": "Execute failed", @@ -212,27 +215,23 @@ "common.extension": "Uniswap Extension", "common.failed.error": "Failed", "common.failedSwitchNetwork": "Failed to switch networks", - "common.fee.caps": "Fee", - "common.fee": "fee", + "common.fee": "Fee", "common.fees": "Fees", "common.feesEarned.label": "{{symbol}} Fees Earned:", "common.feesEarnedPerBase": "{{symbolA}} per {{symbolB}}", "common.fetchingRoute": "Fetching route", "common.floor": "Floor", "common.floorPrice": "Floor price", - "common.for.address": "for {{ address }}", "common.for": "For", - "common.from": "from", "common.fullRange": "Full range", "common.getApp": "Get app", "common.getHelp.button": "Get help", "common.getSupport.button": "Get support", "common.getTheApp": "Get the app", "common.getUniswapWallet": "Get Uniswap Wallet", - "common.googleChrome": "Google Chrome", "common.governance": "Governance", "common.happyHolidays": "Happy Holidays from the Uniswap team!", - "common.helpCenter": "Help Center", + "common.helpCenter": "Help center", "common.hidden": "Hidden", "common.hide.button": "Hide", "common.highPrice": "High price", @@ -247,14 +246,12 @@ "common.invalidPair": "Invalid pair", "common.invalidRecipient.error": "Invalid recipient", "common.iOSAndroid": "iOS and Android", - "common.items": "items", "common.language": "Language", "common.lastPrice": "Last price", - "common.learnMore.link": "Learn more", "common.learnMoreSwap": "Learn more about swaps", "common.legalAndPrivacy": "Legal & Privacy", - "common.limit.cancel.amount": "Cancel {{count}} limits", - "common.limit.cancel": "Cancel limit", + "common.limit.cancel_one": "Cancel limit", + "common.limit.cancel_other": "Cancel {{count}} limits", "common.limit.cancelled": "Limit cancelled", "common.limit.executed": "Limit executed", "common.limit.expired": "Limit expired", @@ -264,31 +261,28 @@ "common.limits": "Limits", "common.limits.approachMax": "Approaching 100 limit maximum", "common.limits.cancelProceed": "Cancel limits to proceed", - "common.limits.expires": "Expires {{ timestamp }}", + "common.limits.expires": "Expires {{timestamp}}", "common.limits.open": "Open limits", - "common.limits.when": "when {{ price }} {{ outSymbol }}/{{ inSymbol }}", + "common.limits.when": "when {{price}} {{outSymbol}}/{{inSymbol}}", "common.links": "Links", "common.liquidity": "Liquidity", - "common.listing": "listing", - "common.listings": "listings", "common.loading": "Loading", "common.loadingAllowance": "Loading allowance", + "common.longText.button.less": "Read less", + "common.longText.button.more": "Read more", "common.lowPrice": "Low price", "common.manage": "Manage", "common.market.label": "Market", - "common.max.caps": "MAX", "common.max": "Max", + "common.migrate": "Migrate", "common.migrate.liquidity.cancelled": "Migrate liquidity cancelled", "common.migrate.liquidity.failed": "Migrate liquidity failed", - "common.migrate": "Migrate", "common.migrated.liquidity": "Migrated liquidity", "common.migrating.liquidity": "Migrating liquidity", - "common.min.label": "Min", "common.mint.cancelled": "Mint cancelled", "common.mint.failed": "Mint failed", "common.minted": "Minted", "common.minting": "Minting", - "common.mobileWallet": "Mobile Wallet", "common.more": "More", "common.navigationButton": "Navigation button", "common.needHelp": "Need help?", @@ -297,11 +291,10 @@ "common.nfts": "NFTs", "common.noActivity": "No activity yet", "common.noAmount.error": "Enter an amount", - "common.notAvailableInRegion.error": "Not available in your region", "common.noData": "No data", "common.noDescription": "No description.", "common.noResults": "No results found.", - "common.notAvailable": "Not available", + "common.notAvailableInRegion.error": "Not available in your region", "common.notCreated.label": "Not created", "common.oneDay": "1 day", "common.oneHour": "1 hour", @@ -321,38 +314,34 @@ "common.pastWeek": "Past week", "common.pastYear": "Past year", "common.pay.button": "Pay", - "common.pending.cancellation": "Pending cancellation", "common.pending": "Pending", + "common.pending.cancellation": "Pending cancellation", "common.pendingEllipsis": "Pending...", - "common.per": "per", - "common.percentage": "{{pct}}%", "common.permit2": "Permit2", "common.pool": "Pool", "common.pools": "Pools", - "common.popularTokens": "Popular tokens", "common.positionUnavailable": "Position unavailable", - "common.preferences": "Preferences", "common.preview": "Preview", "common.price": "Price", "common.priceImpact": "Price impact warning", "common.priceUpdated": "Price updated", "common.privacyPolicy": "Privacy Policy", "common.proceed": "Proceed", - "common.proceedInWallet.short": "Proceed in wallet", "common.proceedInWallet": "Proceed in your wallet", + "common.proceedInWallet.short": "Proceed in wallet", "common.protocol": "Protocol", "common.publicResolver": "Public Resolver", "common.purchased": "Purchased", + "common.queue": "Queue", "common.queue.cancelled": "Queue cancelled", "common.queue.failed": "Queue failed", - "common.queue": "Queue", "common.queued": "Queued", "common.queuing": "Queuing", "common.rate": "Rate", "common.readMore": "Read more", + "common.receive": "Receive", "common.receive.cancelled": "Receive cancelled", "common.receive.failed": "Receive failed", - "common.receive": "Receive", "common.received": "Received", "common.receiving": "Receiving", "common.recent": "Recent", @@ -376,7 +365,6 @@ "common.resolveIssue": "Resolve issue", "common.resolveIssues": "Resolve {{issues}} issues", "common.restricted.region": "Restricted region", - "common.retry": "Retry", "common.return.label": "Return", "common.returnToTop": "Return to top", "common.reverseRegistrar": "Reverse Registrar", @@ -386,14 +374,10 @@ "common.revoking.approval": "Revoking approval", "common.samePrice": "Same price", "common.scanQRDownload": "Scan the QR code with your phone to download", - "common.search.label": "Search", - "common.searchResults": "Search results", - "common.searchTokens": "Search tokens", "common.searchTokensNFT": "Search tokens and NFT collections", "common.selectAction.label": "Select an action", - "common.selectToken.label": "Select a token", "common.selectRegion.label": "Select your region", - "common.selectToken": "Select token", + "common.selectToken.label": "Select a token", "common.sell.label": "Sell", "common.send.button": "Send", "common.send.cancelled": "Send cancelled", @@ -401,13 +385,12 @@ "common.sending": "Sending", "common.sent": "Sent", "common.settings": "Settings", + "common.share": "Share", "common.share.shareToTwitter": "Share to Twitter", "common.share.twitter": "Share on Twitter", - "common.share": "Share", "common.show.button": "Show", "common.showLess.button": "Show less", "common.showMore.button": "Show more", - "common.sign.action": "Sign", "common.signatureExpired": "Your signature has expired.", "common.signMessage": "Sign message", "common.signMessageWallet": "Sign message in wallet", @@ -421,49 +404,37 @@ "common.submitted.proposal": "Submitted proposal", "common.submitting.proposal": "Submitting proposal", "common.success": "Success", + "common.swap": "Swap", "common.swap.cancelled": "Swap cancelled", "common.swap.expired": "Swap expired", "common.swap.failed": "Swap failed", - "common.swap": "Swap", "common.swapped": "Swapped", "common.swapPending": "Swap pending...", "common.swapping": "Swapping", "common.switchNetworks": "Switch networks", "common.termsOfService": "Terms of Service", - "common.termsPrivacy": "Terms & Privacy", "common.thisMonth": "This month", "common.thisWeek": "This week", "common.thisYear": "This year", + "common.time": "Time", "common.time.day": "day", "common.time.days": "days", - "common.time.daysAbbr": "{{days}}d", "common.time.hour": "hour", "common.time.hours": "hours", - "common.time.hoursAbbr": "{{hours}}h", "common.time.minute.amt": "{{time}}m", - "common.time.minute": "minute", "common.time.minutes": "minutes", - "common.time.minutesAbbr": "{{minutes}}m", "common.time.month": "month", "common.time.months": "months", - "common.time.monthsAbbr": "{{months}}mo", - "common.time.second": "second", - "common.time.seconds": "seconds", - "common.time.secondsAbbr": "{{seconds}}s", - "common.time.year": "year", - "common.time.years": "years", - "common.time.yearsAbbr": "{{years}}y", - "common.time": "Time", - "common.timestamp.daysAgo": "{{daysPassed}}d ago", - "common.timestamp.hoursAgo": "{{hoursPassed}}h ago", - "common.timestamp.minutesAgo": "{{minutesPassed}}m ago", - "common.timestamp.monthsAgo": "{{monthsPassed}}mo ago", - "common.timestamp.secondsAgo": "{{secondsPassed}}s ago", + "common.time.past.days": "{{days}}d ago", + "common.time.past.hours": "{{hours}}h ago", + "common.time.past.minutes": "{{minutes}}m ago", + "common.time.past.months": "{{months}}mo ago", + "common.time.past.seconds": "{{seconds}}s ago", + "common.time.week": "week", + "common.time.weeks": "weeks", "common.tip.label": "Tip:", - "common.to.caps": "To", "common.to": "to", "common.today": "Today", - "common.token": "Token", "common.tokenA": "Token A", "common.tokenAmount": "Token amount", "common.tokenB": "Token B", @@ -485,13 +456,12 @@ "common.type.label": "Type", "common.unavailable": "Unavailable", "common.uniGovernance": "UNI Governance", - "common.uniInterface": "Uniswap Interface", + "common.uniswapMobile": "Uniswap Mobile", "common.uniswapProtocol": "Uniswap Protocol", "common.uniswapTVL": "Uniswap TVL", "common.uniswapWallet": "Uniswap wallet", "common.uniswapX": "UniswapX", "common.unknown": "Unknown", - "common.UNKNOWN": "UNKNOWN", "common.unknownApproval": "Unknown Approval", "common.unknownError.error": "Unknown Error", "common.unknownLend": "Unknown Lend", @@ -505,27 +475,18 @@ "common.unwrap.failed": "Unwrap failed", "common.unwrapped": "Unwrapped", "common.unwrapping": "Unwrapping", - "common.v2": "v2", - "common.v3": "v3", "common.viewOnBlockExplorer": "View on Block Explorer", "common.viewOnExplorer": "View on Explorer", "common.viewTransactionExplorer.link": "View transaction on Explorer", - "common.volume.lowercase": "volume", "common.volume": "Volume", "common.vote.cancelled": "Vote cancelled", "common.vote.failed": "Vote failed", - "common.vote": "Vote", "common.voted": "Voted", "common.voting": "Voting", "common.wallet.approve": "Approve in wallet", "common.wallet.label": "Wallet", "common.wallet.unsupported": "Unsupported by your wallet", "common.walletForSwapping": "The wallet built for swapping. Available on iOS and Android.", - "common.warning.tokenNotTraded": "{{name}} isn’t traded on leading U.S. centralized exchanges.", - "common.warning.tokenNotTradedOrSwapped": "{{name}} isn’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap.", - "common.warning.tokensNotTraded": "These tokens aren’t traded on leading U.S. centralized exchanges.", - "common.warning.tokensNotTradedOrSwapped": "These tokens aren’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap.", - "common.warning": "Warning", "common.webApp": "Web app", "common.website": "Website", "common.whyApprove": "Why do I have to approve a token?", @@ -536,16 +497,15 @@ "common.withdrawing": "Withdrawing", "common.withdrew": "Withdrew", "common.withinRange": "In range", + "common.wrap": "Wrap {{symbol}}", "common.wrap.button": "Wrap", "common.wrap.cancelled": "Wrap cancelled", "common.wrap.failed": "Wrap failed", - "common.wrap": "Wrap {{symbol}}", "common.wrapIn": "Wrap {{symbol}} in wallet", "common.wrapped": "Wrapped", "common.wrapping": "Wrapping", "common.wrappingToken": "Wrapping {{symbol}}...", "common.wrongNetwork": "Wrong network", - "common.xPerY": "<x /> per <y />", "common.your.account.had.insufficient.funds": "Your account had insufficient funds to complete this swap.", "common.your.account.has.insufficient.funds": "Your account has insufficient funds to complete this swap.", "common.your.limit.could.not.be.fulfilled": "Your limit could not be fulfilled at this time. Please try again.", @@ -553,7 +513,6 @@ "common.youreBuying": "You’re buying", "common.youRecieve": "You receive", "common.youreSending": "You’re sending", - "common.yourTokens": "Your tokens", "common.youWillReceive": "You will receive", "currency.aud": "Australian Dollar", "currency.brl": "Brazilian Real", @@ -568,6 +527,7 @@ "currency.ngn": "Nigerian Naira", "currency.pkr": "Pakistani Rupee", "currency.rub": "Russian Ruble", + "currency.search.placeholder": "Search name or paste address", "currency.sgd": "Singapore Dollar", "currency.thb": "Thai Baht", "currency.try": "Turkish Lira", @@ -583,7 +543,7 @@ "explore.unableToDisplayHistorical": "Unable to display historical volume data for the current chain.", "explore.unableToDisplayHistoricalTVL": "Unable to display historical TVL data for the current chain.", "explore.uniVolume": "Uniswap volume", - "extension.announcement": "The Uniswap Extension Beta is here. Swap, sign transactions, and send tokens right from your sidebar.", + "extension.announcement": "The Uniswap Extension is here. Swap, sign transactions, and send tokens right from your browser.", "extension.introduction": "Introducing the Uniswap Extension.", "extension.open": "Open Uniswap Extension", "fee.bestForExotic": "Best for exotic pairs.", @@ -594,86 +554,91 @@ "fee.selectPercent": "{{pct}}% select", "fee.tier": "Fee tier", "fee.tierExact": "{{fee}} fee tier", - "fiatOnRamp.notAvailable.error": "This service is unavailable in your region", "fiatOnRamp.checkoutWith": "Checkout with", "fiatOnRamp.chooseProvider.description": "You’ll continue to the provider’s portal to see the fees associated with your transaction.", + "fiatOnRamp.completeTransactionHeader": "Complete transaction with {{serviceProvider}}", + "fiatOnRamp.connection.error": "Connection failed", + "fiatOnRamp.connection.errorDescription": "Something went wrong connecting with {{serviceProvider}}.", "fiatOnRamp.connection.message": "Connecting you to {{serviceProvider}}", "fiatOnRamp.connection.quote": "Buying {{amount}} worth of {{currencySymbol}}", + "fiatOnRamp.continueInTab": "Go to the {{serviceProvider}} tab to continue. It’s safe to close this modal now.", "fiatOnRamp.disclaimer": "By continuing, you acknowledge that you’ll be subject to the <tosLink>Terms of Service</tosLink> and <privacyLink>Privacy Policy</privacyLink> with {{serviceProvider}}, as applicable.", - "fiatOnRamp.error.min": "Minimum {{amount}}", "fiatOnRamp.error.max": "Maximum {{amount}}", - "fiatOnRamp.continueInTab": "Go to the {{serviceProvider}} tab to continue. It’s safe to close this modal now.", - "fiatOnRamp.completeTransactionHeader": "Complete transaction with {{serviceProvider}}", - "fiatOnRamp.quote.type.other": "Other options", - "fiatOnRamp.connection.error": "Connection failed", - "fiatOnRamp.connection.errorDescription": "Something went wrong connecting with {{serviceProvider}}.", - "fiatOnRamp.purchasedOn": "Purchased on {{serviceProvider}}", + "fiatOnRamp.error.min": "Minimum {{amount}}", "fiatOnRamp.exchangeRate": "{{outputAmount}} {{outputSymbol}} for {{inputAmount}} {{inputSymbol}}", + "fiatOnRamp.notAvailable.error": "This service is unavailable in your region", + "fiatOnRamp.purchasedOn": "Purchased on {{serviceProvider}}", + "fiatOnRamp.quote.type.list": "{{optionsList}}, and other options", + "fiatOnRamp.quote.type.other": "Other options", + "fiatOnRamp.quote.type.recent": "Recently used", + "fiatOnRamp.receiveCrypto.modal.addressQr.supportedNetworks": "You can receive tokens & NFTs on Ethereum, Polygon, Arbitrum, Optimism, Base, BNB, Blast, Avalanche, Zora, and ZKsync.", + "fiatOnRamp.receiveCrypto.modal.sectionTitle.fromAccount": "From an account", "fiatOnRamp.receiveCrypto.title": "Receive crypto", "fiatOnRamp.receiveCrypto.transferFunds": "Fund your wallet by transferring crypto from another wallet or account", - "fiatOnRamp.receiveCrypto.modal.sectionTitle.fromAccount": "From an account", - "fiatOnRamp.receiveCrypto.modal.addressQr.supportedNetworks": "You can receive tokens & NFTs on Ethereum, Polygon, Arbitrum, Optimism, Base, BNB, Blast, Avalanche, Zora, and ZKsync.", - "fiatOnRamp.quote.type.recent": "Recently used", - "for {{address}}": "for {{address}}", - "for": "for", "globalPreferences.title": "Global preferences", - "hero.swap.title": "Swap anytime, <br/> anywhere.", "hero.scroll": "Scroll to learn more", "hero.subtitle": "The largest onchain marketplace. Buy and sell crypto on Ethereum and 7+ other chains.", + "hero.swap.title": "Swap anytime, <br/> anywhere.", "home.tokens.empty.action.buy.description": "Purchase crypto with a debit card or a bank account", "home.tokens.empty.action.buy.title": "Buy crypto with card", "home.tokens.empty.welcome": "Welcome to your wallet!", "home.tokens.empty.welcome.description": "Looks like you have a new wallet. Let’s get it funded before you make your first swap.", + "interface.metatags.description": "Swap or provide liquidity on the Uniswap Protocol", + "interface.metatags.title": "Uniswap Interface", "landing.buildNextGen": "Build the next generation of open applications and tools.", "landing.connectWithUs": "Connect with us", "landing.devDocs": "Developer docs", "landing.directToDeFi": "Go direct to DeFi", "landing.followOnX": "Follow @Uniswap on X for the latest updates", "landing.protocolDescription": "Uniswap products are powered by the Uniswap Protocol. The protocol is the largest onchain marketplace, with billions of dollars in weekly volume across thousands of tokens on Ethereum and 7+ additional chains.", - "landing.provideLiquidity.message": "Provide liquidity to pools on the Uniswap Protocol and earn fees on swaps.", "landing.provideLiquidity": "Provide Liquidity", + "landing.provideLiquidity.message": "Provide liquidity to pools on the Uniswap Protocol and earn fees on swaps.", "landing.swapSimple": "Swapping made simple. Access thousands of tokens on 8+ chains.", "landing.teamInsights": "Insights and news from the team", "landing.trusted": "Trusted by millions", - "limit.form.limitExecutionTime.warning": "Limits may not execute exactly when tokens reach the specified price. {{learnMoreLink}}", - "limit.open.count": "{{count}} open limits", - "limit.open.one": "1 open limit", + "limit.open.count_one": "1 open limit", + "limit.open.count_other": "{{count}} open limits", "limitPrice.buyingAboveMarketPrice.error.description": "Your limit price is {{percentage}}% higher than market. Adjust your limit price to proceed.", "limitPrice.buyingAboveMarketPrice.error.title": "Buying {{tokenSymbol}} above market price.error", "limitPrice.marketPriceNotAvailable.error.description": "We are unable to calculate the current market price. To avoid submitting an order below market price, please check your network connection and try again.", "limitPrice.marketPriceNotAvailable.error.title": "Market price not available", "limitPrice.sellingBelowMarketPrice.error.description": "Your limit price is {{percentage}}% lower than market. Adjust your limit price to proceed.", "limitPrice.sellingBelowMarketPrice.error.title": "Selling {{tokenSymbol}} below market price", - "limits.isWorth": "is worth", + "limits.form.disclaimer.mainnet": "Only Ethereum mainnet tokens are available for limits. <link>Learn more</link>", + "limits.form.disclaimer.uniswapx": "Limits may not execute exactly when tokens reach the specified price. <link>Learn more</link>", "limits.learnMore": "Learn more about limits", - "limits.onlyMainnet": "Only Ethereum mainnet tokens are available for limits. <0>Learn more</0>", + "limits.price.input.label": "When 1 <tokenSymbol /> is worth", "limits.price.label": "Limit price", - "limits.priceWarning": "Limits may not execute exactly when tokens reach the specified price. <0>Learn more</0>", "limits.selectSupportedTokens": "Select supported tokens", - "limits.whenOne": "When 1", "liquidity.notEnough.label": "Not enough liquidity to show accurate USD value.", - "max": "max", + "liquidityPool.chart.tooltip.amount": "{{token}} liquidity: {{amount}}", + "liquidityPool.page.title": "Add liquidity to pools{{version}} on Uniswap", + "liquidityPool.positions.closed.title": "Closed Positions", + "liquidityPool.positions.page.title": "Manage {{quoteSymbol}}/{{baseSymbol}} pool liquidity on Uniswap", + "liquidityPool.positions.page.version.description": "View your active {{version}} liquidity positions. Add new positions.", + "liquidityPool.positions.page.version.title": "Manage pool liquidity{{version}} on Uniswap", + "liquidityPool.positions.price": "{{amountWithSymbol}} per {{outputToken}}", + "liquidityPool.positions.price.formatted": "<amountWithSymbol /> per <outputToken />", "migrate.allowed": "Allowed", "migrate.allowLpMigration": "Allow LP token migration", "migrate.connectAccount": "You must connect an account.", "migrate.connectWallet": "Connect to a wallet to view your V2 liquidity.", "migrate.contract": "Uniswap migration contract", - "migrate.firstLP": "You are the first liquidity provider for this Uniswap V3 pool. Your liquidity will migrate at the current {{ source }} price.", + "migrate.firstLP": "You are the first liquidity provider for this Uniswap V3 pool. Your liquidity will migrate at the current {{source}} price.", "migrate.highGasCost": "Your transaction cost will be much higher as it includes the gas to create the pool.", - "migrate.import": "Import it.", "migrate.invalidRange": "Invalid range selected. The min price must be lower than the max price.", "migrate.lpNFT": "{{symA}}/{{symB}} LP NFT", - "migrate.lpTokens": "{{ symA }}/{{ symB }} LP tokens", + "migrate.lpTokens": "{{symA}}/{{symB}} LP tokens", "migrate.migrating": "Migrating", - "migrate.missingV2Position": "Don’t see one of your v2 positions? <0>Import it.</0>", + "migrate.missingV2Position": "Don’t see one of your v2 positions? <link>Import it.</link>", "migrate.noV2Liquidity": "No V2 liquidity found.", "migrate.positionNoFees": "Your position will not earn fees or be used in trades until the market price moves into your range.", "migrate.priceDifference": "Price difference: ", "migrate.priceWarning": "You should only deposit liquidity into Uniswap V3 at a price you believe is correct. <br /> If the price seems incorrect, you can either make a swap to move the price or wait for someone else to do so.", - "migrate.refund": "At least {{ amtA }} {{ symA }} and {{ amtB }} {{ symB }} will be refunded to your wallet due to selected price range.", + "migrate.refund": "At least {{amtA}} {{symA}} and {{amtB}} {{symB}} will be refunded to your wallet due to selected price range.", "migrate.setRange": "Set price range", - "migrate.symbolPrice": "{{name}} {{sym}} Price:", - "migrate.v2Description": "This tool will safely migrate your {{ source }} liquidity to V3. The process is completely trustless thanks to the <0>Uniswap migration contract</0> ↗", + "migrate.symbolPrice": "{{protocolName}} {{tokenSymbol}} Price:", + "migrate.v2Description": "This tool will safely migrate your {{source}} liquidity to V3. The process is completely trustless thanks to the <0>Uniswap migration contract</0> ↗", "migrate.v2Instruction": "For each pool shown below, click migrate to remove your liquidity from Uniswap V2 and deposit it into Uniswap V3.", "migrate.v2Subtitle": "Migrate your liquidity tokens from Uniswap V2 to Uniswap V3.", "migrate.v2Title": "Migrate V2 liquidity", @@ -684,35 +649,36 @@ "moonpay.poweredBy": "Fiat onramp powered by MoonPay USA LLC", "moonpay.rampIframe": "MoonPay fiat on-ramp iframe", "moonpay.restricted.region": "Moonpay is not available in some regions. Click to learn more.", + "nav.tabs.createPosition": "Create position", + "nav.tabs.viewPosition": "View position", "network.lostConnection": "You may have lost your network connection.", - "network.mightBeDown": "{{label}} might be down right now, or you may have lost your network connection.", + "network.mightBeDown": "{{network}} might be down right now, or you may have lost your network connection.", "network.warning": "Network warning", "nft.addToBag": "Add to bag", - "nft.alreadyListedAt": "Already listed at", "nft.authorsCollectionOnUni": "{{name}}’s NFT collection on Uniswap", - "nft.availableYet": "available yet", "nft.bag": "Bag", - "nft.belowFloorEnd": "below floor price.", "nft.blockedCollection": "This collection is blocked", "nft.blockedOpenSea": "Blocked on OpenSea", "nft.buyTransferNFTToStart": "Buy or transfer NFTs to this wallet to get started.", "nft.buyTransferTokensToStart": "Buy or transfer tokens to this wallet to get started.", + "nft.card.notAvailable": "Content not <br /> available yet", "nft.chainSupportComingSoon": "{{chainName}} support coming soon", + "nft.collection.title": "Explore and buy on Uniswap", "nft.collectionOnUni": "NFT collection on Uniswap", "nft.collections": "NFT collections", "nft.collectonOnAddress": "NFT collection on Uniswap - {{address}}", "nft.complete": "Complete!", - "nft.confirmBelowFloor": "below the collection’s floor price. Are you sure you want to continue?", - "nft.contentNot": "Content not", "nft.editListings": "Edit listings", "nft.event": "Event", "nft.explore": "Explore NFTs", - "nft.last": "Last", "nft.learnWhy": "Learn why", - "nft.list": "List NFTs", - "nft.listedSignificantly": "{{count}} NFTs are listed significantly", + "nft.list.header.lastPrice": "Last", + "nft.list.title": "List NFTs", + "nft.listedSignificantly_one": "One NFT is listed {{percentage}} below the collection’s floor price. Are you sure you want to continue?", + "nft.listedSignificantly_other": "{{count}} NFTs are listed significantly below the collection’s floor price. Are you sure you want to continue?", "nft.listForSale": "List for sale", "nft.lowPrice": "Low listing price", + "nft.marketplace.royalty.header": "{{marketName}} fees", "nft.maxFees": "Max fees", "nft.maxRoyalties": "Max creator royalties", "nft.noItems": "No items to display", @@ -720,10 +686,10 @@ "nft.noneFound": "No NFT collections found.", "nft.noPools": "No pools yet", "nft.notListed": "Not listed", - "nft.noTokens": "No tokens yet", - "nft.oneListedDelta": "One NFT is listed {{delta}}", "nft.popularCollections": "Popular NFT collections", "nft.proceedsIfSold": "Proceeds if sold", + "nft.profile.priceInput.warning.alreadyListed": "Already listed at {{tokenAmountWithSymbol}}", + "nft.profile.priceInput.warning.belowFloor": "{{percentage}} below floor price.", "nft.refundsInEth": "Refunds for unavailable items will be given in ETH", "nft.removeFromBag": "Remove from bag", "nft.returnToExplore": "Return to NFT Explore", @@ -734,25 +700,29 @@ "nft.sweep": "Sweep", "nft.unavailableToList": "Unavailable for listing", "nft.view": "View NFTs", - "nft.whyTransaction.reason": "Listing an NFT requires a one-time marketplace approval for each NFT collection.", "nft.whyTransaction": "Why is a transaction required?", + "nft.whyTransaction.reason": "Listing an NFT requires a one-time marketplace approval for each NFT collection.", "nft.willAppearHere": "Your onchain transactions and crypto purchases will appear here.", "nft.wishGranted": "Uniswap has granted your wish!", - "nft": "NFT", + "nfts.collection.action.approve_one": "Approve collection", + "nfts.collection.action.approve_other": "Approve collections", + "nfts.collection.action.sign_one": "Sign listing", + "nfts.collection.action.sign_other": "Sign listings", + "nfts.marketplace.fees.deltaMax": "{{percentChanged}} max", "nfts.my": "My NFTs", "nfts.noneYet": "No NFTs yet", "nfts.sell": "Sell NFTs", "nfts.viewAndSell": "View and sell NFTs", "notFound.oops": "Oops, take me back to Swap", - "notice.uk.label": "UK disclaimer:", "notice.uk": "This web application is provided as a tool for users to interact with the Uniswap Protocol on their own initiative, with no endorsement or recommendation of cryptocurrency trading activities. In doing so, Uniswap is not recommending that users or potential users engage in cryptoasset trading activity, and users or potential users of the web application should not regard this webpage or its contents as involving any form of recommendation, invitation or inducement to deal in cryptoassets.", + "notice.uk.label": "UK disclaimer:", + "notification.send.network": "Sending on {{network}}", + "notification.swap.network": "Swapping on {{network}}", + "outageBanner.message": "{{chainName}} {{versionDescription}} data is unavailable right now, but we expect the issue to be resolved shortly.", "outageBanner.message.sub": "You can still swap and provide liquidity on this chain without issue.", - "outageBanner.message": "{{ chainName }} {{ versionDescription }} data is unavailable right now, but we expect the issue to be resolved shortly.", - "outageBanner.title": "{{ versionName }} will be back soon", - "permit.approval.fail.message.": "Permit2 allows token approvals to be shared and managed across different applications.", + "outageBanner.title": "{{versionName}} will be back soon", "permit.approval.fail": "Permit approval failed", - "polling.recentBlock": "The most recent block number on this network. Prices update on every block.", - "pool.account.analyticsFees": "Account analytics and accrued fees", + "permit.approval.fail.message": "Permit2 allows token approvals to be shared and managed across different applications.", "pool.accruedFees": "View accrued fees and analytics", "pool.activePositions.appear": "Your active V3 liquidity positions will appear here.", "pool.activeRange": "Active tick range", @@ -780,8 +750,8 @@ "pool.explorers": "Explorers", "pool.exporeAnalytics": "Explore Uniswap Analytics.", "pool.hideClosed": "Hide closed positions", - "pool.import.v2": "Import V2 pool", "pool.import": "Import pool", + "pool.import.v2": "Import V2 pool", "pool.increaseLiquidity": "Increase liquidity", "pool.initialShare": "Initial prices and pool share", "pool.learn": "Learn", @@ -791,12 +761,12 @@ "pool.limitFluctuation.warning": "Please be aware that the execution for limits may vary based on real-time market fluctuations and Ethereum network congestion. Limits may not execute exactly when tokens reach the specified price.", "pool.liquidity.connectToAdd": "Connect to a wallet to view your liquidity.", "pool.liquidity.earn.fee": "Liquidity providers earn a 0.3% fee on all trades proportional to their share of the pool. Fees are added to the pool, accrue in real time and can be claimed by withdrawing your liquidity.", - "pool.liquidity.outOfSync.message": "This pool is out of sync with market prices. Adding liquidity at the suggested ratios may result in loss of funds.", "pool.liquidity.outOfSync": "Pool out of sync", - "pool.liquidity.ownershipWarning.message": "You are not the owner of this LP position. You will not be able to withdraw the liquidity from this position unless you own the following address: {{ ownerAddress }}", + "pool.liquidity.outOfSync.message": "This pool is out of sync with market prices. Adding liquidity at the suggested ratios may result in loss of funds.", + "pool.liquidity.ownershipWarning.message": "You are not the owner of this LP position. You will not be able to withdraw the liquidity from this position unless you own the following address: {{ownerAddress}}", "pool.liquidity.rewards": "Liquidity provider rewards", - "pool.liquidity.taxWarning.message": "One or more of these tokens have taxes on transfers. Adding liquidity with V3 may result in loss of funds. Try using V2 instead.", "pool.liquidity.taxWarning": "Token taxes", + "pool.liquidity.taxWarning.message": "One or more of these tokens have taxes on transfers. Adding liquidity with V3 may result in loss of funds. Try using V2 instead.", "pool.liquidityPoolFeesNotice": "When you add liquidity, you will receive pool tokens representing your position. These tokens automatically earn fees proportional to your share of the pool, and can be redeemed at any time.", "pool.manageRewardsLiquidity": "Manage liquidity in rewards pool", "pool.max.label": "Max:", @@ -804,19 +774,19 @@ "pool.min.label": "Min:", "pool.minPrice": "Min price", "pool.mustBeInitialized": "This pool must be initialized before you can add liquidity. To initialize, select a starting price for the pool. Then, enter your liquidity price range and deposit amount. Gas fees will be higher than usual due to the initialization transaction.", - "pool.newPosition.plus": "+ New position", "pool.newPosition": "New position", + "pool.newPosition.plus": "+ New position", "pool.noLiquidity": "No liquidity found.", "pool.onceHappyReview": "Once you are happy with the rate click supply to review.", "pool.openToStart": "Open a new position or create a pool to get started.", "pool.owner": "Owner", "pool.percent": "{{pct}}% pool", "pool.pooled": "Pooled {{sym}}:", - "pool.position.100.at": "Your position will be 100% {{symbol}} at this price.", + "pool.position": "Your positions", "pool.position.100": "Your position will be 100% at this price.", + "pool.position.100.at": "Your position will be 100% {{symbol}} at this price.", "pool.position.networkConnect": "To view a position, you must be connected to the network it belongs to.", "pool.position.willBe100": "Your position will be 100% composed of {{sym}} at this price", - "pool.position": "Your positions", "pool.positions": "Positions", "pool.priceRange": "Price range", "pool.rangeBadge.tooltip.outsideRange": "The price of this pool is outside of your selected range. Your position is not currently earning fees.", @@ -828,8 +798,8 @@ "pool.rewardsPool.label": "Pool tokens in rewards pool:", "pool.selectedRange": "Selected range", "pool.selectPair": "Select pair", - "pool.share.label": "Your pool share:", "pool.share": "Prices and pool share", + "pool.share.label": "Your pool share:", "pool.shareOf": "Share of Pool:", "pool.showClosed": "Show closed positions", "pool.startingPrice": "Starting {{sym}} Price:", @@ -838,9 +808,9 @@ "pool.top": "Top pools", "pool.totalTokens": "Your total pool tokens:", "pool.unclaimedFees": "Unclaimed fees", + "pool.v2": "v2 pools", "pool.v2.add": "Add V2 liquidity", "pool.v2.migratev3": "Migrate liquidity to V3", - "pool.v2": "v2 pools", "pool.v2liquidity": "V2 liquidity", "pool.v3": "v3 pools", "pool.volume.sevenDay": "7 day volume", @@ -873,34 +843,27 @@ "proposal.queue.delay": "Adding this proposal to the queue will allow it to be executed, after a delay.", "proposal.queueId": "Queue proposal {{proposalId}}", "proposal.queueing": "Queueing", - "proposal.readTheDocs": "read the docs", "proposal.title": "Proposal Title", "proposal.willAppearHere": "Proposals submitted by community members will appear here.", "proposal.willEnact": "Executing this proposal will enact the calldata on-chain.", "removeLiquidity.collectFees": "You will also collect fees earned from this position.", - "quickKey.swap": "U", - "quickKey.limit": "L", - "quickKey.send": "E", - "quickKey.buy": "B", - "quickKey.tokens": "T", - "quickKey.pools": "P", - "quickKey.transactions": "X", - "quickKey.nfts": "N", - "removeLiquidity.outputEstimated": "Output is estimated. If the price changes by more than {{ allowed }}% your transaction will revert.", - "removeLiquidity.pendingText": "Removing {{ amtA }} {{ symA }} and {{ amtB }} {{ symB }}", - "removeLiquidity.pooled": "Pooled {{ symbol }}:", - "removeLiquidity.removing": "Removing {{ amt1 }} {{ symbol1}} and {{ amt2 }} {{ symbol2 }}", + "removeLiquidity.outputEstimated": "Output is estimated. If the price changes by more than {{allowed}}% your transaction will revert.", + "removeLiquidity.pendingText": "Removing {{amtA}} {{symA}} and {{amtB}} {{symB}}", + "removeLiquidity.pooled": "Pooled {{symbol}}:", + "removeLiquidity.removing": "Removing {{amt1}} {{symbol1}} and {{amt2}} {{symbol2}}", "removeLiquidity.removingTokensTip": "Tip: Removing pool tokens converts your position back into underlying tokens at the current rate, proportional to your share of the pool. Accrued fees are included in the amounts you receive.", "removeLiquidity.uniBurned": "UNI {{a}}/{{b}} Burned", "routing.aggregateLiquidity": "When available, aggregates liquidity sources for better prices and gas free swaps.", "routing.cheapest": "The Uniswap client selects the cheapest trade option factoring price and network costs.", "search.avalancheComing": "Coming soon: search and explore tokens on Avalanche Chain", - "search.recent": "Recent searches", + "search.results.count_one": "1 item", + "search.results.count_other": "{{count}} items", "search.ukDisclaimer": "Disclaimer for UK residents", "sendRecipientForm.recentAddresses.label": "Recents", "sendReviewModal.title": "Review send", "settings.hideSmallBalances": "Hide small balances", "settings.maxSlippage": "Max. slippage", + "settings.setting.appearance.option.auto": "Auto", "settings.showTestNets": "Show testnets", "settings.switchNetwork.warning": "To use Uniswap on {{label}}, switch the network in your wallet’s settings.", "speedBump.newAddress.warning.description": "You haven’t transacted with this address before. Make sure it’s the correct address before continuing.", @@ -912,13 +875,13 @@ "stats.allTimeFees": "All time LP fees", "stats.allTimeSwappers": "All time swappers", "stats.allTimeVolume": "All time volume", - "stats.fdv.description": "Fully diluted valuation (FDV) calculates the total market value assuming all tokens are in circulation.", "stats.fdv": "FDV", - "stats.marketCap.description": "Market capitalization is the total market value of an asset’s circulating supply.", + "stats.fdv.description": "Fully diluted valuation (FDV) calculates the total market value assuming all tokens are in circulation.", "stats.marketCap": "Market cap", + "stats.marketCap.description": "Market capitalization is the total market value of an asset’s circulating supply.", "stats.tvl.description": "Total value locked (TVL) is the aggregate amount of the asset available across all Uniswap v3 liquidity pools.", - "stats.volume.1d.description": "1 day volume is the amount of the asset that has been traded on Uniswap v3 during the past 24 hours.", "stats.volume.1d": "1 day volume", + "stats.volume.1d.description": "1 day volume is the amount of the asset that has been traded on Uniswap v3 during the past 24 hours.", "stats.volume.description": "Volume is the amount of the asset that has been traded on Uniswap v3 during the selected time frame.", "swap.allow.oneTime": "Allow {{sym}} (one time)", "swap.approvalNeeded": "An approval is needed to use this token. ", @@ -928,25 +891,37 @@ "swap.approveInWallet": "Approve in your wallet", "swap.balance.amount": "Balance: {{amount}}", "swap.bestRoute.cost": "Best price route costs ~{{gasPrice}} in gas. ", - "swap.cancel.cannotExecute.plural": "Your swaps could execute before cancellation is processed. Your network costs cannot be refunded. Do you wish to proceed?", + "swap.buy.countryModal.placeholder": "Search by country or region", "swap.cancel.cannotExecute": "Your swap could execute before cancellation is processed. Your network costs cannot be refunded. Do you wish to proceed?", + "swap.cancel.cannotExecute.plural": "Your swaps could execute before cancellation is processed. Your network costs cannot be refunded. Do you wish to proceed?", "swap.confirmLimit": "Confirm limit", "swap.confirmSwap": "Confirm swap", + "swap.details.feeOnTransfer": "{{tokenSymbol}} fee", + "swap.details.feeOnTransfer.default": "Token fee", "swap.enterAmount": "Enter {{sym}} amount", + "swap.error.default": "You may need to increase your slippage tolerance. Note: fee-on-transfer and rebase tokens are incompatible with Uniswap V3.", "swap.error.expectedToFail": "Your swap is expected to fail.", "swap.error.modifiedByWallet": "Your swap was modified through your wallet. If this was a mistake, please cancel immediately or risk losing your funds.", + "swap.error.rejected": "Transaction rejected", + "swap.error.undefinedObject": "An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If that does not work, there may be an incompatibility with the token you are trading. Note: fee-on-transfer and rebase tokens are incompatible with Uniswap V3.", + "swap.error.unknown": "Unknown error.", + "swap.error.v2.expired": "This transaction could not be sent because the deadline has passed. Please check that your transaction deadline is not too low.", + "swap.error.v2.k": "The Uniswap invariant x*y=k was not satisfied by the swap. This usually means one of the tokens you are swapping incorporates custom behavior on transfer.", + "swap.error.v2.slippage": "This transaction will not succeed either due to price movement or fee on transfer. Try increasing your slippage tolerance", + "swap.error.v2.transferInput": "The input token cannot be transferred. There may be an issue with the input token.", + "swap.error.v2.transferOutput": "The output token cannot be transferred. There may be an issue with the output token.", + "swap.error.v3.slippage": "This transaction will not succeed due to price movement. Try increasing your slippage tolerance. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3.", + "swap.error.v3.transferOutput": "The output token cannot be transferred. There may be an issue with the output token. Note: fee on transfer and rebase tokens are incompatible with Uniswap V3.", "swap.estimatedDifference.label": "The estimated difference between the USD values of input and output amounts.", "swap.fail.message": "Try adjusting slippage to a higher value.", "swap.fail.uniswapX": "Swap couldn’t be completed with UniswapX. Try your swap again to route it through the classic Uniswap API.", - "swap.failed.label": "Your swap could not be executed. Please check your network connection and your slippage settings.", "swap.fees.experience": "This fee is applied on select token pairs to ensure the best experience with Uniswap. It is paid in the output token and has already been factored into the quote.", "swap.fees.noFee": "Fees are applied on select token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap.", "swap.fetchingBestPrice": "Fetching best price...", "swap.fetchingPrice": "Fetching price...", "swap.finalizingQuote": "Finalizing quote...", "swap.form.insufficientLiquidity": "Insufficient liquidity for this trade.", - "swap.form.pay": "You pay", - "swap.form.pocketUniverseExtension.warning": "The Pocket Universe extension violates our <termsLink /> by modifying our product in a way that is misleading and could harm users. Please disable the extension and reload the page.", + "swap.form.pocketUniverseExtension.warning": "The Pocket Universe extension violates our <termsLink /> by modifying our product in a way that is misleading and could harm users. Please disable the extension and reload the page.", "swap.form.swapAnywayAction": "Swap anyway", "swap.frontrun.warning": "Your transaction may be frontrun and result in an unfavorable trade.", "swap.impactOfTrade": "The impact your trade has on the market price of this pool.", @@ -957,8 +932,7 @@ "swap.limitSubmitted": "Limit submitted", "swap.marketPrice.outsideRange.label": "The market price is outside your specified price range. Single-asset deposit only.", "swap.maxPriceSlip.revert": "The maximum amount you are guaranteed to spend. If the price slips any further, your transaction will revert.", - "swap.minPriceSlip.revert": "If the price moves so that you will receive less than {{ amount }}, your transaction will revert.", - "swap.namedFee": "{{name}} fee", + "swap.minPriceSlip.revert": "If the price moves so that you will receive less than {{amount}}, your transaction will revert.", "swap.networkCost.paidIn": "Network cost is paid in {{sym}} on the {{chainName}} network in order to transact.", "swap.orderRouting": "Order routing", "swap.outputEstimated.atLeast": "Output is estimated. You will receive at least <amount /> or the transaction will revert.", @@ -966,9 +940,8 @@ "swap.payAtMost": "Pay at most", "swap.payWith": "Pay with", "swap.placeOrder": "Place order", - "swap.priceImpact.high": "A swap of this size may have a high price impact, given the current liquidity in the pool. There may be a large difference between the amount of your input token and what you will receive in the output token", - "swap.priceImpact.upperCase": "Price Impact", "swap.priceImpact": "Price impact", + "swap.priceImpact.high": "A swap of this size may have a high price impact, given the current liquidity in the pool. There may be a large difference between the amount of your input token and what you will receive in the output token", "swap.receive.atLeast": "Receive at least", "swap.review": "Review swap", "swap.reviewLimit": "Review limit", @@ -976,8 +949,6 @@ "swap.settings.transactionRevertPrice": "Your transaction will revert if the price changes unfavorably by more than this percentage.", "swap.signAndSwap": "Sign and swap", "swap.slippage.amt": "{{amt}} slippage", - "swap.slippage.exactIn.revert": "If the price moves so that you will receive less than {{amount}}, your transaction will be reverted. This is the minimum amount you are guaranteed to receive.", - "swap.slippage.exactOut.revert": "If the price moves so that you will pay more than {{amount}}, your transaction will be reverted. This is the maximum amount you are guaranteed to pay.", "swap.slippage.tooltip": "The maximum price movement before your transaction will revert.", "swap.slippageBelow.warning": "Slippage below {{amt}} may result in a failed transaction", "swap.submitted": "Swap submitted", @@ -998,10 +969,11 @@ "tdp.invalidTokenPage.switchChainPrompt": "Switch to {{network}}", "tdp.invalidTokenPage.title": "This token doesn’t exist", "tdp.invalidTokenPage.titleWithChain": "This token doesn’t exist on {{network}}", - "tdp.loading.title": "token data for {{tokenLink}}{{chainSuffix}}", + "tdp.loading.title.default": "token data for <tokenLink />", + "tdp.loading.title.withChain": "token data for <tokenLink /> on {{chainName}}", "tdp.nameNotFound": "Name not found", "tdp.noInfoAvailable": "No token information available", - "tdp.stats.unsupportedChainDescription": "Token stats and charts for {{ chain }} are available on {{ infoLink }}", + "tdp.stats.unsupportedChainDescription": "Token stats and charts for {{chain}} are available on {{infoLink}}", "tdp.symbolNotFound": "Symbol not found", "themeToggle.theme": "Theme", "title.betterPricesMoreListings": "Better prices. More listings. Buy, sell, and trade NFTs across top marketplaces like OpenSea. Explore trending collections.", @@ -1029,14 +1001,49 @@ "title.useImportTool": "Use this import tool to find v2 pools that don’t automatically appear in the interface.", "title.voteOnGov": "Vote on governance proposals on Uniswap", "token.bridge": "{{label}} token bridge", + "token.chart.tooltip": "Fees: {{amount}}", "token.fee.buy.label": "buy fee:", "token.fee.label": "fee:", "token.fee.sell.label": "sell fee:", - "token.safety.cantTrade": "You can’t trade {{name}} using the Uniswap App.", - "token.safetyWarning": "Always conduct your own research before trading.", - "token.wrap.fail.message": "Swaps on the Uniswap Protocol can start and end with ETH. However, during the swap ETH is wrapped into WETH.", + "token.safety.warning.blocked.description.default_one": "You can’t trade this token using the Uniswap App.", + "token.safety.warning.blocked.description.default_other": "You can’t trade these tokens using the Uniswap App.", + "token.safety.warning.blocked.description.named": "You can’t trade {{tokenSymbol}} using the Uniswap App.", + "token.safety.warning.description": "Always conduct your own research before trading.", + "token.safety.warning.medium.heading.default_one": "This token isn’t traded on leading U.S. centralized exchanges.", + "token.safety.warning.medium.heading.default_other": "These tokens aren’t traded on leading U.S. centralized exchanges.", + "token.safety.warning.medium.heading.named": "{{tokenSymbol}} isn’t traded on leading U.S. centralized exchanges.", + "token.safety.warning.strong.heading.default_one": "This token isn’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap.", + "token.safety.warning.strong.heading.default_other": "These tokens aren’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap.", + "token.safety.warning.strong.heading.named": "{{tokenSymbol}} isn’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap.", + "token.safetyLevel.blocked.header": "Not available", + "token.safetyLevel.blocked.message": "You can’t trade this token using the Uniswap Wallet.", + "token.safetyLevel.medium.header": "Caution", + "token.safetyLevel.medium.message": "This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading.", + "token.safetyLevel.strong.header": "Warning", + "token.safetyLevel.strong.message": "This token isn’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap. Always conduct your own research before trading.", + "token.selector.search.error": "Couldn’t load search results", + "token.wrap.fail.message": "Swaps on the Uniswap Protocol can start and end with ETH. However, during the swap, ETH is wrapped into WETH.", "tokens.noneFound": "No tokens found.", - "transaction.completed.in": "Transaction completed in ", + "tokens.selector.button.choose": "Select token", + "tokens.selector.button.clear": "Clear all", + "tokens.selector.empty.buy.message": "Buy crypto with a card or bank to send tokens.", + "tokens.selector.empty.buy.title": "Buy crypto", + "tokens.selector.empty.receive.message": "Transfer tokens from a centralized exchange or another wallet to send tokens.", + "tokens.selector.empty.receive.title": "Receive tokens", + "tokens.selector.empty.title": "No tokens yet", + "tokens.selector.error.load": "Couldn’t load tokens", + "tokens.selector.search.empty": "No results found for <highlight>{{searchText}}</highlight>", + "tokens.selector.search.placeholder": "Search tokens", + "tokens.selector.section.favorite": "Favorites", + "tokens.selector.section.popular": "Popular tokens", + "tokens.selector.section.recent": "Recent searches", + "tokens.selector.section.search": "Search results", + "tokens.selector.section.suggested": "Suggested", + "tokens.selector.section.yours": "Your tokens", + "tokens.table.search.placeholder.pools": "Search pools", + "tokens.table.search.placeholder.tokens": "Search tokens", + "transaction.confirmation.completionTime_one": "Transaction completed in <highlight>{{count}}</highlight> second 🎉", + "transaction.confirmation.completionTime_other": "Transaction completed in <highlight>{{count}}</highlight> seconds 🎉", "transaction.confirmation.pending.wallet": "Confirm transaction in wallet", "transaction.confirmation.submitted.currency.add": "Add {{currency}}", "transaction.confirmation.submitted.currency.added": "Added {{currency}}", @@ -1044,8 +1051,6 @@ "transaction.insufficientLiquidity": "Insufficient pool liquidity to complete transaction", "transaction.network.all": "All networks", "uni.addDelegate": "Add delegate +", - "uni.claim.notAvailable": "Address has no available claim", - "uni.claim": "Claim UNI", "uni.delegateVotes": "Delegate votes", "uni.delegatingVotes": "Delegating votes", "uni.removeDelegate": "Remove delegate", @@ -1053,19 +1058,17 @@ "uni.unlockingVotes": "Unlocking votes", "uni.voteOrDelegate": "You can either vote on each proposal yourself or delegate your votes to a third party.", "uni.votingShares": "Earned UNI tokens represent voting shares in Uniswap governance.", - "uni.welcome": "Welcome to team Unicorn :) ", + "uniswap.wallet.modal.subtitle": "Uniswap products work seamlessly together to create the best swapping experience.", + "uniswap.wallet.modal.title": "Get started with Uniswap", "uniswapX.aggregatesLiquidity": "<logo /> aggregates liquidity sources for better prices and gas free swaps.", "uniswapX.learnMore": "Learn more about swapping with UniswapX", "uniswapx.v2QuoteFailed": "UniswapX v2 hard quote failed. Retry with classic swap.", - "uniswap.wallet.modal.title": "Get started with Uniswap", - "uniswap.wallet.modal.subtitle": "Uniswap products work seamlessly together to create the best swapping experience.", - "unitag.addressClaim": "Enter an address to trigger a UNI claim. If the address has any claimable UNI it will be sent to them on submission.", "v2.notAvailable": "Uniswap V2 is not available on this network.", "v2.switchTo": "Switch to v2", "v3.blast.yield.usdbAndWeth": "On Blast, USDB and WETH are rebasing tokens that automatically earn yield. Due to incompatibility with Uniswap v3, LP positions with USDB or WETH won’t earn rebasing yield, but will in Uniswap v2.", "v3.continue": "Continue on v3", "v3.rebase.unavailable": "Rebasing is unavailable on v3", - "vote.create.prompt": "Tip: Select an action and describe your proposal for the community. The proposal cannot be modified after submission, so please verify all information before submitting. The voting period will begin immediately and last for 7 days. To propose a custom action, <0>read the docs.</0>", + "vote.create.prompt": "Tip: Select an action and describe your proposal for the community. The proposal cannot be modified after submission, so please verify all information before submitting. The voting period will begin immediately and last for 7 days. To propose a custom action, <link>read the docs.</link>", "vote.landing.createProposal": "Create proposal", "vote.landing.delegatedTo": "Delegated to:", "vote.landing.edit": "(edit)", @@ -1076,44 +1079,42 @@ "vote.landing.showCancelled": "Show cancelled", "vote.landing.uniswapGovernance": "Uniswap governance", "vote.landing.unlockVoting": "Unlock voting", - "vote.landing.voteAmount": "{{ amount }} Votes", + "vote.landing.voteAmount": "{{amount}} Votes", "vote.proposal.activeOrPendingProposal": "You already have an active or pending proposal", "vote.proposal.approveToken": "Approve token", "vote.proposal.notEnoughVotes": "You don’t have enough votes to submit a proposal", "vote.proposal.submitted": "Proposal submitted", "vote.proposal.title": "Proposal", "vote.proposal.transferToken": "Transfer token", - "vote.proposal.voteThreshold": "You must have {{ formattedProposalThreshold }} votes to submit a proposal", + "vote.proposal.voteThreshold": "You must have {{formattedProposalThreshold}} votes to submit a proposal", "vote.styled.active": "Active", "vote.styled.defeated": "Defeated", "vote.styled.succeeded": "Succeeded", "vote.styled.undetermined": "Undetermined", "vote.submitting": "Submitting vote", "vote.votePage.against": "Against", - "vote.votePage.allProposals": "{{ arrow }} All Proposals", + "vote.votePage.allProposals": "All Proposals", "vote.votePage.description": "Description", "vote.votePage.details": "Details", "vote.votePage.execute": "Execute", - "vote.votePage.mayBeExecutedAfter": "This proposal may be executed after {{ eta }}.", - "vote.votePage.onlyUniVotesBeforeBlockEligible": "Only UNI votes that were self delegated or delegated to another address before block {{ startBlock }} are eligible for voting.", + "vote.votePage.mayBeExecutedAfter": "This proposal may be executed after {{eta}}.", + "vote.votePage.onlyUniVotesBeforeBlockEligible": "Only UNI votes that were self delegated or delegated to another address before block {{startBlock}} are eligible for voting.", "vote.votePage.proposer": "Proposer", "vote.votePage.unlockVotes": "Unlock votes", - "vote.votePage.unlockVotingLink": "{{ link }} to prepare for the next proposal.", + "vote.votePage.unlockVotingLink": "{{link}} to prepare for the next proposal.", "vote.votePage.updateDelegation": "Update delegation", "vote.votePage.voteAgainst": "Vote against", "vote.votePage.voteFor": "Vote for", - "vote.votePage.votingEnded": "Voting ended {{ date }}", - "vote.votePage.votingEnds": "Voting ends approximately {{ date }}", - "vote.votePage.votingStarts": "Voting starts approximately {{ date }}", - "wallet.backToSelection": "Back to wallet selection", - "wallet.connectingAgreement": "By connecting a wallet, you agree to Uniswap Labs’", + "vote.votePage.votingEnded": "Voting ended {{date}}", + "vote.votePage.votingEnds": "Voting ends approximately {{date}}", + "vote.votePage.votingStarts": "Voting starts approximately {{date}}", + "wallet.connectingAgreement": "By connecting a wallet, you agree to Uniswap Labs’ <termsLink>Terms of Service</termsLink> and consent to its <privacyLink>Privacy Policy</privacyLink>.", "wallet.connectionFailed.message": "The connection attempt failed. Please click try again and follow the steps to connect in your wallet.", - "wallet.networkUnsupported": "Your wallet’s current network is unsupported.", "wallet.other": "Other wallets", - "wallet.privacyPolicyPeriod": "Privacy Policy.", "wallet.scanToConnect": "Scan QR code to connect", - "wallet.termsAndConsent": "and consent to its", "wallet.wrongNet": "Your wallet is connected to the wrong network.", - "week": "week", - "weeks": "weeks" + "web.explore.description": "Discover and research tokens on {{network}}. Explore top pools. View real-time prices, trading volume, TVL, charts, and transaction data.", + "web.explore.title.pools": "Explore top pools on {{network}} on Uniswap", + "web.explore.title.tokens": "Explore top tokens on {{network}} on Uniswap", + "web.explore.title.transactions": "Explore top transactions on {{network}} on Uniswap" } diff --git a/packages/uniswap/src/i18n/locales/web-translations/.gitkeep b/packages/uniswap/src/i18n/locales/web-translations/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/uniswap/src/i18n/shared.ts b/packages/uniswap/src/i18n/shared.ts new file mode 100644 index 00000000000..ae34a039265 --- /dev/null +++ b/packages/uniswap/src/i18n/shared.ts @@ -0,0 +1,3 @@ +export type PluralProps = { value: number; one: string; other: string } + +export class MissingI18nInterpolationError extends Error {} diff --git a/packages/uniswap/src/i18n/useTranslation.ts b/packages/uniswap/src/i18n/useTranslation.ts new file mode 100644 index 00000000000..e05a3d2b63d --- /dev/null +++ b/packages/uniswap/src/i18n/useTranslation.ts @@ -0,0 +1,15 @@ +import i18n, { t } from 'i18next' +import { useTranslation as useTranslationOG } from 'react-i18next' +import { isTestEnv } from 'utilities/src/environment' + +// the types in react-i18next are odd because it returns an array and an object +// so simplifying it to just be an object +type UseTranslationReturn = { + t: typeof t + i18n: typeof i18n + ready: boolean +} + +export const useTranslation: () => UseTranslationReturn = isTestEnv() + ? (): UseTranslationReturn => ({ i18n, t, ready: true }) + : useTranslationOG diff --git a/packages/uniswap/src/react-native-dotenv.d.ts b/packages/uniswap/src/react-native-dotenv.d.ts index aa54c55db59..811436e8eb4 100644 --- a/packages/uniswap/src/react-native-dotenv.d.ts +++ b/packages/uniswap/src/react-native-dotenv.d.ts @@ -3,17 +3,25 @@ declare module 'react-native-dotenv' { export const APPSFLYER_API_KEY: string export const APPSFLYER_APP_ID: string + export const DATADOG_CLIENT_TOKEN: string + export const DATADOG_PROJECT_ID: string export const UNISWAP_API_KEY: string export const INFURA_KEY: string - export const INFURA_PROJECT_ID: string export const SENTRY_DSN: string export const SIMPLEHASH_API_KEY: string export const SIMPLEHASH_API_URL: string export const STATSIG_PROXY_URL: string export const ONESIGNAL_APP_ID: string + export const OPENAI_API_KEY: string export const WALLETCONNECT_PROJECT_ID: string export const QUICKNODE_ARBITRUM_RPC_URL: string + export const QUICKNODE_AVAX_RPC_URL: string + export const QUICKNODE_BASE_RPC_URL: string + export const QUICKNODE_BLAST_RPC_URL: string export const QUICKNODE_BNB_RPC_URL: string + export const QUICKNODE_CELO_RPC_URL: string + export const QUICKNODE_OP_RPC_URL: string + export const QUICKNODE_POLYGON_RPC_URL: string export const QUICKNODE_ZORA_RPC_URL: string export const QUICKNODE_ZKSYNC_RPC_URL: string export const QUICKNODE_MAINNET_RPC_URL: string diff --git a/packages/uniswap/src/test/fixtures/testIDs.ts b/packages/uniswap/src/test/fixtures/testIDs.ts index f5554a7350c..f213080ec29 100644 --- a/packages/uniswap/src/test/fixtures/testIDs.ts +++ b/packages/uniswap/src/test/fixtures/testIDs.ts @@ -20,6 +20,7 @@ export const TestID = { CreateAccount: 'create-account', Done: 'done', Edit: 'edit', + ExploreSearchInput: 'explore-search-input', Favorite: 'favorite', ImportAccount: 'import-account', ImportAccountInput: 'import-account-input', @@ -32,7 +33,12 @@ export const TestID = { QRCodeModalToggle: 'qr-code-modal-toggle', PortfolioBalance: 'portfolio-balance', PortfolioRelativeChange: 'portfolio-relative-change', + PriceExplorerAnimatedNumber: 'price-explorer-animated-number', + PriceExplorerChart: 'price-explorer-chart', + PriceText: 'price-text', + ReadMoreButton: 'read-more-button', Remove: 'remove', + RelativePriceChange: 'relative-price-change', ReviewSwap: 'review-swap', RestoreFromCloud: 'restore-from-cloud', RestoreWallet: 'restore-wallet', @@ -43,6 +49,7 @@ export const TestID = { SearchTokensAndWallets: 'search-tokens-and-wallets', SelectRecipient: 'select-recipient', Send: 'send', + SendModalHeaderLabel: 'send-modal-header-label', SendReview: 'send-review', SetMaxInput: 'set-max-input', SetMaxOutput: 'set-max-output', @@ -53,7 +60,16 @@ export const TestID = { SwapFormHeader: 'swap-form-header', SwapSettings: 'swap-settings', SwitchCurrenciesButton: 'switch-currencies-button', + TokenDetailsAboutHeader: 'token-details-about-header', + TokenDetailsBuyButton: 'token-details-buy-button', + TokenDetailsMoreButton: 'token-details-more-button', + TokenDetailsSellButton: 'token-details-sell-button', + TokenDetailsHeaderText: 'token-details-header-text', TokenSelectorToggle: 'token-selector-toggle', + TokenLinkCopy: 'token-link-copy', + TokenLinkEtherscan: 'token-link-etherscan', + TokenLinkTwitter: 'token-link-twitter', + TokenLinkWebsite: 'token-link-website', TokensTab: 'tokens-tab', TokenWarningAccept: 'token-warning-accept', WalletCard: 'wallet-card', @@ -66,3 +82,7 @@ export const TestID = { } as const export type TestIDType = (typeof TestID)[keyof typeof TestID] + +export type TestIDIterableType = `${(typeof TestID)[keyof typeof TestID]}-${number}` + +export type TestIDwithSufixType = `${(typeof TestID)[keyof typeof TestID]}-${string}` diff --git a/packages/uniswap/src/test/render.tsx b/packages/uniswap/src/test/render.tsx index f6ed5852a44..764a2c51e23 100644 --- a/packages/uniswap/src/test/render.tsx +++ b/packages/uniswap/src/test/render.tsx @@ -2,7 +2,7 @@ import { render as RNRender, RenderOptions, RenderResult } from '@testing-librar import { PropsWithChildren } from 'react' import { TamaguiProvider } from 'ui/src' import { config as tamaguiConfig } from 'ui/src/tamagui.config' -import 'uniswap/src/i18n/i18n' +import 'uniswap/src/i18n' /** * diff --git a/packages/uniswap/src/test/utils/factory.ts b/packages/uniswap/src/test/utils/factory.ts index 5e44e7812c1..a215ad89761 100644 --- a/packages/uniswap/src/test/utils/factory.ts +++ b/packages/uniswap/src/test/utils/factory.ts @@ -1,4 +1,4 @@ -import { omit, pick } from 'lodash' +import { omit, pick } from 'es-toolkit' /** * This utility function, `createFixture`, generates a factory function for creating test data fixtures. It is designed to support @@ -122,15 +122,19 @@ export function createFixture<T extends object, P extends object>( typeof defaultOptionsOrGetter === 'function' ? defaultOptionsOrGetter() : defaultOptionsOrGetter // Get overrides for options (filter out undefined values) const optionOverrides = Object.fromEntries( - Object.entries(defaultOptions ? pick(overrides, Object.keys(defaultOptions)) : {}).filter( - ([, value]) => value !== undefined, - ), + Object.entries( + defaultOptions + ? pick(overrides || ({} as { [key in string]: unknown }), Object.keys(defaultOptions) || []) + : {}, + ).filter(([, value]) => value !== undefined), ) // Get values with getValues function const mergedOptions = defaultOptions ? { ...defaultOptions, ...optionOverrides } : undefined const values = getValues(mergedOptions) // Get overrides for values - const valueOverrides = overrides ? omit(overrides, Object.keys(defaultOptions || {})) : {} + const valueOverrides = overrides + ? omit(overrides as { [key in string]: unknown }, Object.keys(defaultOptions || [])) + : {} return Array.isArray(values) ? // eslint-disable-next-line @typescript-eslint/no-unsafe-return values.map((v) => ({ ...v, ...valueOverrides })) diff --git a/packages/uniswap/src/types/screens/extension.ts b/packages/uniswap/src/types/screens/extension.ts index f666aa761c1..0ae58c45b5c 100644 --- a/packages/uniswap/src/types/screens/extension.ts +++ b/packages/uniswap/src/types/screens/extension.ts @@ -6,6 +6,7 @@ export enum HomeTabs { export enum ExtensionScreens { Home = 'home', + PopupOpenExtension = 'PopupOpenExtension', UnsupportedBrowserScreen = 'UnsupportedBrowserScreen' } diff --git a/packages/uniswap/src/utils/addresses.ts b/packages/uniswap/src/utils/addresses.ts index 30001ca304a..9b6215544fc 100644 --- a/packages/uniswap/src/utils/addresses.ts +++ b/packages/uniswap/src/utils/addresses.ts @@ -34,7 +34,10 @@ export function getValidAddress(address: Maybe<string>, withChecksum = false, lo return getAddress(addressWith0x) } catch (error) { if (log) { - logger.warn('utils/addresses', 'getValidAddress', `Invalid address at checksum: ${address}`) + logger.warn('utils/addresses', 'getValidAddress', 'Invalid address at checksum', { + data: address, + stacktrace: new Error().stack, + }) } return null } @@ -42,7 +45,10 @@ export function getValidAddress(address: Maybe<string>, withChecksum = false, lo if (addressWith0x.length !== 42) { if (log) { - logger.warn('utils/addresses', 'getValidAddress', `Address has an invalid format: ${address}`) + logger.warn('utils/addresses', 'getValidAddress', 'Address has an invalid format', { + data: address, + stacktrace: new Error().stack, + }) } return null } diff --git a/packages/uniswap/src/utils/currencyId.ts b/packages/uniswap/src/utils/currencyId.ts index ac060f784e7..9ecf8aa8d83 100644 --- a/packages/uniswap/src/utils/currencyId.ts +++ b/packages/uniswap/src/utils/currencyId.ts @@ -10,7 +10,7 @@ export function currencyId(currency: Currency): CurrencyId { return buildCurrencyId(currency.chainId, currencyAddress(currency)) } -export function buildCurrencyId(chainId: WalletChainId, address: string): string { +export function buildCurrencyId(chainId: UniverseChainId, address: string): string { return `${chainId}-${address}` } diff --git a/packages/utilities/package.json b/packages/utilities/package.json index 4daa293d9bb..964d3ac055e 100644 --- a/packages/utilities/package.json +++ b/packages/utilities/package.json @@ -8,6 +8,7 @@ "@amplitude/analytics-types": "0.13.0", "@apollo/client": "3.10.4", "@datadog/browser-logs": "^5.20.0", + "@datadog/mobile-react-native": "2.4.1", "@ethersproject/abstract-signer": "5.7.0", "@ethersproject/address": "5.7.0", "@ethersproject/constants": "5.7.0", diff --git a/packages/utilities/src/format/urls.test.ts b/packages/utilities/src/format/urls.test.ts index 47c761dc351..a5e375fa93f 100644 --- a/packages/utilities/src/format/urls.test.ts +++ b/packages/utilities/src/format/urls.test.ts @@ -148,6 +148,11 @@ describe(isGifUri, () => { }) describe(formatDappURL, () => { + it('removes query params from url', () => { + expect(formatDappURL('example.com?test=true')).toEqual('example.com') + expect(formatDappURL('example.com?test=true&test2=false')).toEqual('example.com') + }) + it('removes prefix from url', () => { expect(formatDappURL('https://example.com')).toEqual('example.com') expect(formatDappURL('https://www.example.com')).toEqual('example.com') diff --git a/packages/utilities/src/format/urls.ts b/packages/utilities/src/format/urls.ts index b7c5aab4136..19c47fc0aab 100644 --- a/packages/utilities/src/format/urls.ts +++ b/packages/utilities/src/format/urls.ts @@ -76,11 +76,19 @@ export function isGifUri(uri: Maybe<string>): boolean { return isSegmentUri(uri, '.gif') } +function truncateQueryParams(url: string): string { + // In fact, the first element will be always returned below. url is + // added as a fallback just to satisfy TypeScript. + return url.split('?')[0] ?? url +} + /** - * Removes safe prefixes and trailing slashes from URL to improve human readability. + * Removes query params, safe prefixes and trailing slashes from URL to improve human readability. * * @param {string} url The URL to check. */ export function formatDappURL(url: string): string { - return url?.replace('https://', '').replace('www.', '').replace(/\/$/, '') + const truncatedURL = truncateQueryParams(url) + + return truncatedURL?.replace('https://', '').replace('www.', '').replace(/\/$/, '') } diff --git a/packages/utilities/src/logger/Datadog.native.ts b/packages/utilities/src/logger/Datadog.native.ts new file mode 100644 index 00000000000..08dba9c56f9 --- /dev/null +++ b/packages/utilities/src/logger/Datadog.native.ts @@ -0,0 +1,42 @@ +import { DdLogs } from '@datadog/mobile-react-native' +import { LogLevel, LoggerErrorContext } from 'utilities/src/logger/types' + +export function logErrorToDatadog(error: Error, context: LoggerErrorContext): void { + DdLogs.error(error instanceof Error ? (error as Error).message : 'Unknown error', { + ...context, + }).catch(() => {}) +} + +export function logWarningToDatadog( + message: string, + { + level: _level, + ...options + }: { + level: LogLevel + args: unknown[] + fileName: string + functionName: string + }, +): void { + DdLogs.warn(message, { + ...options, + }).catch(() => {}) +} + +export function logToDatadog( + message: string, + { + level: _level, + ...options + }: { + level: LogLevel + args: unknown[] + fileName: string + functionName: string + }, +): void { + DdLogs.info(message, { + ...options, + }).catch(() => {}) +} diff --git a/packages/utilities/src/logger/Datadog.ts b/packages/utilities/src/logger/Datadog.ts index 82ae814b006..4e6d484c494 100644 --- a/packages/utilities/src/logger/Datadog.ts +++ b/packages/utilities/src/logger/Datadog.ts @@ -1,75 +1,34 @@ -import { datadogLogs } from '@datadog/browser-logs' -import { getEnvName, isTestEnv } from 'utilities/src/environment' +import { NotImplementedError } from 'utilities/src/errors' import { LogLevel, LoggerErrorContext } from 'utilities/src/logger/types' -import { v4 as uuidv4 } from 'uuid' - -// setup user information -const USER_ID_KEY = 'datadog-user-id' export function setupDatadog(): void { - if (isTestEnv()) { - return - } - if (!process.env.REACT_APP_DATADOG_CLIENT_TOKEN) { - // eslint-disable-next-line no-console - console.error(`No datadog client token, disabling`) - return - } - - datadogLogs.init({ - clientToken: process.env.REACT_APP_DATADOG_CLIENT_TOKEN, - site: 'datadoghq.com', - forwardErrorsToLogs: true, - }) - - let userId = localStorage.getItem(USER_ID_KEY) - if (!userId) { - localStorage.setItem(USER_ID_KEY, (userId = uuidv4())) - } - datadogLogs.setUser({ - id: userId, - }) - - datadogLogs.setUserProperty('env', getEnvName()) - datadogLogs.setUserProperty('version', process.env.REACT_APP_GIT_COMMIT_HASH) + throw new NotImplementedError('Please use the web implementation from Datadog.web.ts') } export function logToDatadog( - message: string, - { - level, - ...options - }: { + _message: string, + _options: { + level: LogLevel + args: unknown[] + fileName: string + functionName: string + }, +): void { + throw new NotImplementedError('Please use the web / native implementation from Datadog.web.ts or Datadog.native.ts') +} + +export function logWarningToDatadog( + _message: string, + _options: { level: LogLevel args: unknown[] fileName: string functionName: string }, ): void { - if (isTestEnv()) { - return - } - datadogLogs.logger[level](message, options) + throw new NotImplementedError('Please use the web / native implementation from Datadog.web.ts or Datadog.native.ts') } -export function logErrorToDatadog(error: Error, context?: LoggerErrorContext): void { - if (isTestEnv()) { - return - } - if (error instanceof Error) { - datadogLogs.logger.error(error.message, { - error: { - kind: error.name, - stack: error.stack, - }, - ...context, - }) - } else { - datadogLogs.logger.error(error, { - error: { - stack: new Error().stack, - }, - ...context, - }) - } +export function logErrorToDatadog(_error: Error, _context?: LoggerErrorContext): void { + throw new NotImplementedError('Please use the web / native implementation from Datadog.web.ts or Datadog.native.ts') } diff --git a/packages/utilities/src/logger/Datadog.web.ts b/packages/utilities/src/logger/Datadog.web.ts new file mode 100644 index 00000000000..773cdaa61f1 --- /dev/null +++ b/packages/utilities/src/logger/Datadog.web.ts @@ -0,0 +1,88 @@ +import { datadogLogs } from '@datadog/browser-logs' +import { getEnvName, isTestEnv } from 'utilities/src/environment' +import { NotImplementedError } from 'utilities/src/errors' +import { LogLevel, LoggerErrorContext } from 'utilities/src/logger/types' +import { v4 as uuidv4 } from 'uuid' + +// setup user information +const USER_ID_KEY = 'datadog-user-id' + +export function setupDatadog(): void { + if (isTestEnv()) { + return + } + if (!process.env.REACT_APP_DATADOG_CLIENT_TOKEN) { + // eslint-disable-next-line no-console + console.error(`No datadog client token, disabling`) + return + } + + datadogLogs.init({ + clientToken: process.env.REACT_APP_DATADOG_CLIENT_TOKEN, + site: 'datadoghq.com', + forwardErrorsToLogs: true, + }) + + let userId = localStorage.getItem(USER_ID_KEY) + if (!userId) { + localStorage.setItem(USER_ID_KEY, (userId = uuidv4())) + } + datadogLogs.setUser({ + id: userId, + }) + + datadogLogs.setUserProperty('env', getEnvName()) + datadogLogs.setUserProperty('version', process.env.REACT_APP_GIT_COMMIT_HASH) +} + +export function logToDatadog( + message: string, + { + level, + ...options + }: { + level: LogLevel + args: unknown[] + fileName: string + functionName: string + }, +): void { + if (isTestEnv()) { + return + } + datadogLogs.logger[level](message, options) +} + +export function logWarningToDatadog( + _message: string, + _options: { + level: LogLevel + args: unknown[] + fileName: string + functionName: string + }, +): void { + throw new NotImplementedError('Stub logWarningToDatadog to match native') +} + +export function logErrorToDatadog(error: Error, context?: LoggerErrorContext): void { + if (isTestEnv()) { + return + } + if (error instanceof Error) { + datadogLogs.logger.error(error.message, { + error: { + kind: error.name, + stack: error.stack, + }, + ...context, + }) + } else { + datadogLogs.logger.error(error, { + error: { + stack: new Error().stack, + }, + ...context, + }) + } +} diff --git a/packages/utilities/src/logger/logger.ts b/packages/utilities/src/logger/logger.ts index 7acb1b21757..9b23da56bc6 100644 --- a/packages/utilities/src/logger/logger.ts +++ b/packages/utilities/src/logger/logger.ts @@ -1,8 +1,8 @@ import { Extras } from '@sentry/types' -import { logErrorToDatadog, logToDatadog } from 'utilities/src/logger/Datadog' +import { logErrorToDatadog, logToDatadog, logWarningToDatadog } from 'utilities/src/logger/Datadog' import { Sentry } from 'utilities/src/logger/Sentry' import { LogLevel, LoggerErrorContext } from 'utilities/src/logger/types' -import { isInterface, isWeb } from 'utilities/src/platform' +import { isInterface, isMobile, isWeb } from 'utilities/src/platform' // weird temp fix: the web app is complaining about __DEV__ being global // i tried declaring it in a variety of places: @@ -60,8 +60,24 @@ function logMessage( if (level === 'warn') { Sentry.captureMessage('warning', `${fileName}#${functionName}`, message, ...args) + if (isMobile) { + logWarningToDatadog(message, { + level, + args, + functionName, + fileName, + }) + } } else if (level === 'info') { Sentry.captureMessage('info', `${fileName}#${functionName}`, message, ...args) + if (isMobile) { + logToDatadog(message, { + level, + args, + functionName, + fileName, + }) + } } if (isInterface) { @@ -98,7 +114,7 @@ function logException(error: unknown, captureContext: LoggerErrorContext): void } Sentry.captureException(error, updatedContext) - if (isInterface) { + if (isInterface || isMobile) { logErrorToDatadog(error instanceof Error ? error : new Error(`${error}`), updatedContext) } } diff --git a/packages/wallet/package.json b/packages/wallet/package.json index cf92884db91..232bfb1392c 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -7,8 +7,6 @@ "lint": "eslint . --ext ts,tsx --max-warnings=0", "lint:fix": "eslint . --ext ts,tsx --fix", "test": "jest", - "tradingapi:schema": "curl https://api.uniswap.org/v2/trade/api.json -o ./src/data/tradingApi/api.json", - "tradingapi:generate": "openapi --input ./src/data/tradingApi/api.json --output ./src/data/tradingApi/__generated__ --client axios --useOptions --exportServices true --exportModels true", "snapshots": "jest -u", "typecheck": "tsc -b" }, @@ -90,7 +88,6 @@ "jest": "29.7.0", "jest-extended": "4.0.1", "jest-presets": "workspace:^", - "openapi-typescript-codegen": "0.27.0", "react-native-apollo-devtools-client": "1.0.4", "react-native-mmkv-flipper-plugin": "1.0.0", "react-test-renderer": "18.2.0", diff --git a/packages/wallet/src/components/RecipientSearch/RecipientList.tsx b/packages/wallet/src/components/RecipientSearch/RecipientList.tsx index 0a7002ce089..abfcf8a6c0e 100644 --- a/packages/wallet/src/components/RecipientSearch/RecipientList.tsx +++ b/packages/wallet/src/components/RecipientSearch/RecipientList.tsx @@ -1,6 +1,6 @@ import { BottomSheetSectionList } from '@gorhom/bottom-sheet' -import { memo, useCallback, useState } from 'react' -import { Keyboard, ListRenderItemInfo, SectionList, SectionListData } from 'react-native' +import { memo, useCallback } from 'react' +import { ListRenderItemInfo, SectionList, SectionListData } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { Text, TouchableArea, isWeb, useDeviceInsets } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' @@ -8,7 +8,6 @@ import { spacing } from 'ui/src/theme' import { SearchResultType } from 'uniswap/src/features/search/SearchResult' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { ViewOnlyRecipientModal } from 'wallet/src/components/RecipientSearch/ViewOnlyRecipientModal' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { SearchableRecipient } from 'wallet/src/features/address/types' import { extractDomain } from 'wallet/src/features/search/SearchResult' @@ -22,28 +21,14 @@ interface RecipientListProps { export function RecipientList({ onPress, sections, renderedInModal = false }: RecipientListProps): JSX.Element { const insets = useDeviceInsets() - const [selectedViewOnlyRecipient, setSelectedViewOnlyRecipient] = useState<SearchableRecipient | null>(null) const onRecipientPress = useCallback( (recipient: SearchableRecipient) => { - if (recipient.type === AccountType.Readonly) { - Keyboard.dismiss() - setSelectedViewOnlyRecipient(recipient) - } else { - onPress(recipient.address) - } + onPress(recipient.address) }, [onPress], ) - const onConfirmViewOnlyRecipient = useCallback(() => { - const address = selectedViewOnlyRecipient?.address - if (address) { - setSelectedViewOnlyRecipient(null) - onPress(address) - } - }, [onPress, selectedViewOnlyRecipient]) - const renderItem = function ({ item }: ListRenderItemInfo<SearchableRecipient>): JSX.Element { return ( // TODO(EXT-526): re-enable `exiting` animation when it's fixed. @@ -69,19 +54,12 @@ export function RecipientList({ onPress, sections, renderedInModal = false }: Re sections={sections} showsVerticalScrollIndicator={false} /> - - {selectedViewOnlyRecipient && ( - <ViewOnlyRecipientModal - onCancel={(): void => setSelectedViewOnlyRecipient(null)} - onConfirm={onConfirmViewOnlyRecipient} - /> - )} </> ) } -function SectionHeader(info: { section: SectionListData<SearchableRecipient> }): JSX.Element { - return ( +function SectionHeader(info: { section: SectionListData<SearchableRecipient> }): JSX.Element | null { + return info.section.title ? ( <AnimatedFlex backgroundColor="$surface1" entering={FadeIn} @@ -93,7 +71,7 @@ function SectionHeader(info: { section: SectionListData<SearchableRecipient> }): {info.section.title} </Text> </AnimatedFlex> - ) + ) : null } function key(recipient: SearchableRecipient): string { @@ -125,6 +103,7 @@ export const RecipientRow = memo(function RecipientRow({ recipient, onPress }: R return ( <TouchableArea hapticFeedback onPress={onPressWithAnalytics}> <AddressDisplay + includeUnitagSuffix address={recipient.address} overrideDisplayName={isNonUnitagSubdomain && recipient.name ? recipient.name : undefined} showViewOnlyBadge={isViewOnlyWallet} diff --git a/packages/wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps.tsx b/packages/wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps.tsx new file mode 100644 index 00000000000..a9753f79a11 --- /dev/null +++ b/packages/wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps.tsx @@ -0,0 +1,166 @@ +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useSporeColors } from 'ui/src' +import { Eye } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { PaginatedModalRenderer } from 'uniswap/src/components/modals/PaginatedModals' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { isSameAddress } from 'utilities/src/addresses' +import { NewAddressWarningModal } from 'wallet/src/components/RecipientSearch/modals/NewAddressWarningModal' +import { ConditionalModalRenderer, SpeedBumps } from 'wallet/src/components/modals/SpeedBumps' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { useIsErc20Contract } from 'wallet/src/features/contracts/hooks' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { useAllTransactionsBetweenAddresses } from 'wallet/src/features/transactions/hooks/useAllTransactionsBetweenAddresses' +import { useIsSmartContractAddress } from 'wallet/src/features/transactions/transfer/hooks/useIsSmartContractAddress' +import { + useActiveAccountAddressWithThrow, + useSignerAccounts, + useViewOnlyAccounts, +} from 'wallet/src/features/wallet/hooks' + +interface RecipientSelectSpeedBumpsProps { + recipientAddress?: string + chainId?: WalletChainId + checkSpeedBumps: boolean + setCheckSpeedBumps: (value: boolean) => void + onConfirm: () => void +} + +export function RecipientSelectSpeedBumps({ + recipientAddress, + checkSpeedBumps, + chainId, + ...rest +}: RecipientSelectSpeedBumpsProps): JSX.Element | null { + const { t } = useTranslation() + const colors = useSporeColors() + + const activeAddress = useActiveAccountAddressWithThrow() + const viewOnlyAccounts = useViewOnlyAccounts() + const currentSignerAccounts = useSignerAccounts() + const previousTransactions = useAllTransactionsBetweenAddresses(activeAddress, recipientAddress) + const { isSmartContractAddress, loading: addressLoading } = useIsSmartContractAddress( + recipientAddress, + chainId ?? UniverseChainId.Mainnet, + ) + + const renderViewOnlyWarning = useCallback<PaginatedModalRenderer>( + (props) => ( + <WarningModal + backgroundIconColor={colors.surface2.val} + caption={t('send.recipient.warning.viewOnly.message')} + closeText={t('common.button.goBack')} + confirmText={t('common.button.understand')} + icon={<Eye color="$neutral2" size={iconSizes.icon24} />} + modalName={ModalName.RecipientSelectViewOnlyWarning} + severity={WarningSeverity.High} + title={t('send.recipient.warning.viewOnly.title')} + {...props} + /> + ), + [t, colors.surface2.val], + ) + + const renderNewAddressWarning = useCallback<PaginatedModalRenderer>( + (props) => (recipientAddress ? <NewAddressWarningModal address={recipientAddress} {...props} /> : null), + [recipientAddress], + ) + + const renderSelfSendWarning = useCallback<PaginatedModalRenderer>( + (props) => ( + <WarningModal + caption={t('send.warning.self.message')} + closeText={t('common.button.cancel')} + confirmText={t('common.button.understand')} + modalName={ModalName.RecipientSelectSelfSendWarning} + severity={WarningSeverity.High} + title={t('send.warning.self.title')} + {...props} + /> + ), + [t], + ) + + const renderErc20Warning = useCallback<PaginatedModalRenderer>( + (props) => ( + <WarningModal + caption={t('send.warning.erc20.message')} + closeText={t('common.button.cancel')} + confirmText={t('common.button.understand')} + modalName={ModalName.RecipientSelectErc20Warning} + severity={WarningSeverity.High} + title={t('send.warning.erc20.title')} + {...props} + /> + ), + [t], + ) + + const renderSmartContractWarning = useCallback<PaginatedModalRenderer>( + (props) => ( + <WarningModal + caption={t('send.warning.smartContract.message')} + closeText={t('common.button.cancel')} + confirmText={t('common.button.understand')} + modalName={ModalName.RecipientSelectSmartContractWarning} + severity={WarningSeverity.None} + title={t('send.warning.smartContract.title')} + {...props} + /> + ), + [t], + ) + + const isActiveViewOnly = viewOnlyAccounts.some((a) => a.address === activeAddress) + + const isNewRecipient = !previousTransactions || previousTransactions.length === 0 + const isSignerRecipient = useMemo( + () => currentSignerAccounts.some((a) => a.address === recipientAddress), + [currentSignerAccounts, recipientAddress], + ) + const isViewOnlyRecipient = useMemo( + () => viewOnlyAccounts.some((a) => a.address === recipientAddress), + [viewOnlyAccounts, recipientAddress], + ) + + const shouldWarnViewOnly = isViewOnlyRecipient + const shouldWarnSelfSend = isSameAddress(activeAddress, recipientAddress) + const shouldWarnErc20 = useIsErc20Contract(recipientAddress, chainId ?? UniverseChainId.Mainnet) + const shouldWarnSmartContract = isNewRecipient && !isSignerRecipient && isSmartContractAddress + const shouldWarnNewAddress = isNewRecipient && !isSignerRecipient && !shouldWarnSmartContract + + const modalRenderers = useMemo<ConditionalModalRenderer[]>( + () => [ + { renderModal: renderViewOnlyWarning, condition: shouldWarnViewOnly }, + { renderModal: renderNewAddressWarning, condition: shouldWarnNewAddress }, + { renderModal: renderSelfSendWarning, condition: shouldWarnSelfSend }, + { renderModal: renderErc20Warning, condition: shouldWarnErc20 }, + { renderModal: renderSmartContractWarning, condition: shouldWarnSmartContract }, + ], + [ + renderViewOnlyWarning, + renderNewAddressWarning, + renderSelfSendWarning, + renderErc20Warning, + renderSmartContractWarning, + shouldWarnViewOnly, + shouldWarnNewAddress, + shouldWarnSelfSend, + shouldWarnErc20, + shouldWarnSmartContract, + ], + ) + + return ( + <SpeedBumps + // Wait until the address is loaded before checking speed bumps + checkSpeedBumps={checkSpeedBumps && !addressLoading} + // Don't check speed bumps if the current account is view-only + // (the user won't be able to complete the transfer anyway) + modalRenderers={isActiveViewOnly ? [] : modalRenderers} + {...rest} + /> + ) +} diff --git a/packages/wallet/src/components/RecipientSearch/ViewOnlyRecipientModal.tsx b/packages/wallet/src/components/RecipientSearch/ViewOnlyRecipientModal.tsx deleted file mode 100644 index be9009c75c7..00000000000 --- a/packages/wallet/src/components/RecipientSearch/ViewOnlyRecipientModal.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Button, Flex, Text } from 'ui/src' -import { Eye } from 'ui/src/components/icons' -import { iconSizes } from 'ui/src/theme' -import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' -import { ModalName } from 'uniswap/src/features/telemetry/constants' - -type ViewOnlyRecipientModalProps = { - onConfirm: () => void - onCancel: () => void -} - -export function ViewOnlyRecipientModal({ onConfirm, onCancel }: ViewOnlyRecipientModalProps): JSX.Element { - const { t } = useTranslation() - - return ( - <BottomSheetModal name={ModalName.ViewOnlyRecipientWarning} onClose={onCancel}> - <Flex centered gap="$spacing12" pb="$spacing12" pt="$spacing12" px="$spacing24"> - <Flex - centered - backgroundColor="$surface2" - borderRadius="$rounded12" - height={iconSizes.icon48} - mb="$spacing8" - width={iconSizes.icon48} - > - <Eye color="$neutral2" size={iconSizes.icon24} /> - </Flex> - - <Text textAlign="center" variant="body1"> - {t('send.recipient.warning.viewOnly.title')} - </Text> - <Text color="$neutral2" textAlign="center" variant="body2"> - {t('send.recipient.warning.viewOnly.message')} - </Text> - - <Flex centered row gap="$spacing12" pt="$spacing24"> - <Button fill theme="secondary" onPress={onCancel}> - {t('common.button.goBack')} - </Button> - <Button fill theme="detrimental" onPress={onConfirm}> - {t('common.button.understand')} - </Button> - </Flex> - </Flex> - </BottomSheetModal> - ) -} diff --git a/packages/wallet/src/components/RecipientSearch/hooks.ts b/packages/wallet/src/components/RecipientSearch/hooks.ts index d7246af11b3..0d9510a20ad 100644 --- a/packages/wallet/src/components/RecipientSearch/hooks.ts +++ b/packages/wallet/src/components/RecipientSearch/hooks.ts @@ -1,6 +1,7 @@ import { isEqual } from 'lodash' import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { useUnitagByName } from 'uniswap/src/features/unitags/hooks' import { UniverseChainId } from 'uniswap/src/types/chains' import { getValidAddress } from 'uniswap/src/utils/addresses' @@ -16,12 +17,11 @@ import { DEFAULT_WATCHED_ADDRESSES } from 'wallet/src/features/favorites/slice' import { selectRecipientsByRecency } from 'wallet/src/features/transactions/selectors' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { selectInactiveAccounts } from 'wallet/src/features/wallet/selectors' -import { useAppSelector } from 'wallet/src/state' const MAX_RECENT_RECIPIENTS = 15 type RecipientSection = { - title: string + title?: string data: SearchableRecipient[] } @@ -128,7 +128,7 @@ export function useRecipients( } { const { t } = useTranslation() - const inactiveLocalAccounts = useAppSelector(selectInactiveAccounts) + const inactiveLocalAccounts = useSelector(selectInactiveAccounts) const { importedWallets, viewOnlyWallets } = useMemo( () => inactiveLocalAccounts.reduce<{ importedWallets: Account[]; viewOnlyWallets: Account[] }>( @@ -144,7 +144,7 @@ export function useRecipients( ), [inactiveLocalAccounts], ) - const recentRecipients = useAppSelector(selectRecipientsByRecency).slice(0, MAX_RECENT_RECIPIENTS) + const recentRecipients = useSelector(selectRecipientsByRecency).slice(0, MAX_RECENT_RECIPIENTS) const { recipients: validatedAddressRecipients, @@ -152,7 +152,7 @@ export function useRecipients( searchTerm, } = useValidatedSearchedAddress(pattern, debounceDelayMs) - const watchedWallets = useAppSelector(selectWatchedAddressSet) + const watchedWallets = useSelector(selectWatchedAddressSet) const isPatternEmpty = pattern.length === 0 const sections = useMemo(() => { @@ -247,11 +247,20 @@ export function useFilteredRecipientSections(searchPattern: string, debounceDela return filterSections(sections, filteredAddresses) }, [debouncedPattern, searchableRecipientOptions, sections]) + const getFilteredRecipientList = useCallback( + () => filterRecipientByNameAndAddress(debouncedPattern, searchableRecipientOptions).map((item) => item.data), + [debouncedPattern, searchableRecipientOptions], + ) + // Update displayed sections only if debouncing is finished and the new result is not being loaded if (searchPattern === debouncedPattern && !loading) { - const filteredSections = getFilteredSections() - const noResult = debouncedPattern.length > 0 && filteredSections.length === 0 - sectionsRef.current = noResult ? [] : filteredSections.length ? filteredSections : sections + if (debouncedPattern.length > 0) { + const recipients = getFilteredRecipientList() + sectionsRef.current = recipients.length ? [{ data: recipients }] : [] + } else { + const filteredSections = getFilteredSections() + sectionsRef.current = filteredSections.length ? filteredSections : sections + } } return sectionsRef.current diff --git a/packages/wallet/src/components/RecipientSearch/modals/NewAddressWarningModal.tsx b/packages/wallet/src/components/RecipientSearch/modals/NewAddressWarningModal.tsx new file mode 100644 index 00000000000..4b07e22a525 --- /dev/null +++ b/packages/wallet/src/components/RecipientSearch/modals/NewAddressWarningModal.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from 'react-i18next' +import { Button, Flex, Text } from 'ui/src' +import { UserSquare } from 'ui/src/components/icons' +import { fonts, iconSizes, imageSizes } from 'ui/src/theme' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { shortenAddress } from 'utilities/src/addresses' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { useENSAvatar, useENSName } from 'wallet/src/features/ens/api' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' + +type NewAddressWarningModalProps = { + address: string + onConfirm: () => void + onClose: () => void +} + +export function NewAddressWarningModal({ address, onConfirm, onClose }: NewAddressWarningModalProps): JSX.Element { + const { t } = useTranslation() + + const validated = getValidAddress(address) + const displayName = useDisplayName(address, { includeUnitagSuffix: true }) + const ensDisplayName = useENSName(validated ?? undefined) + const { data: ensAvatar } = useENSAvatar(validated) + + return ( + <BottomSheetModal name={ModalName.NewAddressWarning} onClose={onClose}> + <Flex px="$spacing24" py="$spacing12"> + <Flex centered gap="$spacing16" pb="$spacing16"> + <Flex centered backgroundColor="$surface2" borderRadius="$rounded12" p="$spacing12"> + <UserSquare color="$neutral2" size={iconSizes.icon24} /> + </Flex> + <Text color="$neutral1" variant="subheading1"> + {t('send.warning.newAddress.title')} + </Text> + <Text color="$neutral2" variant="body3"> + {t('send.warning.newAddress.message')} + </Text> + </Flex> + + <Flex borderColor="$surface3" borderRadius="$rounded16" borderWidth={1} gap="$spacing8" p="$spacing16"> + {displayName?.type === DisplayNameType.Unitag && ( + <Flex row justifyContent="space-between"> + <Text color="$neutral2" fontWeight="bold" variant="body3"> + {t('send.warning.newAddress.details.username')} + </Text> + <AddressDisplay + hideAddressInSubtitle + includeUnitagSuffix + address={address} + lineHeight={fonts.body3.lineHeight} + size={16} + variant="body3" + /> + </Flex> + )} + + {ensDisplayName.data && ( + <Flex row justifyContent="space-between"> + <Text color="$neutral2" fontWeight="bold" variant="body3"> + {t('send.warning.newAddress.details.ENS')} + </Text> + <Flex row alignItems="center" gap="$spacing4"> + <AccountIcon address={address} avatarUri={ensAvatar} size={imageSizes.image16} /> + <Text flexShrink={1} loading={ensDisplayName.loading} numberOfLines={1} variant="body3"> + {ensDisplayName.data} + </Text> + </Flex> + </Flex> + )} + + <Flex row alignItems="center" gap="$spacing16" justifyContent="space-between"> + <Text color="$neutral2" fontWeight="bold" variant="body3"> + {t('send.warning.newAddress.details.walletAddress')} + </Text> + <Text flexShrink={1} numberOfLines={1} variant="body3"> + {shortenAddress(address, 8, 8)} + </Text> + </Flex> + </Flex> + + <Flex row gap="$spacing12" pt="$spacing24"> + <Button flex={1} flexBasis={1} theme="secondary" onPress={onClose}> + {t('common.button.back')} + </Button> + <Button flex={1} flexBasis={1} theme="primary" onPress={onConfirm}> + {t('common.button.confirm')} + </Button> + </Flex> + </Flex> + </BottomSheetModal> + ) +} diff --git a/packages/wallet/src/components/RecipientSearch/utils.ts b/packages/wallet/src/components/RecipientSearch/utils.ts index 07f00a083e9..4e987030f93 100644 --- a/packages/wallet/src/components/RecipientSearch/utils.ts +++ b/packages/wallet/src/components/RecipientSearch/utils.ts @@ -4,11 +4,13 @@ import { SearchableRecipient } from 'wallet/src/features/address/types' export function filterSections( sections: SectionListData<SearchableRecipient>[], filteredAddresses: string[], -): { title: string; data: SearchableRecipient[] }[] { + includeTitle = true, +): ({ title: string; data: SearchableRecipient[] } | { data: SearchableRecipient[] })[] { return sections .map((section) => { const { title, data } = section - return { title, data: data.filter((item) => filteredAddresses.includes(item.address)) } + const filteredData = data.filter((item) => filteredAddresses.includes(item.address)) + return includeTitle ? { title, data: filteredData } : { data: filteredData } }) .filter((section) => section.data.length > 0) } diff --git a/packages/wallet/src/components/TokenSelector/SelectTokenButton.tsx b/packages/wallet/src/components/TokenSelector/SelectTokenButton.tsx index b8cb82057fa..a87f6ae71d5 100644 --- a/packages/wallet/src/components/TokenSelector/SelectTokenButton.tsx +++ b/packages/wallet/src/components/TokenSelector/SelectTokenButton.tsx @@ -4,12 +4,13 @@ import { RotatableChevron } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' interface SelectTokenButtonProps { onPress: () => void selectedCurrencyInfo?: CurrencyInfo | null - testID?: string + testID?: TestIDType } export function SelectTokenButton({ selectedCurrencyInfo, onPress, testID }: SelectTokenButtonProps): JSX.Element { @@ -26,7 +27,7 @@ export function SelectTokenButton({ selectedCurrencyInfo, onPress, testID }: Sel {selectedCurrencyInfo ? ( <Flex centered row gap="$spacing4" p="$spacing4" pr={isWeb ? undefined : '$spacing12'}> <CurrencyLogo currencyInfo={selectedCurrencyInfo} size={iconSizes.icon28} /> - <Text color="$neutral1" pl="$spacing4" variant="buttonLabel1"> + <Text color="$neutral1" pl="$spacing4" testID={`${testID}-label`} variant="buttonLabel1"> {getSymbolDisplayText(selectedCurrencyInfo.currency.symbol)} </Text> {isWeb && ( @@ -35,7 +36,7 @@ export function SelectTokenButton({ selectedCurrencyInfo, onPress, testID }: Sel </Flex> ) : ( <Flex centered row gap="$spacing4" pl="$spacing8" pr={isWeb ? '$spacing4' : '$spacing8'} py="$spacing4"> - <Text color="$sporeWhite" variant="buttonLabel2"> + <Text color="$sporeWhite" testID={`${testID}-label`} variant="buttonLabel2"> {t('tokens.selector.button.choose')} </Text> {isWeb && ( diff --git a/packages/wallet/src/components/TokenSelector/hooks.test.ts b/packages/wallet/src/components/TokenSelector/hooks.test.ts index e1e0d2474e4..eae67e86a2e 100644 --- a/packages/wallet/src/components/TokenSelector/hooks.test.ts +++ b/packages/wallet/src/components/TokenSelector/hooks.test.ts @@ -2,6 +2,15 @@ import { ApolloError } from '@apollo/client' import { toIncludeSameMembers } from 'jest-extended' import { PreloadedState } from 'redux' +import { + useAllCommonBaseCurrencies, + useCommonTokensOptions, + useCurrencyInfosToTokenOptions, + useFilterCallbacks, + usePopularTokensOptions, + usePortfolioBalancesForAddressById, + usePortfolioTokenOptions, +} from 'uniswap/src/components/TokenSelector/hooks' import { createEmptyBalanceOption } from 'uniswap/src/components/TokenSelector/utils' import { BRIDGED_BASE_ADDRESSES } from 'uniswap/src/constants/addresses' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' @@ -11,17 +20,7 @@ import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/ty import { arbitrumDaiCurrencyInfo, ethCurrencyInfo, usdcCurrencyInfo } from 'uniswap/src/test/fixtures' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' -import { - useAllCommonBaseCurrencies, - useCommonTokensOptions, - useCurrencyInfosToTokenOptions, - useFavoriteCurrencies, - useFavoriteTokensOptions, - useFilterCallbacks, - usePopularTokensOptions, - usePortfolioBalancesForAddressById, - usePortfolioTokenOptions, -} from 'wallet/src/components/TokenSelector/hooks' +import { useFavoriteCurrencies, useFavoriteTokensOptions } from 'wallet/src/components/TokenSelector/hooks' import { SharedState } from 'wallet/src/state/reducer' import { SAMPLE_SEED_ADDRESS_1, @@ -354,7 +353,7 @@ describe(usePortfolioBalancesForAddressById, () => { const { resolvers } = queryResolvers({ portfolios: queryResolver(input), }) - const { result } = renderHook(() => usePortfolioBalancesForAddressById(SAMPLE_SEED_ADDRESS_1), { + const { result } = renderHook(() => usePortfolioBalancesForAddressById(SAMPLE_SEED_ADDRESS_1, []), { resolvers, }) @@ -393,7 +392,7 @@ describe(usePortfolioTokenOptions, () => { const { resolvers } = queryResolvers({ portfolios: queryResolver(input), }) - const { result } = renderHook(() => usePortfolioTokenOptions(SAMPLE_SEED_ADDRESS_1, null), { + const { result } = renderHook(() => usePortfolioTokenOptions(SAMPLE_SEED_ADDRESS_1, null, []), { resolvers, }) @@ -433,7 +432,7 @@ describe(usePortfolioTokenOptions, () => { }[] = [ { test: 'returns only shown tokens after data is fetched', - input: [SAMPLE_SEED_ADDRESS_1, null], + input: [SAMPLE_SEED_ADDRESS_1, null, []], output: { data: shownPortfolioBalances, loading: false, @@ -443,7 +442,7 @@ describe(usePortfolioTokenOptions, () => { }, { test: 'returns shown tokens filtered by chain', - input: [SAMPLE_SEED_ADDRESS_1, fromGraphQLChain(usdcTokenBalance.token.chain)], + input: [SAMPLE_SEED_ADDRESS_1, fromGraphQLChain(usdcTokenBalance.token.chain), []], output: { data: [usdcPortfolioBalance], loading: false, @@ -453,7 +452,7 @@ describe(usePortfolioTokenOptions, () => { }, { test: 'returns shown tokens starting with "et" (ETH) filtered by search filter', - input: [SAMPLE_SEED_ADDRESS_1, null, 'et'], + input: [SAMPLE_SEED_ADDRESS_1, null, [], 'et'], output: { data: [ethPortfolioBalance], loading: false, @@ -463,7 +462,7 @@ describe(usePortfolioTokenOptions, () => { }, { test: 'returns shown tokens starting with "us" (USDC) filtered by search filter', - input: [SAMPLE_SEED_ADDRESS_1, null, 'us'], + input: [SAMPLE_SEED_ADDRESS_1, null, [], 'us'], output: { data: [usdcPortfolioBalance], loading: false, @@ -473,7 +472,7 @@ describe(usePortfolioTokenOptions, () => { }, { test: 'returns no data when there is no token that matches both chain and search filter', - input: [SAMPLE_SEED_ADDRESS_1, UniverseChainId.Base, 'et'], + input: [SAMPLE_SEED_ADDRESS_1, UniverseChainId.Base, [], 'et'], output: { data: [], loading: false, diff --git a/packages/wallet/src/components/TokenSelector/hooks.tsx b/packages/wallet/src/components/TokenSelector/hooks.tsx index 3a1a83f7bad..4e4c6e45a1d 100644 --- a/packages/wallet/src/components/TokenSelector/hooks.tsx +++ b/packages/wallet/src/components/TokenSelector/hooks.tsx @@ -1,92 +1,30 @@ -/* eslint-disable max-lines */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Text, TouchableArea } from 'ui/src' import { filter } from 'uniswap/src/components/TokenSelector/filter' -import { TokenOption, TokenSection } from 'uniswap/src/components/TokenSelector/types' import { - createEmptyBalanceOption, - formatSearchResults, - getTokenOptionsSection, -} from 'uniswap/src/components/TokenSelector/utils' -import { BRIDGED_BASE_ADDRESSES } from 'uniswap/src/constants/addresses' -import { DAI, USDC, USDT, WBTC } from 'uniswap/src/constants/tokens' -import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' + currencyInfosToTokenOptions, + filterRecentlySearchedTokenOptions, + useCurrencyInfosToTokenOptions, + usePortfolioBalancesForAddressById, +} from 'uniswap/src/components/TokenSelector/hooks' +import { TokenOption, TokenSection } from 'uniswap/src/components/TokenSelector/types' +import { getTokenOptionsSection } from 'uniswap/src/components/TokenSelector/utils' +import { PortfolioValueModifier } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' -import { useSearchTokens } from 'uniswap/src/features/dataApi/searchTokens' import { useTokenProjects } from 'uniswap/src/features/dataApi/tokenProjects' -import { usePopularTokens } from 'uniswap/src/features/dataApi/topTokens' -import { CurrencyInfo, PortfolioBalance } from 'uniswap/src/features/dataApi/types' -import { buildCurrency, gqlTokenToCurrencyInfo, usePersistedError } from 'uniswap/src/features/dataApi/utils' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { WalletEventName } from 'uniswap/src/features/telemetry/constants' -import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { gqlTokenToCurrencyInfo, usePersistedError } from 'uniswap/src/features/dataApi/utils' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { UniverseChainId } from 'uniswap/src/types/chains' -import { areAddressesEqual } from 'uniswap/src/utils/addresses' -import { buildNativeCurrencyId, buildWrappedNativeCurrencyId, currencyId } from 'uniswap/src/utils/currencyId' -import { flowToModalName } from 'wallet/src/components/TokenSelector/flowToModalName' -import { - sortPortfolioBalances, - usePortfolioBalances, - useTokenBalancesGroupedByVisibility, -} from 'wallet/src/features/dataApi/balances' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' -import { TokenSearchResult } from 'wallet/src/features/search/SearchResult' import { addToSearchHistory, clearSearchHistory } from 'wallet/src/features/search/searchHistorySlice' import { selectSearchHistory } from 'wallet/src/features/search/selectSearchHistory' import { usePopularTokens as usePopularWalletTokens } from 'wallet/src/features/tokens/hooks' -import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' -import { useAppSelector } from 'wallet/src/state' - -// Use Mainnet base token addresses since TokenProjects query returns each token -// on each network -const baseCurrencyIds = [ - buildNativeCurrencyId(UniverseChainId.Mainnet), - buildNativeCurrencyId(UniverseChainId.Polygon), - buildNativeCurrencyId(UniverseChainId.Bnb), - buildNativeCurrencyId(UniverseChainId.Celo), - buildNativeCurrencyId(UniverseChainId.Avalanche), - currencyId(DAI), - currencyId(USDC), - currencyId(USDT), - currencyId(WBTC), - buildWrappedNativeCurrencyId(UniverseChainId.Mainnet), -] - -export function useAllCommonBaseCurrencies(): GqlResult<CurrencyInfo[]> { - return useCurrencies(baseCurrencyIds) -} - -export function useCurrencies(currencyIds: string[]): GqlResult<CurrencyInfo[]> { - const { data: baseCurrencyInfos, loading, error, refetch } = useTokenProjects(currencyIds) - const persistedError = usePersistedError(loading, error) - - // TokenProjects returns tokens on every network, so filter out native assets that have a - // bridged version on other networks - const filteredBaseCurrencyInfos = useMemo(() => { - return baseCurrencyInfos?.filter((currencyInfo) => { - if (currencyInfo.currency.isNative) { - return true - } - - const { address } = currencyInfo.currency - const bridgedAsset = BRIDGED_BASE_ADDRESSES.find((bridgedAddress) => areAddressesEqual(bridgedAddress, address)) - - if (!bridgedAsset) { - return true - } - - return false - }) - }, [baseCurrencyInfos]) - - return { data: filteredBaseCurrencyInfos, loading, error: persistedError, refetch } -} export function useFavoriteCurrencies(): GqlResult<CurrencyInfo[]> { - const favoriteCurrencyIds = useAppSelector(selectFavoriteTokens) + const favoriteCurrencyIds = useSelector(selectFavoriteTokens) const { data: favoriteTokensOnAllChains, loading, error, refetch } = useTokenProjects(favoriteCurrencyIds) const persistedError = usePersistedError(loading, error) @@ -109,165 +47,6 @@ export function useFavoriteCurrencies(): GqlResult<CurrencyInfo[]> { return { data: favoriteTokens, loading, error: persistedError, refetch } } -export function useFilterCallbacks( - chainId: UniverseChainId | null, - flow: TokenSelectorFlow, -): { - chainFilter: UniverseChainId | null - searchFilter: string | null - onChangeChainFilter: (newChainFilter: UniverseChainId | null) => void - onClearSearchFilter: () => void - onChangeText: (newSearchFilter: string) => void -} { - const [chainFilter, setChainFilter] = useState<UniverseChainId | null>(chainId) - const [searchFilter, setSearchFilter] = useState<string | null>(null) - - useEffect(() => { - setChainFilter(chainId) - }, [chainId]) - - const onChangeChainFilter = useCallback( - (newChainFilter: typeof chainFilter) => { - setChainFilter(newChainFilter) - sendAnalyticsEvent(WalletEventName.NetworkFilterSelected, { - chain: newChainFilter ?? 'All', - modal: flowToModalName(flow), - }) - }, - [flow], - ) - - const onClearSearchFilter = useCallback(() => { - setSearchFilter(null) - }, []) - - const onChangeText = useCallback((newSearchFilter: string) => setSearchFilter(newSearchFilter), [setSearchFilter]) - - return { - chainFilter, - searchFilter, - onChangeChainFilter, - onClearSearchFilter, - onChangeText, - } -} - -export function useCurrencyInfosToTokenOptions({ - currencyInfos, - portfolioBalancesById, - sortAlphabetically, -}: { - currencyInfos?: CurrencyInfo[] - sortAlphabetically?: boolean - portfolioBalancesById?: Record<string, PortfolioBalance> -}): TokenOption[] | undefined { - // we use useMemo here to avoid recalculation of internals when function params are the same, - // but the component, where this hook is used is re-rendered - return useMemo(() => { - if (!currencyInfos) { - return undefined - } - const sortedCurrencyInfos = sortAlphabetically - ? [...currencyInfos].sort((a, b) => { - if (a.currency.name && b.currency.name) { - return a.currency.name.localeCompare(b.currency.name) - } - return 0 - }) - : currencyInfos - - return sortedCurrencyInfos.map( - (currencyInfo) => portfolioBalancesById?.[currencyInfo.currencyId] ?? createEmptyBalanceOption(currencyInfo), - ) - }, [currencyInfos, portfolioBalancesById, sortAlphabetically]) -} - -export function usePortfolioBalancesForAddressById( - address: Address, -): GqlResult<Record<Address, PortfolioBalance> | undefined> { - const { - data: portfolioBalancesById, - error, - refetch, - loading, - } = usePortfolioBalances({ - address, - fetchPolicy: 'cache-first', // we want to avoid re-renders when token selector is opening - }) - - return { - data: portfolioBalancesById, - error, - refetch, - loading, - } -} - -export function usePortfolioTokenOptions( - address: Address, - chainFilter: UniverseChainId | null, - searchFilter?: string, -): GqlResult<TokenOption[] | undefined> { - const { data: portfolioBalancesById, error, refetch, loading } = usePortfolioBalancesForAddressById(address) - - const { shownTokens } = useTokenBalancesGroupedByVisibility({ - balancesById: portfolioBalancesById, - }) - - const portfolioBalances = useMemo(() => (shownTokens ? sortPortfolioBalances(shownTokens) : undefined), [shownTokens]) - - const filteredPortfolioBalances = useMemo( - () => portfolioBalances && filter(portfolioBalances, chainFilter, searchFilter), - [chainFilter, portfolioBalances, searchFilter], - ) - - return { - data: filteredPortfolioBalances, - error, - refetch, - loading, - } -} - -export function usePopularTokensOptions( - address: Address, - chainFilter: UniverseChainId, -): GqlResult<TokenOption[] | undefined> { - const { - data: portfolioBalancesById, - error: portfolioBalancesByIdError, - refetch: portfolioBalancesByIdRefetch, - loading: loadingPorfolioBalancesById, - } = usePortfolioBalancesForAddressById(address) - - const { - data: popularTokens, - error: popularTokensError, - refetch: refetchPopularTokens, - loading: loadingPopularTokens, - } = usePopularTokens(chainFilter) - - const popularTokenOptions = useCurrencyInfosToTokenOptions({ - currencyInfos: popularTokens, - portfolioBalancesById, - sortAlphabetically: true, - }) - - const refetch = useCallback(() => { - portfolioBalancesByIdRefetch?.() - refetchPopularTokens?.() - }, [portfolioBalancesByIdRefetch, refetchPopularTokens]) - - const error = (!portfolioBalancesById && portfolioBalancesByIdError) || (!popularTokenOptions && popularTokensError) - - return { - data: popularTokenOptions, - refetch, - error: error || undefined, - loading: loadingPorfolioBalancesById || loadingPopularTokens, - } -} - export function useAddToSearchHistory(): { registerSearch: (currencyInfo: CurrencyInfo) => void } { const dispatch = useDispatch() @@ -292,60 +71,17 @@ export function useAddToSearchHistory(): { registerSearch: (currencyInfo: Curren return { registerSearch } } -export function useCommonTokensOptions( - address: Address, - chainFilter: UniverseChainId | null, -): GqlResult<TokenOption[] | undefined> { - const { - data: portfolioBalancesById, - error: portfolioBalancesByIdError, - refetch: portfolioBalancesByIdRefetch, - loading: loadingPorfolioBalancesById, - } = usePortfolioBalancesForAddressById(address) - - const { - data: commonBaseCurrencies, - error: commonBaseCurrenciesError, - refetch: refetchCommonBaseCurrencies, - loading: loadingCommonBaseCurrencies, - } = useAllCommonBaseCurrencies() - - const commonBaseTokenOptions = useCurrencyInfosToTokenOptions({ - currencyInfos: commonBaseCurrencies, - portfolioBalancesById, - }) - - const refetch = useCallback(() => { - portfolioBalancesByIdRefetch?.() - refetchCommonBaseCurrencies?.() - }, [portfolioBalancesByIdRefetch, refetchCommonBaseCurrencies]) - - const error = - (!portfolioBalancesById && portfolioBalancesByIdError) || (!commonBaseCurrencies && commonBaseCurrenciesError) - - const filteredCommonBaseTokenOptions = useMemo( - () => commonBaseTokenOptions && filter(commonBaseTokenOptions, chainFilter), - [chainFilter, commonBaseTokenOptions], - ) - - return { - data: filteredCommonBaseTokenOptions, - refetch, - error: error || undefined, - loading: loadingPorfolioBalancesById || loadingCommonBaseCurrencies, - } -} - export function useFavoriteTokensOptions( address: Address, chainFilter: UniverseChainId | null, + valueModifiers?: PortfolioValueModifier[], ): GqlResult<TokenOption[] | undefined> { const { data: portfolioBalancesById, error: portfolioBalancesByIdError, refetch: portfolioBalancesByIdRefetch, loading: loadingPorfolioBalancesById, - } = usePortfolioBalancesForAddressById(address) + } = usePortfolioBalancesForAddressById(address, valueModifiers) const { data: favoriteCurrencies, @@ -381,47 +117,6 @@ export function useFavoriteTokensOptions( } } -function searchResultToCurrencyInfo({ - chainId, - address, - symbol, - name, - logoUrl, - safetyLevel, -}: TokenSearchResult): CurrencyInfo | null { - const currency = buildCurrency({ - chainId, - address, - decimals: 0, // this does not matter in a context of CurrencyInfo here, as we do not provide any balance - symbol, - name, - }) - - if (!currency) { - return null - } - - const currencyInfo: CurrencyInfo = { - currency, - currencyId: currencyId(currency), - logoUrl, - safetyLevel: safetyLevel ?? SafetyLevel.StrongWarning, - // defaulting to not spam, as user has searched and chosen this token before - isSpam: false, - } - return currencyInfo -} - -function currencyInfosToTokenOptions(currencyInfos: Array<CurrencyInfo | null> | undefined): TokenOption[] | undefined { - return currencyInfos - ?.filter((cI): cI is CurrencyInfo => Boolean(cI)) - .map((currencyInfo) => ({ - currencyInfo, - quantity: null, - balanceUSD: undefined, - })) -} - function ClearAll({ onPress }: { onPress: () => void }): JSX.Element { const { t } = useTranslation() return ( @@ -439,7 +134,8 @@ export function useTokenSectionsForEmptySearch(): GqlResult<TokenSection[]> { const { popularTokens, loading } = usePopularWalletTokens() - const searchHistory = useAppSelector(selectSearchHistory) + const searchHistory = useSelector(selectSearchHistory) + const recentlySearchedTokenOptions = filterRecentlySearchedTokenOptions(searchHistory as TokenSearchResult[]) // it's a depenedency of useMemo => useCallback const onPressClearSearchHistory = useCallback((): void => { @@ -450,11 +146,7 @@ export function useTokenSectionsForEmptySearch(): GqlResult<TokenSection[]> { () => [ ...(getTokenOptionsSection( t('tokens.selector.section.recent'), - currencyInfosToTokenOptions( - searchHistory - .filter((searchResult): searchResult is TokenSearchResult => searchResult.type === SearchResultType.Token) - .map(searchResultToCurrencyInfo), - ), + recentlySearchedTokenOptions, <ClearAll onPress={onPressClearSearchHistory} />, ) ?? []), ...(getTokenOptionsSection( @@ -462,7 +154,7 @@ export function useTokenSectionsForEmptySearch(): GqlResult<TokenSection[]> { currencyInfosToTokenOptions(popularTokens?.map(gqlTokenToCurrencyInfo)), ) ?? []), ], - [onPressClearSearchHistory, popularTokens, searchHistory, t], + [onPressClearSearchHistory, popularTokens, recentlySearchedTokenOptions, t], ) return useMemo( @@ -473,72 +165,3 @@ export function useTokenSectionsForEmptySearch(): GqlResult<TokenSection[]> { [loading, sections], ) } - -export function useTokenSectionsForSearchResults( - chainFilter: UniverseChainId | null, - searchFilter: string | null, - isBalancesOnlySearch: boolean, -): GqlResult<TokenSection[]> { - const { t } = useTranslation() - const activeAccountAddress = useActiveAccountAddressWithThrow() - - const { - data: portfolioBalancesById, - error: portfolioBalancesByIdError, - refetch: refetchPortfolioBalances, - loading: portfolioBalancesByIdLoading, - } = usePortfolioBalancesForAddressById(activeAccountAddress) - - const { - data: portfolioTokenOptions, - error: portfolioTokenOptionsError, - refetch: refetchPortfolioTokenOptions, - loading: portfolioTokenOptionsLoading, - } = usePortfolioTokenOptions(activeAccountAddress, chainFilter, searchFilter ?? undefined) - - // Only call search endpoint if isBalancesOnlySearch is false - const { - data: searchResultCurrencies, - error: searchTokensError, - refetch: refetchSearchTokens, - loading: searchTokensLoading, - } = useSearchTokens(searchFilter, chainFilter, /*skip*/ isBalancesOnlySearch) - - const searchResults = useMemo(() => { - return formatSearchResults(searchResultCurrencies, portfolioBalancesById, searchFilter) - }, [searchResultCurrencies, portfolioBalancesById, searchFilter]) - - const loading = - portfolioTokenOptionsLoading || portfolioBalancesByIdLoading || (!isBalancesOnlySearch && searchTokensLoading) - - const sections = useMemo( - () => - getTokenOptionsSection( - t('tokens.selector.section.search'), - // Use local search when only searching balances - isBalancesOnlySearch ? portfolioTokenOptions : searchResults, - ), - [isBalancesOnlySearch, portfolioTokenOptions, searchResults, t], - ) - - const error = - (!portfolioBalancesById && portfolioBalancesByIdError) || - (!portfolioTokenOptions && portfolioTokenOptionsError) || - (!isBalancesOnlySearch && !searchResults && searchTokensError) - - const refetchAll = useCallback(() => { - refetchPortfolioBalances?.() - refetchSearchTokens?.() - refetchPortfolioTokenOptions?.() - }, [refetchPortfolioBalances, refetchPortfolioTokenOptions, refetchSearchTokens]) - - return useMemo( - () => ({ - data: sections, - loading, - error: error || undefined, - refetch: refetchAll, - }), - [error, loading, refetchAll, sections], - ) -} diff --git a/packages/wallet/src/components/accounts/AddressDisplay.tsx b/packages/wallet/src/components/accounts/AddressDisplay.tsx index 2de26e415a9..150e5b8d0d5 100644 --- a/packages/wallet/src/components/accounts/AddressDisplay.tsx +++ b/packages/wallet/src/components/accounts/AddressDisplay.tsx @@ -22,6 +22,7 @@ type AddressDisplayProps = { address: string overrideDisplayName?: string allowFontScaling?: boolean + lineHeight?: number hideAddressInSubtitle?: boolean size?: number variant?: keyof typeof fonts @@ -63,11 +64,12 @@ function CopyButtonWrapper({ children, onPress }: PropsWithChildren<CopyButtonWr // This seems to work for most font sizes and screens, but could probably be improved and abstracted // if we find more uses for it in other areas. -function getLineHeightForAdjustedFontSize(nameLength: number): number { +function getLineHeightForAdjustedFontSize(nameLength: number, maxLineHeight?: number): number { // as name gets longer, number gets smaller down to 1, past 50 just 1 const lineHeightBase = 50 - Math.min(49, nameLength) const scale = 1.2 - return lineHeightBase * scale + const calculatedLineHeight = lineHeightBase * scale + return maxLineHeight ? Math.min(calculatedLineHeight, maxLineHeight) : calculatedLineHeight } /** Helper component to display identicon and formatted address */ @@ -75,6 +77,7 @@ function getLineHeightForAdjustedFontSize(nameLength: number): number { export function AddressDisplay({ allowFontScaling = true, overrideDisplayName, + lineHeight, address, size = 24, variant = 'body1', @@ -147,10 +150,10 @@ export function AddressDisplay({ name.length > 20 ? { adjustsFontSizeToFit: true, - lineHeight: getLineHeightForAdjustedFontSize(name.length), + lineHeight: getLineHeightForAdjustedFontSize(name.length, lineHeight), } : { - lineHeight: fonts[variant].lineHeight, + lineHeight: lineHeight ?? fonts[variant].lineHeight, } return ( diff --git a/packages/wallet/src/components/gating/GatingOverrides.tsx b/packages/wallet/src/components/gating/GatingOverrides.tsx index f3b31aca42d..8aff2c0fd7e 100644 --- a/packages/wallet/src/components/gating/GatingOverrides.tsx +++ b/packages/wallet/src/components/gating/GatingOverrides.tsx @@ -1,10 +1,10 @@ import React from 'react' -import { Accordion, Button, Flex, Separator, Text, isWeb } from 'ui/src' +import { Accordion, Button, Flex, Input, Separator, Text, isWeb } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons' import { Experiments } from 'uniswap/src/features/gating/experiments' import { FeatureFlags, WALLET_FEATURE_FLAG_NAMES, getFeatureFlagName } from 'uniswap/src/features/gating/flags' import { useFeatureFlagWithExposureLoggingDisabled } from 'uniswap/src/features/gating/hooks' -import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' +import { Statsig, useExperiment } from 'uniswap/src/features/gating/sdk/statsig' import { Switch, WebSwitch } from 'wallet/src/components/buttons/Switch' export function GatingOverrides(): JSX.Element { @@ -43,13 +43,14 @@ export function GatingOverrides(): JSX.Element { <Text variant="body2">Clear all local experiment/config overrides</Text> </Button> - <Flex gap="$spacing24" mt="$spacing12"> + <Flex gap="$spacing12" mt="$spacing12"> {experimentRows} </Flex> </Accordion.Content> </Accordion.Item> <Button + mt="$spacing12" p="$spacing4" theme="tertiary" onPress={() => { @@ -81,12 +82,12 @@ export function AccordionHeader({ title }: { title: React.ReactNode }): JSX.Elem ) } +const SwitchElement = isWeb ? WebSwitch : Switch + function FeatureFlagRow({ flag }: { flag: FeatureFlags }): JSX.Element { const status = useFeatureFlagWithExposureLoggingDisabled(flag) const name = getFeatureFlagName(flag) - const SwitchElement = isWeb ? WebSwitch : Switch - return ( <Flex row alignItems="center" gap="$spacing16" justifyContent="space-between"> <Text variant="body1">{name}</Text> @@ -101,22 +102,66 @@ function FeatureFlagRow({ flag }: { flag: FeatureFlags }): JSX.Element { } function ExperimentRow({ experiment }: { experiment: Experiments }): JSX.Element { + const { config } = useExperiment(experiment) + + const paramRows = Object.entries(config.value).map(([key, value]) => { + let valueElement: JSX.Element | undefined + if (typeof value === 'boolean') { + valueElement = ( + <SwitchElement + value={value} + onValueChange={(newValue: boolean): void => { + Statsig.overrideConfig(experiment, { + ...config.value, + [key]: newValue, + }) + }} + /> + ) + } else if (typeof value === 'number') { + valueElement = ( + <Input + value={value.toString()} + onChangeText={(newValue: string): void => { + Statsig.overrideConfig(experiment, { + ...config.value, + [key]: Number(newValue), + }) + }} + /> + ) + } else if (typeof value === 'string') { + valueElement = ( + <Input + value={value} + onChangeText={(newValue: string): void => { + Statsig.overrideConfig(experiment, { + ...config.value, + [key]: newValue, + }) + }} + /> + ) + } + + return ( + valueElement && ( + <Flex key={key} row alignItems="center" gap="$spacing16" justifyContent="space-between"> + <Text variant="body1">{key}</Text> + {valueElement} + </Flex> + ) + ) + }) + return ( <> <Separator /> <Flex> <Text variant="body1">{experiment}</Text> - <Flex gap="$spacing4"> - <Flex - key={experiment} - row - alignItems="center" - gap="$spacing16" - justifyContent="space-between" - paddingStart="$spacing16" - > - <Text variant="body2" /> - {/* TODO(WEB-4164): implement experiment groups overrides */} + <Flex> + <Flex key={experiment} gap="$spacing8" paddingStart="$spacing8"> + {paramRows} </Flex> </Flex> </Flex> diff --git a/packages/wallet/src/components/input/MaxAmountButton.tsx b/packages/wallet/src/components/input/MaxAmountButton.tsx index ff010ad1c52..f5c5543e61f 100644 --- a/packages/wallet/src/components/input/MaxAmountButton.tsx +++ b/packages/wallet/src/components/input/MaxAmountButton.tsx @@ -6,7 +6,8 @@ import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TestID } from 'uniswap/src/test/fixtures/testIDs' -import { maxAmountSpend } from 'wallet/src/utils/balance' +import { useMaxAmountSpend } from 'wallet/src/features/gas/useMaxAmountSpend' +import { TransactionType } from 'wallet/src/features/transactions/types' interface MaxAmountButtonProps { currencyAmount: CurrencyAmount<Currency> | null | undefined @@ -14,6 +15,7 @@ interface MaxAmountButtonProps { onSetMax: (amount: string) => void style?: StyleProp<ViewStyle> currencyField: CurrencyField + transactionType?: TransactionType } export function MaxAmountButton({ @@ -22,10 +24,11 @@ export function MaxAmountButton({ onSetMax, style, currencyField, + transactionType, }: MaxAmountButtonProps): JSX.Element { const { t } = useTranslation() - const maxInputAmount = maxAmountSpend(currencyBalance) + const maxInputAmount = useMaxAmountSpend(currencyBalance, transactionType) // Disable max button if max already set or when balance is not sufficient const disableMaxButton = diff --git a/packages/wallet/src/components/landing/LandingBackground.tsx b/packages/wallet/src/components/landing/LandingBackground.tsx index 363e408c878..bad391d6a0d 100644 --- a/packages/wallet/src/components/landing/LandingBackground.tsx +++ b/packages/wallet/src/components/landing/LandingBackground.tsx @@ -19,7 +19,7 @@ import { ONBOARDING_LANDING_DARK, ONBOARDING_LANDING_LIGHT, UNISWAP_LOGO } from import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { imageSizes } from 'ui/src/theme' -import { isAndroid } from 'utilities/src/platform' +import { isAndroid, isMobile } from 'utilities/src/platform' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useTimeout } from 'utilities/src/time/timing' import { @@ -355,7 +355,7 @@ export const LandingBackground = ({ if ( // Android Platform.Version is always a number (isAndroid && typeof Platform.Version === 'number' && Platform.Version < 30) || - language !== Language.English + (language !== Language.English && isMobile) ) { return <OnboardingStaticImage /> } diff --git a/packages/wallet/src/components/legacy/CurrencyInputPanelLegacy.tsx b/packages/wallet/src/components/legacy/CurrencyInputPanelLegacy.tsx index 82d5d963dcc..8d897077d74 100644 --- a/packages/wallet/src/components/legacy/CurrencyInputPanelLegacy.tsx +++ b/packages/wallet/src/components/legacy/CurrencyInputPanelLegacy.tsx @@ -14,6 +14,8 @@ import { MaxAmountButton } from 'wallet/src/components/input/MaxAmountButton' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { Warning, WarningLabel } from 'wallet/src/features/transactions/WarningModal/types' +import { useTokenAndFiatDisplayAmounts } from 'wallet/src/features/transactions/hooks/useTokenAndFiatDisplayAmounts' +import { TransactionType } from 'wallet/src/features/transactions/types' import { useDynamicFontSizing } from 'wallet/src/utils/useDynamicFontSizing' type CurrentInputPanelProps = { @@ -36,6 +38,7 @@ type CurrentInputPanelProps = { selection?: TextInputProps['selection'] onSelectionChange?: (start: number, end: number) => void usdValue: Maybe<CurrencyAmount<Currency>> + transactionType?: TransactionType // sometimes CurrencyInputPanel rendered off screen like with Send input -> selector flow isOnScreen?: boolean @@ -107,22 +110,18 @@ export function _CurrencyInputPanel(props: CurrentInputPanelProps): JSX.Element onSelectionChange: selectionChange, usdValue, isOnScreen, + transactionType, ...rest } = props const { t } = useTranslation() const inputRef = useRef<TextInput>(null) - const { convertFiatAmountFormatted, formatCurrencyAmount } = useLocalizationContext() + const { formatCurrencyAmount } = useLocalizationContext() const insufficientBalanceWarning = warnings.find((warning) => warning.type === WarningLabel.InsufficientFunds) const showInsufficientBalanceWarning = insufficientBalanceWarning && !isOutput - const formattedFiatValue = convertFiatAmountFormatted(usdValue?.toExact(), NumberType.FiatTokenQuantity) - const formattedCurrencyAmount = currencyAmount - ? formatCurrencyAmount({ value: currencyAmount, type: NumberType.TokenTx }) - : '' - // the focus state for native Inputs can sometimes be out of sync with the controlled `focus` // prop. When the internal focus state differs from our `focus` prop, sync the internal // focus state to be what our prop says it should be @@ -194,6 +193,14 @@ export function _CurrencyInputPanel(props: CurrentInputPanelProps): JSX.Element const inputColor = !value ? '$neutral3' : '$neutral1' const { symbol: fiatCurrencySymbol } = useAppFiatCurrencyInfo() + const inputPanelFormattedValue = useTokenAndFiatDisplayAmounts({ + value, + currencyInfo, + currencyAmount, + usdValue, + isFiatMode: isFiatInput, + }) + return ( <Flex gap="$spacing8" pb={paddingBottom} pt={paddingTop} px={paddingHorizontal} {...rest}> <Flex @@ -261,7 +268,7 @@ export function _CurrencyInputPanel(props: CurrentInputPanelProps): JSX.Element <TouchableArea onPress={handleToggleFiatInput}> <Flex shrink> <Text color="$neutral2" numberOfLines={1} variant="subheading2"> - {!isFiatInput ? (usdValue ? formattedFiatValue : '') : formattedCurrencyAmount} + {inputPanelFormattedValue} </Text> </Flex> </TouchableArea> @@ -279,6 +286,7 @@ export function _CurrencyInputPanel(props: CurrentInputPanelProps): JSX.Element currencyAmount={currencyAmount} currencyBalance={currencyBalance} currencyField={isOutput ? CurrencyField.OUTPUT : CurrencyField.INPUT} + transactionType={transactionType} onSetMax={handleSetMax} /> )} diff --git a/packages/wallet/src/components/modals/SpeedBumps.tsx b/packages/wallet/src/components/modals/SpeedBumps.tsx new file mode 100644 index 00000000000..50e6aacc4a5 --- /dev/null +++ b/packages/wallet/src/components/modals/SpeedBumps.tsx @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Keyboard } from 'react-native' +import { PaginatedModalRenderer, PaginatedModals } from 'uniswap/src/components/modals/PaginatedModals' + +export type ConditionalModalRenderer = { + renderModal: PaginatedModalRenderer + condition: boolean +} + +type SpeedBumpsProps = { + modalRenderers: ConditionalModalRenderer[] + checkSpeedBumps: boolean + setCheckSpeedBumps: (value: boolean) => void + onConfirm: () => void +} + +export function SpeedBumps({ + modalRenderers, + checkSpeedBumps, + setCheckSpeedBumps, + onConfirm: onConfirmFinish, +}: SpeedBumpsProps): JSX.Element { + const onConfirmRef = useRef(onConfirmFinish) + onConfirmRef.current = onConfirmFinish + const [displayedModals, setDisplayedModals] = useState<PaginatedModalRenderer[] | undefined>() + + const handleClose = useCallback(() => { + setCheckSpeedBumps(false) + }, [setCheckSpeedBumps]) + + const handleConfirm = useCallback(() => { + onConfirmRef.current() + handleClose() + }, [handleClose]) + + useEffect(() => { + if (!checkSpeedBumps) { + setDisplayedModals(undefined) + return + } + + const newModals = modalRenderers.filter(({ condition }) => condition).map(({ renderModal }) => renderModal) + + if (newModals.length > 0) { + Keyboard.dismiss() + setDisplayedModals(newModals) + } else { + handleConfirm() + setDisplayedModals(undefined) + } + }, [checkSpeedBumps, modalRenderers, handleConfirm]) + + return <PaginatedModals modals={displayedModals ?? []} onClose={handleClose} onFinish={handleConfirm} /> +} diff --git a/packages/wallet/src/components/settings/AnalyticsToggleLineSwitch.tsx b/packages/wallet/src/components/settings/AnalyticsToggleLineSwitch.tsx index 70e37ae29e5..47ffa7d1e1d 100644 --- a/packages/wallet/src/components/settings/AnalyticsToggleLineSwitch.tsx +++ b/packages/wallet/src/components/settings/AnalyticsToggleLineSwitch.tsx @@ -1,15 +1,14 @@ import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Flex, Text, isWeb } from 'ui/src' import { Switch, WebSwitch } from 'wallet/src/components/buttons/Switch' import { selectAllowAnalytics } from 'wallet/src/features/telemetry/selectors' import { setAllowAnalytics } from 'wallet/src/features/telemetry/slice' -import { useAppSelector } from 'wallet/src/state' export function AnalyticsToggleLineSwitch(): JSX.Element { const { t } = useTranslation() const dispatch = useDispatch() - const analyticsAllowed = useAppSelector(selectAllowAnalytics) + const analyticsAllowed = useSelector(selectAllowAnalytics) const onChangeAllowAnalytics = (enabled: boolean): void => { dispatch(setAllowAnalytics({ enabled })) diff --git a/packages/wallet/src/contexts/WalletNavigationContext.tsx b/packages/wallet/src/contexts/WalletNavigationContext.tsx index 10d836c5855..9f4c27b6918 100644 --- a/packages/wallet/src/contexts/WalletNavigationContext.tsx +++ b/packages/wallet/src/contexts/WalletNavigationContext.tsx @@ -1,4 +1,5 @@ import { createContext, ReactNode, useContext } from 'react' +import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' import { CurrencyField, TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { WalletChainId } from 'uniswap/src/types/chains' import { NFTItem } from 'wallet/src/features/nfts/types' @@ -73,6 +74,10 @@ export type NavigateToNftCollectionArgs = { collectionAddress: Address } +export type NavigateToFiatOnRampArgs = { + prefilledCurrency?: FiatOnRampCurrency +} + export type ShareTokenArgs = { currencyId: string } @@ -87,6 +92,7 @@ export type WalletNavigationContextState = { navigateToAccountTokenList: () => void // Action that should be taken when the user presses the "Buy crypto" or "Receive tokens" button when they open the Send flow with an empty wallet. navigateToBuyOrReceiveWithEmptyWallet: () => void + navigateToFiatOnRamp: (args: NavigateToFiatOnRampArgs) => void navigateToNftDetails: (args: NavigateToNftItemArgs) => void navigateToNftCollection: (args: NavigateToNftCollectionArgs) => void navigateToSwapFlow: (args: NavigateToSwapFlowArgs) => void diff --git a/packages/wallet/src/data/apollo/usePersistedApolloClient.tsx b/packages/wallet/src/data/apollo/usePersistedApolloClient.tsx index 19bc09522d0..97df7d3fa6d 100644 --- a/packages/wallet/src/data/apollo/usePersistedApolloClient.tsx +++ b/packages/wallet/src/data/apollo/usePersistedApolloClient.tsx @@ -14,9 +14,12 @@ import { getCustomGraphqlHttpLink, getErrorLink, getGraphqlHttpLink, + getOnRampAuthLink, getPerformanceLink, getRestLink, } from 'wallet/src/data/links' +import { useWalletSigners } from 'wallet/src/features/wallet/context' +import { useAccounts } from 'wallet/src/features/wallet/hooks' type ApolloClientRef = { current: ApolloClient<NormalizedCacheObject> | null @@ -74,6 +77,8 @@ export const usePersistedApolloClient = ({ customEndpoint?: CustomEndpoint }): ApolloClient<NormalizedCacheObject> | undefined => { const [client, setClient] = useState<ApolloClient<NormalizedCacheObject>>() + const signerManager = useWalletSigners() + const accounts = useAccounts() const apolloLink = customEndpoint ? getCustomGraphqlHttpLink(customEndpoint) : getGraphqlHttpLink() @@ -97,6 +102,7 @@ export const usePersistedApolloClient = ({ // requires typing outside of wallet package // eslint-disable-next-line @typescript-eslint/no-explicit-any getPerformanceLink((args: any) => sendAnalyticsEvent(WalletEventName.PerformanceGraphql, args)), + getOnRampAuthLink(accounts, signerManager), restLink, apolloLink, ]), diff --git a/packages/wallet/src/data/links.ts b/packages/wallet/src/data/links.ts index 38b17d7c9e4..a4072e55892 100644 --- a/packages/wallet/src/data/links.ts +++ b/packages/wallet/src/data/links.ts @@ -1,16 +1,24 @@ import { ApolloLink, createHttpLink } from '@apollo/client' +import { setContext } from '@apollo/client/link/context' import { onError } from '@apollo/client/link/error' import { RestLink } from 'apollo-link-rest' import { config } from 'uniswap/src/config' import { uniswapUrls } from 'uniswap/src/constants/urls' import { REQUEST_SOURCE, getVersionHeader } from 'uniswap/src/data/constants' +import { GQLQueries } from 'uniswap/src/data/graphql/uniswap-data-api/queries' +import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' +import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' import { logger } from 'utilities/src/logger/logger' +import { isMobile } from 'utilities/src/platform' +import { ON_RAMP_AUTH_MAX_LIMIT, createOnRampTransactionsAuth } from 'wallet/src/data/utils' import { EnsLookupParams, STUB_ONCHAIN_ENS_ENDPOINT, getOnChainEnsFetch } from 'wallet/src/features/ens/api' import { BalanceLookupParams, STUB_ONCHAIN_BALANCES_ENDPOINT, getOnChainBalancesFetch, } from 'wallet/src/features/portfolio/api' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { SignerManager } from 'wallet/src/features/wallet/signing/SignerManager' // mapping from endpoint to custom fetcher, when needed function getCustomFetcherMap( @@ -118,7 +126,8 @@ export function getErrorLink( ) }) } - if (networkError) { + // We use DataDog to catch network errors on Mobile + if (networkError && !isMobile) { sample( () => logger.error(networkError, { tags: { file: 'data/links', function: 'getErrorLink' } }), networkErrorSamplingRate, @@ -154,3 +163,33 @@ export function getPerformanceLink( }) }) } + +export function getOnRampAuthLink(accounts: Record<string, Account>, signerManager: SignerManager): ApolloLink { + return setContext((operation, prevContext) => { + if (operation.operationName !== GQLQueries.TransactionList) { + return prevContext + } + + const enabled = Statsig.checkGate(getFeatureFlagName(FeatureFlags.ForTransactionsFromGraphQL)) + const account = accounts[operation.variables?.address] + + if (!enabled || !account) { + return prevContext + } + + return createOnRampTransactionsAuth(ON_RAMP_AUTH_MAX_LIMIT, account, signerManager).then((onRampAuth) => { + return { + ...prevContext, + onRampAuth, + } + }) + }).concat((operation, forward) => { + if (operation.getContext().onRampAuth) { + operation.variables = { + ...operation.variables, + onRampAuth: operation.getContext().onRampAuth, + } + } + return forward(operation) + }) +} diff --git a/packages/wallet/src/data/tradingApi/api.json b/packages/wallet/src/data/tradingApi/api.json deleted file mode 100644 index 2e793f34b75..00000000000 --- a/packages/wallet/src/data/tradingApi/api.json +++ /dev/null @@ -1 +0,0 @@ -{"openapi":"3.0.0","servers":[{"description":"Uniswap trading APIs Beta","url":"https://beta.trade-api.gateway.uniswap.org/v1"},{"description":"Uniswap trading APIs","url":"https://trade-api.gateway.uniswap.org/v1"}],"info":{"version":"1.0.0","title":"Token Trading","description":"Uniswap trading APIs for fungible tokens."},"paths":{"/check_approval":{"post":{"tags":["Approval"],"summary":"Check if token approval is required","description":"Checks if the swapper has the required approval. If the swapper does not have the required approval, then the response will include the transaction to approve the token. If the swapper has the required approval, then the response will be empty. If the parameter `includeGasInfo` is set to `true`, then the response will include the gas fee for the approval transaction.","operationId":"check_approval","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/ApprovalSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/ApprovalNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/quote":{"post":{"tags":["Quote"],"summary":"Get a quote","description":"Get a quote according to the provided configuration. Optionally adds a fee to the quote according to the API key being used. The fee is **ALWAYS** taken from the output token. If there is a fee and the trade is `EXACT_INPUT`, then the output amount will **NOT** include the fee subtraction. For `EXACT_INPUT` swaps, use `portionBips` to calculate the fee from the quoted amount. If there is a fee and the trade is `EXACT_OUTPUT`, then the input amount will **NOT** include the fee addition to account for the fee. For `EXACT_OUTPUT` swaps, use `portionAmount` to get the fee. \n \n We also support Wrapping and Unwrapping of native tokens on their respective chains. Wrapping and Unwrapping only works for when `routingPreference` is `CLASSIC`, `BEST_PRICE`, or `BEST_PRICE_V2`. We do not support `UNISWAPX` or `UNISWAPX_V2` for these actions.","operationId":"aggregator_quote","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/QuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/order":{"post":{"tags":["Order"],"summary":"Create a gasless order","description":"Submits a new gasless encoded order. The order will be validated and if valid, will be submitted to the filler network. The network will try to fill the order at the quoted `startAmount`, and if not, the amount will start decaying until the `endAmount` is reached. While the order is within `decayEndTime`, the `orderStatus` is `open`. If the order does not get filled after the `decayEndTime` has passed, that is reflected in the `expired` `orderStatus`. then The order will be filled at the best price possible. Once the order is filled, `orderStatus` becomes `filled`.","operationId":"post_dutch_order","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderRequest"}}}},"responses":{"201":{"$ref":"#/components/responses/OrderSuccess201"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/orders":{"get":{"tags":["Order"],"summary":"Get gasless orders","description":"Retrieve gasless orders filtered by query param(s). Some fields on the order can be used as query param.","operationId":"get_dutch_order","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/orderIdParam"},{"$ref":"#/components/parameters/orderIdsParam"},{"$ref":"#/components/parameters/limitParam"},{"$ref":"#/components/parameters/orderStatusParam"},{"$ref":"#/components/parameters/swapperParam"},{"$ref":"#/components/parameters/sortKeyParam"},{"$ref":"#/components/parameters/sortParam"},{"$ref":"#/components/parameters/fillerParam"},{"$ref":"#/components/parameters/cursorParam"}],"responses":{"200":{"$ref":"#/components/responses/OrdersSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/OrdersNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swap":{"post":{"tags":["Swap"],"summary":"Create swap calldata","description":"Create the calldata for a swap transaction (including wrap/unwrap) against the Uniswap Protocols. If the `quote` parameter includes the fee parameters, then the calldata will include the fee disbursement. The gas estimates will be **more precise** when the the response calldata would be valid if submitted on-chain.","operationId":"create_swap_transaction","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateSwapSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/SwapUnauthorized401"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swap/{txHash}":{"get":{"tags":["Swap"],"summary":"Get swap status","description":"Get the status of a swap transaction.","operationId":"get_swap_transaction","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/transactionHashParam"}],"responses":{"200":{"$ref":"#/components/responses/GetSwapSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/indicative_quote":{"post":{"tags":["IndicativeQuote"],"summary":"Get an indicative quote","description":"Get an indicative quote according to the provided configuration. The quote will not include a fee.","operationId":"indicative_quote","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/IndicativeQuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/send":{"post":{"tags":["Send"],"summary":"Create send calldata","description":"Create the calldata for a send transaction.","operationId":"create_send","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateSendSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/SendNotFound404"},"429":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}}},"components":{"responses":{"OrdersSuccess200":{"description":"The request orders matching the query parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetOrdersResponse"}}}},"OrderSuccess201":{"description":"Encoded order submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderResponse"}}}},"QuoteSuccess200":{"description":"Quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"ApprovalSuccess200":{"description":"Check approval successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalResponse"}}}},"CreateSendSuccess200":{"description":"Create send successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendResponse"}}}},"CreateSwapSuccess200":{"description":"Create swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapResponse"}}}},"GetSwapSuccess200":{"description":"Get swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSwapResponse"}}}},"BadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"ApprovalUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"ApprovalNotFound404":{"description":"ResourceNotFound eg. Token allowance not found or Gas info not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"Unauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"QuoteNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SendNotFound404":{"description":"ResourceNotFound eg. Gas fee not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SwapBadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"SwapUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked or Fee is not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"SwapNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"OrdersNotFound404":{"description":"Orders not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"RateLimitedErr429":{"description":"Ratelimited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err429"}}}},"InternalErr500":{"description":"Unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err500"}}}},"Timeout504":{"description":"Request duration limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err504"}}}},"IndicativeQuoteSuccess200":{"description":"Indicative quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteResponse"}}}}},"schemas":{"NullablePermit":{"allOf":[{"$ref":"#/components/schemas/Permit"},{"type":"object","nullable":true}]},"TokenAmount":{"type":"string"},"SwapStatus":{"type":"string","enum":["pending","success","error"]},"GetSwapResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"status":{"$ref":"#/components/schemas/SwapStatus"}},"required":["requestId","status"]},"CreateSwapRequest":{"type":"object","description":"The parameters **signature** and **permitData** should only be included if *permitData* was returned from **/quote**.","properties":{"quote":{"oneOf":[{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"}]},"signature":{"type":"string","description":"The signed permit."},"includeGasInfo":{"type":"boolean","default":false,"deprecated":true,"description":"Use `refreshGasPrice` instead."},"refreshGasPrice":{"type":"boolean","default":false,"description":"If true, the gas price will be re-fetched from the network."},"simulateTransaction":{"type":"boolean","default":false,"description":"If true, the transaction will be simulated. If the simulation results on an onchain error, endpoint will return an error."},"permitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"safetyMode":{"$ref":"#/components/schemas/SwapSafetyMode"},"deadline":{"type":"integer","description":"The deadline for the swap in unix timestamp format. If the deadline is not defined OR in the past then the default deadline is 30 minutes."},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["quote"]},"CreateSendRequest":{"type":"object","properties":{"sender":{"$ref":"#/components/schemas/Address"},"recipient":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["sender","recipient","token","amount"]},"Address":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{40}$"},"ClassicGasUseEstimateUSD":{"description":"The gas fee you would pay if you opted for a CLASSIC swap over a Uniswap X order in terms of USD.","type":"number"},"CreateSwapResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"swap":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}},"required":["requestId","swap"]},"CreateSendResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"send":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"},"gasFeeUSD":{"type":"number"}},"required":["requestId","send"]},"QuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"quote":{"$ref":"#/components/schemas/Quote"},"routing":{"$ref":"#/components/schemas/Routing"},"permitData":{"$ref":"#/components/schemas/NullablePermit"}},"required":["routing","quote","permitData","requestId"]},"QuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"},"swapper":{"$ref":"#/components/schemas/Address"},"slippageTolerance":{"description":"For **Classic** swaps, the slippage tolerance is the maximum amount the price can change between the time the transaction is submitted and the time it is executed. The slippage tolerance is represented as a percentage of the total value of the swap. \n\n Slippage tolerance works differently in **DutchLimit** swaps, it does not set a limit on the Spread in an order. See [here](https://uniswap-docs.readme.io/reference/faqs#why-do-the-uniswapx-quotes-have-more-slippage-than-the-tolerance-i-set) for more information. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","type":"number"},"autoSlippage":{"$ref":"#/components/schemas/AutoSlippage"},"routingPreference":{"$ref":"#/components/schemas/RoutingPreference"},"spreadOptimization":{"$ref":"#/components/schemas/SpreadOptimization"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut","swapper"]},"GetOrdersResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orders":{"type":"array","items":{"$ref":"#/components/schemas/UniswapXOrder"}},"cursor":{"type":"string"}},"required":["orders","requestId"]},"OrderResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orderId":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"}},"required":["requestId","orderId","orderStatus"]},"OrderRequest":{"type":"object","properties":{"signature":{"type":"string","description":"The signed permit."},"quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"}]},"routing":{"$ref":"#/components/schemas/Routing"}},"required":["signature","quote"]},"Urgency":{"type":"string","enum":["normal","fast","urgent"],"description":"The urgency determines the urgency of the transaction. The default value is `urgent`.","default":"urgent"},"Err400":{"type":"object","properties":{"errorCode":{"default":"RequestValidationError","type":"string"},"detail":{"type":"string"}}},"Err401":{"type":"object","properties":{"errorCode":{"default":"UnauthorizedError","type":"string"},"detail":{"type":"string"}}},"Err404":{"type":"object","properties":{"errorCode":{"default":"ResourceNotFound","type":"string"},"detail":{"type":"string"}}},"Err429":{"type":"object","properties":{"errorCode":{"default":"Ratelimited","type":"string"},"detail":{"type":"string"}}},"Err500":{"type":"object","properties":{"errorCode":{"default":"InternalServerError","type":"string"},"detail":{"type":"string"}}},"Err504":{"type":"object","properties":{"errorCode":{"default":"Timeout","type":"string"},"detail":{"type":"string"}}},"ChainId":{"type":"number","enum":[1,10,56,137,8453,42161,81457,43114,42220,7777777,324]},"OrderInput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"}},"required":["token"]},"OrderOutput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"},"isFeeOutput":{"type":"boolean"},"recipient":{"type":"string"}},"required":["token"]},"CosignerData":{"type":"object","properties":{"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"inputOverride":{"type":"string"},"outputOverrides":{"type":"array","items":{"type":"string"}}}},"SettledAmount":{"type":"object","properties":{"tokenOut":{"$ref":"#/components/schemas/Address"},"amountOut":{"type":"string"},"tokenIn":{"$ref":"#/components/schemas/Address"},"amountIn":{"type":"string"}}},"OrderType":{"type":"string","enum":["DutchLimit","Dutch","Dutch_V2"]},"UniswapXOrder":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/OrderType"},"encodedOrder":{"type":"string"},"signature":{"type":"string"},"nonce":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"},"orderId":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"quoteId":{"type":"string"},"swapper":{"type":"string"},"txHash":{"type":"string"},"input":{"$ref":"#/components/schemas/OrderInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/OrderOutput"}},"settledAmounts":{"type":"array","items":{"$ref":"#/components/schemas/SettledAmount"}},"cosignature":{"type":"string"},"cosignerData":{"$ref":"#/components/schemas/CosignerData"}},"required":["encodedOrder","signature","nonce","orderId","orderStatus","chainId","type"]},"SortKey":{"type":"string","enum":["createdAt"]},"OrderId":{"type":"string"},"OrderIds":{"type":"string"},"OrderStatus":{"type":"string","enum":["open","expired","error","cancelled","filled","unverified","insufficient-funds"]},"Permit":{"type":"object","properties":{"domain":{"type":"object"},"values":{"type":"object"},"types":{"type":"object"}}},"DutchInput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"}},"required":["startAmount","endAmount","type"]},"DutchOutput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"},"recipient":{"type":"string"}},"required":["startAmount","endAmount","token","recipient"]},"DutchOrderInfo":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"exclusivityOverrideBps":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchOrderInfoV2":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}},"cosigner":{"$ref":"#/components/schemas/Address"}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchQuote":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfo"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"DutchQuoteV2":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfoV2"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"deadlineBufferSecs":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"TradeType":{"type":"string","enum":["EXACT_INPUT","EXACT_OUTPUT"]},"Routing":{"type":"string","enum":["DUTCH_LIMIT","CLASSIC","DUTCH_V2"]},"Quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"}]},"ApprovalRequest":{"type":"object","properties":{"walletAddress":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"},"includeGasInfo":{"type":"boolean","default":false},"tokenOut":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{40}$","description":"relevant for if we go from a wrapped token to a native token (unwrapping)"},"tokenOutChainId":{"type":"number","enum":[1,10,56,137,8453,42161,81457,43114,42220,7777777,324],"default":1,"description":"relevant for if we go from a wrapped token to a native token (unwrapping)"}},"required":["walletAddress","token","amount"]},"ApprovalResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"approval":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}},"required":["requestId","approval"]},"ClassicQuote":{"type":"object","properties":{"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"swapper":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"slippage":{"type":"number"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei. It does NOT include the additional gas for token approvals."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD. It does NOT include the additional gas for token approvals."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency. It does NOT include the additional gas for token approvals."},"route":{"type":"array","items":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/V3PoolInRoute"},{"$ref":"#/components/schemas/V2PoolInRoute"}]}}},"portionBips":{"type":"number","description":"The portion of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionAmount":{"type":"string","description":"The amount of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionRecipient":{"$ref":"#/components/schemas/Address"},"routeString":{"type":"string","description":"The route in string format."},"quoteId":{"type":"string","description":"The quote id. Used for analytics purposes."},"gasUseEstimate":{"type":"string","description":"The estimated gas use. It does NOT include the additional gas for token approvals."},"blockNumber":{"type":"string","description":"The current block number."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."},"txFailureReasons":{"type":"array","items":{"$ref":"#/components/schemas/TransactionFailureReason"}},"priceImpact":{"type":"number","description":"The impact the trade has on the market price of the pool, between 0-100 percent"}}},"WrapUnwrapQuote":{"type":"object","properties":{"swapper":{"$ref":"#/components/schemas/Address"},"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"chainId":{"$ref":"#/components/schemas/ChainId"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency."},"gasUseEstimate":{"type":"string","description":"The estimated gas use."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."}}},"TokenInRoute":{"type":"object","properties":{"address":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"symbol":{"type":"string"},"decimals":{"type":"string"},"buyFeeBps":{"type":"string"},"sellFeeBps":{"type":"string"}}},"V2Reserve":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/TokenInRoute"},"quotient":{"type":"string"}}},"V2PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v2-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"reserve0":{"$ref":"#/components/schemas/V2Reserve"},"reserve1":{"$ref":"#/components/schemas/V2Reserve"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"V3PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v3-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"sqrtRatioX96":{"type":"string"},"liquidity":{"type":"string"},"tickCurrent":{"type":"string"},"fee":{"type":"string"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"TransactionHash":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{64}$"},"ClassicInput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"}}},"ClassicOutput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"},"recipient":{"$ref":"#/components/schemas/Address"}}},"RequestId":{"type":"string"},"SpreadOptimization":{"type":"string","enum":["EXECUTION","PRICE"],"description":"For **Dutch Limit** orders only. When set to `EXECUTION`, quotes optimize for looser spreads at higher fill rates. When set to `PRICE`, quotes optimize for tighter spreads at lower fill rates","default":"EXECUTION"},"AutoSlippage":{"type":"string","enum":["DEFAULT"],"description":"For **Classic** swaps only. The auto slippage strategy to employ. If auto slippage is not defined then we don't compute it. If the auto slippage strategy is `DEFAULT`, then the swap will use the default slippage tolerance computation. You cannot define auto slippage and slippage tolerance at the same time. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","default":"undefined"},"RoutingPreference":{"type":"string","description":"The routing preference determines which protocol to use for the swap. If the routing preference is `UNISWAPX`, then the swap will be routed through the UniswapX Dutch Auction Protocol. If the routing preference is `CLASSIC`, then the swap will be routed through the Classic Protocol. If the routing preference is `BEST_PRICE`, then the swap will be routed through the protocol that provides the best price. When `UNIXWAPX_V2` is passed, the swap will be routed through the UniswapX V2 Dutch Auction Protocol. When `V3_ONLY` is passed, the swap will be routed ONLY through the Uniswap V3 Protocol. When `V2_ONLY` is passed, the swap will be routed ONLY through the Uniswap V2 Protocol.","enum":["CLASSIC","UNISWAPX","BEST_PRICE","BEST_PRICE_V2","UNISWAPX_V2","V3_ONLY","V2_ONLY"],"default":"BEST_PRICE"},"TransactionRequest":{"type":"object","properties":{"to":{"$ref":"#/components/schemas/Address"},"from":{"$ref":"#/components/schemas/Address"},"data":{"type":"string","description":"The calldata for the transaction."},"value":{"type":"string","description":"The value of the transaction in terms of wei in hex format."},"gasLimit":{"type":"string"},"chainId":{"type":"integer"},"maxFeePerGas":{"type":"string"},"maxPriorityFeePerGas":{"type":"string"},"gasPrice":{"type":"string"}},"required":["to","from","data","value","chainId"]},"TransactionFailureReason":{"type":"string","enum":["SIMULATION_ERROR","UNSUPPORTED_SIMULATION"]},"SwapSafetyMode":{"type":"string","enum":["SAFE"],"description":"The safety mode determines the safety level of the swap. If the safety mode is `SAFE`, then the swap will include a SWEEP for the native token."},"IndicativeQuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut"]},"IndicativeQuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"input":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"output":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"type":{"$ref":"#/components/schemas/TradeType"}},"required":["requestId","input","output","type"]},"IndicativeQuoteToken":{"type":"object","properties":{"amount":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"token":{"$ref":"#/components/schemas/Address"}}}},"parameters":{"addressParam":{"name":"address","in":"path","schema":{"$ref":"#/components/schemas/Address"},"required":true},"tokenIdParam":{"name":"tokenId","in":"path","schema":{"type":"string"},"required":true},"cursorParam":{"name":"cursor","in":"query","schema":{"type":"string"},"required":false},"limitParam":{"name":"limit","in":"query","schema":{"type":"number"},"required":false},"chainParam":{"name":"chain","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"addressPathParam":{"name":"address","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"orderStatusParam":{"name":"orderStatus","in":"query","description":"Filter by order status.","required":false,"schema":{"$ref":"#/components/schemas/OrderStatus"}},"orderIdParam":{"name":"orderId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/OrderId"}},"orderIdsParam":{"name":"orderIds","in":"query","required":false,"description":"ids split by commas","schema":{"$ref":"#/components/schemas/OrderIds"}},"swapperParam":{"name":"swapper","in":"query","description":"Filter by swapper address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"fillerParam":{"name":"filler","in":"query","description":"Filter by filler address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"sortKeyParam":{"name":"sortKey","in":"query","description":"Order the query results by the sort key.","required":false,"schema":{"$ref":"#/components/schemas/SortKey"}},"sortParam":{"name":"sort","in":"query","description":"Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`.","required":false,"schema":{"type":"string"}},"descParam":{"description":"Sort query results by sortKey in descending order.","name":"desc","in":"query","required":false,"schema":{"type":"string"}},"transactionHashParam":{"description":"The transaction hash.","name":"txHash","in":"path","required":true,"schema":{"$ref":"#/components/schemas/TransactionHash"}}},"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"x-api-key"}}},"security":[{"apiKey":[]}]} \ No newline at end of file diff --git a/packages/wallet/src/features/accounts/hooks.ts b/packages/wallet/src/features/accounts/hooks.ts index 94a04fc8b63..f05d9576188 100644 --- a/packages/wallet/src/features/accounts/hooks.ts +++ b/packages/wallet/src/features/accounts/hooks.ts @@ -5,7 +5,6 @@ import { useAccountListQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' -// eslint-disable-next-line no-restricted-imports import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' export function useAccountList({ diff --git a/packages/wallet/src/features/activity/useActivityData.tsx b/packages/wallet/src/features/activity/useActivityData.tsx index 4ae89b383ad..7956ae739b7 100644 --- a/packages/wallet/src/features/activity/useActivityData.tsx +++ b/packages/wallet/src/features/activity/useActivityData.tsx @@ -9,7 +9,6 @@ import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext import { useFormattedTransactionDataForActivity } from 'wallet/src/features/activity/hooks' import { LoadingItem, SectionHeader } from 'wallet/src/features/activity/utils' import { AuthTrigger } from 'wallet/src/features/auth/types' -import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { SwapSummaryCallbacks } from 'wallet/src/features/transactions/SummaryCards/types' import { ActivityItemRenderer, generateActivityItemRenderer } from 'wallet/src/features/transactions/SummaryCards/utils' import { useCreateSwapFormState, useMergeLocalAndRemoteTransactions } from 'wallet/src/features/transactions/hooks' @@ -69,13 +68,7 @@ export function useActivityData({ }, [navigateToSwapFlow]) const renderActivityItem = useMemo(() => { - return generateActivityItemRenderer( - TransactionSummaryLayout, - <Loader.Transaction />, - SectionTitle, - swapCallbacks, - authTrigger, - ) + return generateActivityItemRenderer(<Loader.Transaction />, SectionTitle, swapCallbacks, authTrigger) }, [swapCallbacks, authTrigger]) const { onRetry, isError, sectionData, keyExtractor } = useFormattedTransactionDataForActivity( diff --git a/packages/wallet/src/features/appearance/hooks.tsx b/packages/wallet/src/features/appearance/hooks.tsx index 9e35f21d339..0b4f8ce14d3 100644 --- a/packages/wallet/src/features/appearance/hooks.tsx +++ b/packages/wallet/src/features/appearance/hooks.tsx @@ -1,9 +1,10 @@ import { useColorScheme } from 'react-native' +import { useSelector } from 'react-redux' import { AppearanceSettingType } from 'wallet/src/features/appearance/slice' -import { useAppSelector } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' export function useCurrentAppearanceSetting(): AppearanceSettingType { - const { selectedAppearanceSettings } = useAppSelector((state) => state.appearanceSettings) + const { selectedAppearanceSettings } = useSelector((state: RootState) => state.appearanceSettings) return selectedAppearanceSettings } diff --git a/packages/wallet/src/features/behaviorHistory/selectors.ts b/packages/wallet/src/features/behaviorHistory/selectors.ts index 18ca836bc8a..d1265787948 100644 --- a/packages/wallet/src/features/behaviorHistory/selectors.ts +++ b/packages/wallet/src/features/behaviorHistory/selectors.ts @@ -1,4 +1,3 @@ -import { ExtensionBetaFeedbackState, ExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/slice' import { SharedState } from 'wallet/src/state/reducer' export const selectHasViewedReviewScreen = (state: SharedState): boolean => state.behaviorHistory.hasViewedReviewScreen @@ -11,9 +10,3 @@ export const selectHasSkippedUnitagPrompt = (state: SharedState): boolean => export const selectHasCompletedUnitagsIntroModal = (state: SharedState): boolean => state.behaviorHistory.hasCompletedUnitagsIntroModal - -export const selectExtensionOnboardingState = (state: SharedState): ExtensionOnboardingState => - state.behaviorHistory.extensionOnboardingState - -export const selectExtensionBetaFeedbackState = (state: SharedState): ExtensionBetaFeedbackState | undefined => - state.behaviorHistory.extensionBetaFeedbackState diff --git a/packages/wallet/src/features/behaviorHistory/slice.ts b/packages/wallet/src/features/behaviorHistory/slice.ts index 833f133e695..facb7f99f0c 100644 --- a/packages/wallet/src/features/behaviorHistory/slice.ts +++ b/packages/wallet/src/features/behaviorHistory/slice.ts @@ -1,16 +1,5 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -export enum ExtensionOnboardingState { - Undefined, // We'll query for the status at every app launch - ReadyToOnboard, // User ready to onboard, should see promo banner - Completed, // User has onboarded or dismissed call to action -} - -export enum ExtensionBetaFeedbackState { - ReadyToShow, // Ready to show feedback modal - Shown, // Feedback modal shown -} - /** * Used to store persisted info about a users interactions with UI. * We use this to show conditional UI, usually only for the first time a user views a new feature. @@ -20,8 +9,6 @@ export interface BehaviorHistoryState { hasSubmittedHoldToSwap: boolean hasSkippedUnitagPrompt: boolean hasCompletedUnitagsIntroModal: boolean - extensionOnboardingState: ExtensionOnboardingState - extensionBetaFeedbackState: ExtensionBetaFeedbackState | undefined } export const initialBehaviorHistoryState: BehaviorHistoryState = { @@ -29,8 +16,6 @@ export const initialBehaviorHistoryState: BehaviorHistoryState = { hasSubmittedHoldToSwap: false, hasSkippedUnitagPrompt: false, hasCompletedUnitagsIntroModal: false, - extensionOnboardingState: ExtensionOnboardingState.Undefined, - extensionBetaFeedbackState: undefined, } const slice = createSlice({ @@ -49,12 +34,6 @@ const slice = createSlice({ setHasCompletedUnitagsIntroModal: (state, action: PayloadAction<boolean>) => { state.hasCompletedUnitagsIntroModal = action.payload }, - setExtensionOnboardingState: (state, action: PayloadAction<ExtensionOnboardingState>) => { - state.extensionOnboardingState = action.payload - }, - setExtensionBetaFeedbackState: (state, action: PayloadAction<ExtensionBetaFeedbackState>) => { - state.extensionBetaFeedbackState = action.payload - }, }, }) @@ -63,8 +42,6 @@ export const { setHasSubmittedHoldToSwap, setHasSkippedUnitagPrompt, setHasCompletedUnitagsIntroModal, - setExtensionOnboardingState, - setExtensionBetaFeedbackState, } = slice.actions export const behaviorHistoryReducer = slice.reducer diff --git a/packages/wallet/src/features/dataApi/balances.test.ts b/packages/wallet/src/features/dataApi/balances.test.ts index 9bb2b929e3d..966e7e0f343 100644 --- a/packages/wallet/src/features/dataApi/balances.test.ts +++ b/packages/wallet/src/features/dataApi/balances.test.ts @@ -5,6 +5,15 @@ import { Chain, PortfolioBalanceDocument, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { + sortPortfolioBalances, + useHighestBalanceNativeCurrencyId, + usePortfolioBalances, + usePortfolioCacheUpdater, + usePortfolioTotalValue, + useSortedPortfolioBalances, + useTokenBalancesGroupedByVisibility, +} from 'uniswap/src/features/dataApi/balances' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { ARBITRUM_CURRENCY, @@ -14,17 +23,7 @@ import { POLYGON_CURRENCY, currencyInfo, } from 'uniswap/src/test/fixtures' -import { - sortPortfolioBalances, - useHighestBalanceNativeCurrencyId, - usePortfolioBalances, - usePortfolioCacheUpdater, - usePortfolioTotalValue, - // eslint-disable-next-line no-restricted-imports - usePortfolioValueModifiers, - useSortedPortfolioBalances, - useTokenBalancesGroupedByVisibility, -} from 'wallet/src/features/dataApi/balances' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { FavoritesState, initialFavoritesState } from 'wallet/src/features/favorites/slice' import { WalletState, initialWalletState } from 'wallet/src/features/wallet/slice' import { @@ -218,7 +217,7 @@ describe(usePortfolioValueModifiers, () => { describe(usePortfolioBalances, () => { it('returns empty results if no address was specified', () => { - const { result } = renderHook(() => usePortfolioBalances({})) + const { result } = renderHook(() => usePortfolioBalances({ valueModifiers: [] })) expect(result.current).toEqual({ data: undefined, @@ -230,7 +229,7 @@ describe(usePortfolioBalances, () => { }) it('returns loading set to true when data is being fetched', async () => { - const { result } = renderHook(() => usePortfolioBalances({ address: Portfolio.ownerAddress }), { + const { result } = renderHook(() => usePortfolioBalances({ valueModifiers: [], address: Portfolio.ownerAddress }), { resolvers: portfolioResolvers, }) @@ -253,7 +252,7 @@ describe(usePortfolioBalances, () => { throw new Error('test') }, }) - const { result } = renderHook(() => usePortfolioBalances({ address: Portfolio.ownerAddress }), { + const { result } = renderHook(() => usePortfolioBalances({ valueModifiers: [], address: Portfolio.ownerAddress }), { resolvers, }) @@ -272,7 +271,7 @@ describe(usePortfolioBalances, () => { const { resolvers } = queryResolvers({ portfolios: () => [], }) - const { result } = renderHook(() => usePortfolioBalances({ address: Portfolio.ownerAddress }), { + const { result } = renderHook(() => usePortfolioBalances({ valueModifiers: [], address: Portfolio.ownerAddress }), { resolvers, }) @@ -290,7 +289,7 @@ describe(usePortfolioBalances, () => { }) it('returns balances grouped by currencyId', async () => { - const { result } = renderHook(() => usePortfolioBalances({ address: Portfolio.ownerAddress }), { + const { result } = renderHook(() => usePortfolioBalances({ valueModifiers: [], address: Portfolio.ownerAddress }), { resolvers: portfolioResolvers, }) @@ -326,7 +325,7 @@ describe(usePortfolioBalances, () => { describe(usePortfolioTotalValue, () => { it('returns empty results if no address was specified', () => { - const { result } = renderHook(() => usePortfolioTotalValue({})) + const { result } = renderHook(() => usePortfolioTotalValue({ valueModifiers: [] })) expect(result.current).toEqual({ data: undefined, @@ -419,7 +418,7 @@ describe(useHighestBalanceNativeCurrencyId, () => { const { resolvers } = queryResolvers({ portfolios: () => [portfolio({ tokenBalances: [daiTokenBalance] })], }) - const { result } = renderHook(() => useHighestBalanceNativeCurrencyId(SAMPLE_SEED_ADDRESS_1), { + const { result } = renderHook(() => useHighestBalanceNativeCurrencyId(SAMPLE_SEED_ADDRESS_1, []), { resolvers, }) @@ -429,7 +428,7 @@ describe(useHighestBalanceNativeCurrencyId, () => { }) it('returns native currency id with the highest balance', async () => { - const { result } = renderHook(() => useHighestBalanceNativeCurrencyId(SAMPLE_SEED_ADDRESS_1), { + const { result } = renderHook(() => useHighestBalanceNativeCurrencyId(SAMPLE_SEED_ADDRESS_1, []), { resolvers: portfolioResolvers, }) diff --git a/packages/wallet/src/features/dataApi/balances.ts b/packages/wallet/src/features/dataApi/balances.ts index 3710c35c1ef..0be3859cba6 100644 --- a/packages/wallet/src/features/dataApi/balances.ts +++ b/packages/wallet/src/features/dataApi/balances.ts @@ -1,43 +1,19 @@ -import { NetworkStatus, Reference, useApolloClient, WatchQueryFetchPolicy } from '@apollo/client' -import { useCallback, useMemo } from 'react' -import { PollingInterval } from 'uniswap/src/constants/misc' +import { useMemo } from 'react' import { ContractInput, - IAmount, - PortfolioBalanceDocument, - PortfolioBalancesQuery, PortfolioValueModifier, - usePortfolioBalancesQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { GqlResult } from 'uniswap/src/data/types' -import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { CurrencyInfo, PortfolioBalance } from 'uniswap/src/features/dataApi/types' -import { buildCurrency, currencyIdToContractInput, usePersistedError } from 'uniswap/src/features/dataApi/utils' -import { CurrencyId } from 'uniswap/src/types/currency' -import { currencyId } from 'uniswap/src/utils/currencyId' -import { usePlatformBasedFetchPolicy } from 'uniswap/src/utils/usePlatformBasedFetchPolicy' -import { logger } from 'utilities/src/logger/logger' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import { useCurrencyIdToVisibility } from 'wallet/src/features/transactions/selectors' import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' -type SortedPortfolioBalances = { - balances: PortfolioBalance[] - hiddenBalances: PortfolioBalance[] -} - -export type PortfolioTotalValue = { - balanceUSD: number | undefined - percentChange: number | undefined - absoluteChangeUSD: number | undefined -} - interface TokenOverrides { tokenIncludeOverrides: ContractInput[] tokenExcludeOverrides: ContractInput[] } -export type PortfolioCacheUpdater = (hidden: boolean, portfolioBalance?: PortfolioBalance) => void - +// TODO(MOB-3643): Redux state sharing opportunites +// Share usePortfolioValueModifiers when redux state for visibility settings is available export function usePortfolioValueModifiers(address?: Address | Address[]): PortfolioValueModifier[] | undefined { // Memoize array creation if passed a string to avoid recomputing at every render const addressArray = useMemo(() => (!address ? [] : Array.isArray(address) ? address : [address]), [address]) @@ -74,397 +50,3 @@ export function usePortfolioValueModifiers(address?: Address | Address[]): Portf return modifiers.length > 0 ? modifiers : undefined } - -/** - * Returns all balances indexed by checksummed currencyId for a given address - * @param address - * @param pollInterval optional `PollingInterval` representing polling frequency. - * If undefined, will query once and not poll. - * NOTE: - * on TokenDetails, useBalances relies rely on usePortfolioBalances but don't need - * polling versions of it. Including polling was causing multiple polling intervals - * to be kicked off with usePortfolioBalances. - * Same with on Token Selector's TokenSearchResultList, since the home screen - * has a usePortfolioBalances polling hook, we don't need to duplicate the - * polling interval when token selector is open - * @param onCompleted - * @param fetchPolicy - * @returns - */ -export function usePortfolioBalances({ - address, - pollInterval, - onCompleted, - fetchPolicy, -}: { - address?: Address - pollInterval?: PollingInterval - onCompleted?: () => void - fetchPolicy?: WatchQueryFetchPolicy -}): GqlResult<Record<CurrencyId, PortfolioBalance>> & { networkStatus: NetworkStatus } { - const valueModifiers = usePortfolioValueModifiers(address) - - const { fetchPolicy: internalFetchPolicy, pollInterval: internalPollInterval } = usePlatformBasedFetchPolicy({ - fetchPolicy, - pollInterval, - }) - - const { - data: balancesData, - loading, - networkStatus, - refetch, - error, - } = usePortfolioBalancesQuery({ - fetchPolicy: internalFetchPolicy, - notifyOnNetworkStatusChange: true, - onCompleted, - pollInterval: internalPollInterval, - variables: address ? { ownerAddress: address, valueModifiers } : undefined, - skip: !address, - }) - - const persistedError = usePersistedError(loading, error) - const balancesForAddress = balancesData?.portfolios?.[0]?.tokenBalances - - const formattedData = useMemo(() => { - if (!balancesForAddress) { - return - } - - const byId: Record<CurrencyId, PortfolioBalance> = {} - balancesForAddress.forEach((balance) => { - const { - __typename: tokenBalanceType, - id: tokenBalanceId, - denominatedValue, - token, - tokenProjectMarket, - quantity, - isHidden, - } = balance || {} - const { address: tokenAddress, chain, decimals, symbol, project } = token || {} - const { name, logoUrl, isSpam, safetyLevel } = project || {} - const chainId = fromGraphQLChain(chain) - - // require all of these fields to be defined - if (!balance || !quantity || !token) { - return - } - - const currency = buildCurrency({ - chainId, - address: tokenAddress, - decimals, - symbol, - name, - }) - - if (!currency) { - return - } - - const id = currencyId(currency) - - const currencyInfo: CurrencyInfo = { - currency, - currencyId: currencyId(currency), - logoUrl, - isSpam, - safetyLevel, - } - - const portfolioBalance: PortfolioBalance = { - cacheId: `${tokenBalanceType}:${tokenBalanceId}`, - quantity, - balanceUSD: denominatedValue?.value, - currencyInfo, - relativeChange24: tokenProjectMarket?.relativeChange24?.value, - isHidden, - } - - byId[id] = portfolioBalance - }) - - return byId - }, [balancesForAddress]) - - const retry = useCallback( - () => refetch({ ownerAddress: address, valueModifiers }), - [address, valueModifiers, refetch], - ) - - return { - data: formattedData, - loading, - networkStatus, - refetch: retry, - error: persistedError, - } -} - -export function usePortfolioTotalValue({ - address, - pollInterval, - onCompleted, - fetchPolicy, -}: { - address?: Address - pollInterval?: PollingInterval - onCompleted?: () => void - fetchPolicy?: WatchQueryFetchPolicy -}): GqlResult<PortfolioTotalValue> & { networkStatus: NetworkStatus } { - const valueModifiers = usePortfolioValueModifiers(address) - - const { fetchPolicy: internalFetchPolicy, pollInterval: internalPollInterval } = usePlatformBasedFetchPolicy({ - fetchPolicy, - pollInterval, - }) - - const { - data: balancesData, - loading, - networkStatus, - refetch, - error, - } = usePortfolioBalancesQuery({ - fetchPolicy: internalFetchPolicy, - notifyOnNetworkStatusChange: true, - onCompleted, - pollInterval: internalPollInterval, - variables: address ? { ownerAddress: address, valueModifiers } : undefined, - skip: !address, - }) - - const persistedError = usePersistedError(loading, error) - const portfolioForAddress = balancesData?.portfolios?.[0] - - const formattedData = useMemo(() => { - if (!portfolioForAddress) { - return - } - - return { - balanceUSD: portfolioForAddress?.tokensTotalDenominatedValue?.value, - percentChange: portfolioForAddress?.tokensTotalDenominatedValueChange?.percentage?.value, - absoluteChangeUSD: portfolioForAddress?.tokensTotalDenominatedValueChange?.absolute?.value, - } - }, [portfolioForAddress]) - - const retry = useCallback( - () => refetch({ ownerAddress: address, valueModifiers }), - [address, valueModifiers, refetch], - ) - - return { - data: formattedData, - loading, - networkStatus, - refetch: retry, - error: persistedError, - } -} - -/** - * Returns NativeCurrency with highest balance. - * - * @param address to get portfolio balances for - * @returns CurrencyId of the NativeCurrency with highest balance - * - */ -export function useHighestBalanceNativeCurrencyId(address: Address): CurrencyId | undefined { - const { data } = useSortedPortfolioBalances({ address }) - return data?.balances.find((balance) => balance.currencyInfo.currency.isNative)?.currencyInfo.currencyId -} - -/** - * Custom hook to group Token Balances fetched from API to shown and hidden. - * - * @param balancesById - An object where keys are token ids and values are the corresponding balances. May be undefined. - * - * @returns {object} An object containing two fields: - * - `shownTokens`: shown tokens. - * - `hiddenTokens`: hidden tokens. - * - * @example - * const { shownTokens, hiddenTokens } = useTokenBalancesGroupedByVisibility({ balancesById }); - */ -export function useTokenBalancesGroupedByVisibility({ - balancesById, -}: { - balancesById?: Record<string, PortfolioBalance> -}): { - shownTokens: PortfolioBalance[] | undefined - hiddenTokens: PortfolioBalance[] | undefined -} { - return useMemo(() => { - if (!balancesById) { - return { shownTokens: undefined, hiddenTokens: undefined } - } - - const { shown, hidden } = Object.values(balancesById).reduce<{ - shown: PortfolioBalance[] - hidden: PortfolioBalance[] - }>( - (acc, balance) => { - if (balance.isHidden) { - acc.hidden.push(balance) - } else { - acc.shown.push(balance) - } - return acc - }, - { shown: [], hidden: [] }, - ) - return { - shownTokens: shown.length ? shown : undefined, - hiddenTokens: hidden.length ? hidden : undefined, - } - }, [balancesById]) -} - -/** - * Returns portfolio balances for a given address sorted by USD value. - * - * @param address to get portfolio balances for - * @param pollInterval optional polling interval for auto refresh. - * If undefined, query will run only once. - * @param onCompleted callback - * @returns SortedPortfolioBalances object with `balances` and `hiddenBalances` - */ -export function useSortedPortfolioBalances({ - address, - pollInterval, - onCompleted, -}: { - address: Address - pollInterval?: PollingInterval - valueModifiers?: PortfolioValueModifier[] - onCompleted?: () => void -}): GqlResult<SortedPortfolioBalances> & { networkStatus: NetworkStatus } { - // Fetch all balances including small balances and spam tokens because we want to return those in separate arrays - const { - data: balancesById, - loading, - networkStatus, - refetch, - } = usePortfolioBalances({ - address, - pollInterval, - onCompleted, - fetchPolicy: 'cache-and-network', - }) - - const { shownTokens, hiddenTokens } = useTokenBalancesGroupedByVisibility({ balancesById }) - - return { - data: { - balances: sortPortfolioBalances(shownTokens || []), - hiddenBalances: sortPortfolioBalances(hiddenTokens || []), - }, - loading, - networkStatus, - refetch, - } -} - -/** - * Helper function to stable sort balances by descending balanceUSD, - * followed by balances with null balanceUSD values sorted alphabetically - * */ -export function sortPortfolioBalances(balances: PortfolioBalance[]): PortfolioBalance[] { - const balancesWithUSDValue = balances.filter((b) => b.balanceUSD) - const balancesWithoutUSDValue = balances.filter((b) => !b.balanceUSD) - - return [ - ...balancesWithUSDValue.sort((a, b) => { - if (!a.balanceUSD) { - return 1 - } - if (!b.balanceUSD) { - return -1 - } - return b.balanceUSD - a.balanceUSD - }), - ...balancesWithoutUSDValue.sort((a, b) => { - if (!a.currencyInfo.currency.name) { - return 1 - } - if (!b.currencyInfo.currency.name) { - return -1 - } - return a.currencyInfo.currency.name?.localeCompare(b.currencyInfo.currency.name) - }), - ] -} - -/** - * Creates a function to update the Apollo cache when a token is shown or hidden. - * We manually modify the cache to avoid having to wait for the server's response, - * so that the change is immediately reflected in the UI. - * - * @param address active wallet address - * @returns a `PortfolioCacheUpdater` function that will update the Apollo cache - */ -export function usePortfolioCacheUpdater(address: string): PortfolioCacheUpdater { - const apolloClient = useApolloClient() - - const updater = useCallback( - (hidden: boolean, portfolioBalance?: PortfolioBalance) => { - if (!portfolioBalance) { - return - } - - const cachedPortfolio = apolloClient.readQuery<PortfolioBalancesQuery>({ - query: PortfolioBalanceDocument, - variables: { - owner: address, - }, - })?.portfolios?.[0] - - if (!cachedPortfolio) { - return - } - - apolloClient.cache.modify({ - id: portfolioBalance.cacheId, - fields: { - isHidden() { - return hidden - }, - }, - }) - - apolloClient.cache.modify({ - id: apolloClient.cache.identify(cachedPortfolio), - fields: { - tokensTotalDenominatedValue(amount: Reference | IAmount, { isReference }) { - if (isReference(amount)) { - // I don't think this should ever happen, but this is required to keep TS happy after upgrading to @apollo/client > 3.8. - logger.error(new Error('Unable to modify cache for `tokensTotalDenominatedValue`'), { - tags: { - file: 'balances.ts', - function: 'usePortfolioCacheUpdater', - }, - extra: { - portfolioId: apolloClient.cache.identify(cachedPortfolio), - }, - }) - return amount - } - - const newValue = portfolioBalance.balanceUSD - ? hidden - ? amount.value - portfolioBalance.balanceUSD - : amount.value + portfolioBalance.balanceUSD - : amount.value - return { ...amount, value: newValue } - }, - }, - }) - }, - [apolloClient, address], - ) - - return updater -} diff --git a/packages/wallet/src/features/fiatCurrency/conversion.ts b/packages/wallet/src/features/fiatCurrency/conversion.ts index db513fc721a..a5d9b04ffc3 100644 --- a/packages/wallet/src/features/fiatCurrency/conversion.ts +++ b/packages/wallet/src/features/fiatCurrency/conversion.ts @@ -1,8 +1,6 @@ import { useCallback, useMemo } from 'react' import { PollingInterval } from 'uniswap/src/constants/misc' import { Currency, useConvertQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { FiatNumberType } from 'utilities/src/format/types' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { getFiatCurrencyCode, useAppFiatCurrency } from 'wallet/src/features/fiatCurrency/hooks' @@ -84,14 +82,6 @@ export interface FiatConverter { ) => string } -// Temporary function for feature turned off -function convertFiatAmountDefault(amount: number): { amount: number; currency: FiatCurrency } { - return { - amount, - currency: FiatCurrency.UnitedStatesDollar, - } -} - const SOURCE_CURRENCY = Currency.Usd // Assuming all currency data comes from USD /** @@ -105,7 +95,6 @@ const SOURCE_CURRENCY = Currency.Usd // Assuming all currency data comes from US export function useFiatConverter({ formatNumberOrString, }: Pick<LocalizationContextState, 'formatNumberOrString'>): FiatConverter { - const featureEnabled = useFeatureFlag(FeatureFlags.CurrencyConversion) const appCurrency = useAppFiatCurrency() const toCurrency = mapFiatCurrencyToServerCurrency[appCurrency] @@ -159,9 +148,9 @@ export function useFiatConverter({ return useMemo( () => ({ - convertFiatAmount: featureEnabled ? convertFiatAmountInner : convertFiatAmountDefault, + convertFiatAmount: convertFiatAmountInner, convertFiatAmountFormatted: convertFiatAmountFormattedInner, }), - [convertFiatAmountFormattedInner, convertFiatAmountInner, featureEnabled], + [convertFiatAmountFormattedInner, convertFiatAmountInner], ) } diff --git a/packages/wallet/src/features/fiatCurrency/hooks.ts b/packages/wallet/src/features/fiatCurrency/hooks.ts index e88d3e51a4a..b6f18a543a9 100644 --- a/packages/wallet/src/features/fiatCurrency/hooks.ts +++ b/packages/wallet/src/features/fiatCurrency/hooks.ts @@ -1,15 +1,14 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { AppTFunction } from 'ui/src/i18n/types' import { FiatCurrencyInfo } from 'uniswap/src/features/fiatOnRamp/types' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' // eslint-disable-next-line no-restricted-imports import { FiatCurrencyComponents, getFiatCurrencyComponents } from 'utilities/src/format/localeBased' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useCurrentLocale } from 'wallet/src/features/language/hooks' -import { useAppSelector } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' /** * Helper function for getting the ISO currency code from our internal enum @@ -113,9 +112,8 @@ export function useFiatCurrencyInfo(currency: FiatCurrency): FiatCurrencyInfo { * @returns currently selected fiat currency */ export function useAppFiatCurrency(): FiatCurrency { - const featureEnabled = useFeatureFlag(FeatureFlags.CurrencyConversion) - const { currentCurrency } = useAppSelector((state) => state.fiatCurrencySettings) - return featureEnabled ? currentCurrency : FiatCurrency.UnitedStatesDollar + const { currentCurrency } = useSelector((state: RootState) => state.fiatCurrencySettings) + return currentCurrency } /** diff --git a/apps/mobile/src/features/fiatOnRamp/hooks.ts b/packages/wallet/src/features/fiatOnRamp/hooks.ts similarity index 87% rename from apps/mobile/src/features/fiatOnRamp/hooks.ts rename to packages/wallet/src/features/fiatOnRamp/hooks.ts index f57fbf211c8..c4b30e9a3a9 100644 --- a/apps/mobile/src/features/fiatOnRamp/hooks.ts +++ b/packages/wallet/src/features/fiatOnRamp/hooks.ts @@ -3,13 +3,16 @@ import { FetchBaseQueryError, skipToken } from '@reduxjs/toolkit/query/react' import { Currency } from '@uniswap/sdk-core' import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { getCountry } from 'react-native-localize' import { useDispatch } from 'react-redux' -import { Delay } from 'src/components/layout/Delayed' import { ColorTokens } from 'ui/src' +import { useCurrencies } from 'uniswap/src/components/TokenSelector/hooks' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { useFiatOnRampAggregatorCryptoQuoteQuery, + useFiatOnRampAggregatorGetCountryQuery, useFiatOnRampAggregatorSupportedFiatCurrenciesQuery, useFiatOnRampAggregatorSupportedTokensQuery, } from 'uniswap/src/features/fiatOnRamp/api' @@ -30,8 +33,6 @@ import { WalletChainId } from 'uniswap/src/types/chains' import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' import { NumberType } from 'utilities/src/format/types' import { useDebounce } from 'utilities/src/time/timing' -import { useCurrencies } from 'wallet/src/components/TokenSelector/hooks' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { useAppFiatCurrencyInfo, useFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -46,6 +47,8 @@ import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' import { getFormattedCurrencyAmount } from 'wallet/src/utils/currency' import { ValueType } from 'wallet/src/utils/getCurrencyAmount' +const SHORT_DELAY = 500 + export function useFormatExactCurrencyAmount(currencyAmount: string, currency: Maybe<Currency>): string | undefined { const formatter = useLocalizationContext() @@ -98,7 +101,10 @@ export function useFiatOnRampTransactionCreator( return { externalTransactionId: externalTransactionId.current, dispatchAddTransaction } } -export function useMeldFiatCurrencySupportInfo(countryCode: string): { +export function useMeldFiatCurrencySupportInfo( + countryCode: string, + skip: boolean = false, +): { appFiatCurrencySupportedInMeld: boolean meldSupportedFiatCurrency: FiatCurrencyInfo supportedFiatCurrencies: FORSupportedFiatCurrency[] | undefined @@ -108,9 +114,10 @@ export function useMeldFiatCurrencySupportInfo(countryCode: string): { const fallbackCurrencyInfo = useFiatCurrencyInfo(FiatCurrency.UnitedStatesDollar) const appFiatCurrencyCode = appFiatCurrencyInfo.code.toLowerCase() - const { data: supportedFiatCurrencies } = useFiatOnRampAggregatorSupportedFiatCurrenciesQuery({ - countryCode, - }) + const { data: supportedFiatCurrencies } = useFiatOnRampAggregatorSupportedFiatCurrenciesQuery( + { countryCode }, + { skip }, + ) const appFiatCurrencySupported = !supportedFiatCurrencies || @@ -153,9 +160,11 @@ function buildCurrencyIdForFORSupportedToken(supportedToken: FORSupportedToken): export function useFiatOnRampSupportedTokens({ sourceCurrencyCode, countryCode, + skip = false, }: { sourceCurrencyCode: string countryCode: string + skip?: boolean }): { error: boolean list: FiatOnRampCurrency[] | undefined @@ -167,7 +176,7 @@ export function useFiatOnRampSupportedTokens({ isLoading: supportedTokensLoading, error: supportedTokensError, refetch: refetchSupportedTokens, - } = useFiatOnRampAggregatorSupportedTokensQuery({ fiatCurrency: sourceCurrencyCode, countryCode }) + } = useFiatOnRampAggregatorSupportedTokensQuery({ fiatCurrency: sourceCurrencyCode, countryCode }, { skip }) const currencyIds: string[] = useMemo( () => @@ -229,7 +238,7 @@ export function useFiatOnRampQuotes({ error?: FetchBaseQueryError | SerializedError quotes: FORQuote[] | undefined } { - const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short) + const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, SHORT_DELAY) const walletAddress = useActiveAccountAddress() const { @@ -304,3 +313,32 @@ export function useParseFiatOnRampError( return { errorText, errorColor } } + +export function useIsSupportedFiatOnRampCurrency( + currencyId: string, + skip: boolean = false, +): FiatOnRampCurrency | undefined { + const fallbackCountryCode = getCountry() + const { currentData: ipCountryData } = useFiatOnRampAggregatorGetCountryQuery(undefined, { skip }) + const { meldSupportedFiatCurrency } = useMeldFiatCurrencySupportInfo( + ipCountryData?.countryCode ?? fallbackCountryCode, + skip, + ) + const { + list: supportedTokensList, + loading: supportedTokensLoading, + error: supportedTokensError, + } = useFiatOnRampSupportedTokens({ + sourceCurrencyCode: meldSupportedFiatCurrency.code, + countryCode: ipCountryData?.countryCode ?? fallbackCountryCode, + skip, + }) + + if (supportedTokensLoading || supportedTokensError) { + return undefined + } + + const foundToken = supportedTokensList?.find((token) => token.currencyInfo?.currencyId === currencyId) + + return foundToken +} diff --git a/packages/wallet/src/features/gas/formatExternalTxnWithGasEstimates.tsx b/packages/wallet/src/features/gas/formatExternalTxnWithGasEstimates.tsx index 83a1652b7a1..64c3f642d74 100644 --- a/packages/wallet/src/features/gas/formatExternalTxnWithGasEstimates.tsx +++ b/packages/wallet/src/features/gas/formatExternalTxnWithGasEstimates.tsx @@ -5,15 +5,9 @@ import { GasFeeResult } from 'wallet/src/features/gas/types' * This util should be used for formatting all external txn requests with gas estimates. This is * primarily WC transactions and dapp transactions on extension. * - * We should always be using the estimates from a dapp if they are provided. `gasLimit` will not - * always be included along with fee estimates - use our limit in that case if missing. - * - * If no valid fee combination is found (for legacy type 1, or eip1559 type 2), we should use our own - * estimates instead. Our estimates come from a request to our gas service in both WC and Dapp interaction - * flows. + * Always use our own gas estimates and override and values from the provider txn request. * */ - export function formatExternalTxnWithGasEstimates({ transaction, gasFeeResult, @@ -21,22 +15,17 @@ export function formatExternalTxnWithGasEstimates({ transaction: providers.TransactionRequest gasFeeResult: GasFeeResult }): providers.TransactionRequest { - const { gasLimit: gasLimitDapp, gasPrice, maxFeePerGas, maxPriorityFeePerGas } = transaction - const requestHasLegacyGasValues = !!gasPrice - const requestHasEIP1559GasValues = !!maxFeePerGas && !!maxPriorityFeePerGas - const requestHasValidGasEstimates = requestHasLegacyGasValues || requestHasEIP1559GasValues + const { params } = gasFeeResult - if (requestHasValidGasEstimates) { - return { - ...transaction, - // Avoid `??` in case dapp passes empty string - gasLimit: gasLimitDapp || gasFeeResult?.params?.gasLimit, - } - } + // Remove preset gas params from txn, account for both type 1 and type 2 gas formats + delete transaction.gasLimit + delete transaction.gasPrice + delete transaction.maxFeePerGas + delete transaction.maxPriorityFeePerGas const formattedTxnWithGasEstimates: providers.TransactionRequest = { ...transaction, - ...gasFeeResult.params, + ...params, } return formattedTxnWithGasEstimates diff --git a/packages/wallet/src/features/gas/types.ts b/packages/wallet/src/features/gas/types.ts index 88b2926289d..e76d4da5838 100644 --- a/packages/wallet/src/features/gas/types.ts +++ b/packages/wallet/src/features/gas/types.ts @@ -60,26 +60,6 @@ export type TransactionEip1559FeeParams = { // GasFeeResponse is the type that comes directly from the Gas Service API export type GasFeeResponse = GasFeeResponseEip1559 | GasFeeResponseLegacy -// TransactionGasFeeInfo is the transformed response that is readily usable -// by components -export type TransactionGasFeeInfo = { - type: FeeType - speed: GasSpeed - - // gasFee.value is the total network fee denoted in wei of the native currency - // this is the value to be converted into USD and shown to the user - gasFee: GasFeeResult - - // these are the values corresponding to gasFee that are eventually - // passed to the transaction itself - params: TransactionLegacyFeeParams | TransactionEip1559FeeParams -} - -export type UseTransactionGasFeeResponse = { - data?: TransactionGasFeeInfo - error?: ApolloError -} - export type GasFeeResult = { value?: string loading: boolean diff --git a/packages/wallet/src/utils/balance.test.ts b/packages/wallet/src/features/gas/useMaxAmountSpend.test.ts similarity index 65% rename from packages/wallet/src/utils/balance.test.ts rename to packages/wallet/src/features/gas/useMaxAmountSpend.test.ts index d99cd3a2b35..d25322aebc8 100644 --- a/packages/wallet/src/utils/balance.test.ts +++ b/packages/wallet/src/features/gas/useMaxAmountSpend.test.ts @@ -3,101 +3,116 @@ import JSBI from 'jsbi' import { DAI } from 'uniswap/src/constants/tokens' import { ARBITRUM_CURRENCY, MAINNET_CURRENCY, OPTIMISM_CURRENCY, POLYGON_CURRENCY } from 'uniswap/src/test/fixtures' import { - MIN_ARBITRUM_FOR_GAS, - MIN_ETH_FOR_GAS, - MIN_OPTIMISM_FOR_GAS, - MIN_POLYGON_FOR_GAS, - maxAmountSpend, -} from 'wallet/src/utils/balance' + useMaxAmountSpend, + useMinEthForGas, + useMinGenericL2ForGas, + useMinPolygonForGas, +} from 'wallet/src/features/gas/useMaxAmountSpend' -describe(maxAmountSpend, () => { +jest.mock('uniswap/src/features/gating/hooks', () => { + return { + useDynamicConfigValue: jest.fn().mockImplementation((config: unknown, key: unknown, defaultVal: unknown) => { + return defaultVal + }), + } +}) + +describe(useMaxAmountSpend, () => { it('handles undefined', () => { - expect(maxAmountSpend(undefined)).toEqual(undefined) - expect(maxAmountSpend(null)).toEqual(undefined) + expect(useMaxAmountSpend(undefined)).toEqual(undefined) + expect(useMaxAmountSpend(null)).toEqual(undefined) }) it('handles token amounts', () => { const tokenAmount = CurrencyAmount.fromRawAmount(DAI, '100000000') - expect(maxAmountSpend(tokenAmount)).toBe(tokenAmount) + expect(useMaxAmountSpend(tokenAmount)).toBe(tokenAmount) }) // ETH Mainnet it('reserves gas for large amounts on ETH Mainnet', () => { + const MIN_ETH_FOR_GAS = useMinEthForGas() const amount = CurrencyAmount.fromRawAmount( MAINNET_CURRENCY, JSBI.add(JSBI.BigInt(99), JSBI.BigInt(MIN_ETH_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('99') }) it('handles small amounts on ETH Mainnet', () => { + const MIN_ETH_FOR_GAS = useMinEthForGas() const amount = CurrencyAmount.fromRawAmount( MAINNET_CURRENCY, JSBI.subtract(JSBI.BigInt(99), JSBI.BigInt(MIN_ETH_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('0') }) // Polygon it('reserves gas for large amounts on Polygon', () => { + const MIN_POLYGON_FOR_GAS = useMinPolygonForGas() const amount = CurrencyAmount.fromRawAmount( POLYGON_CURRENCY, JSBI.add(JSBI.BigInt(99), JSBI.BigInt(MIN_POLYGON_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('99') }) it('handles small amounts on Polygon', () => { + const MIN_POLYGON_FOR_GAS = useMinPolygonForGas() const amount = CurrencyAmount.fromRawAmount( POLYGON_CURRENCY, JSBI.subtract(JSBI.BigInt(99), JSBI.BigInt(MIN_POLYGON_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('0') }) // Arbitrum it('reserves gas for large amounts on Arbitrum', () => { + const MIN_ARBITRUM_FOR_GAS = useMinGenericL2ForGas() const amount = CurrencyAmount.fromRawAmount( ARBITRUM_CURRENCY, JSBI.add(JSBI.BigInt(99), JSBI.BigInt(MIN_ARBITRUM_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('99') }) it('handles small amounts on Arbitrum', () => { + const MIN_ARBITRUM_FOR_GAS = useMinGenericL2ForGas() const amount = CurrencyAmount.fromRawAmount( ARBITRUM_CURRENCY, JSBI.subtract(JSBI.BigInt(99), JSBI.BigInt(MIN_ARBITRUM_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('0') }) // Optimism it('reserves gas for large amounts on Optimism', () => { + const MIN_OPTIMISM_FOR_GAS = useMinGenericL2ForGas() const amount = CurrencyAmount.fromRawAmount( OPTIMISM_CURRENCY, JSBI.add(JSBI.BigInt(99), JSBI.BigInt(MIN_OPTIMISM_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('99') }) it('handles small amounts on Optimism', () => { + const MIN_OPTIMISM_FOR_GAS = useMinGenericL2ForGas() const amount = CurrencyAmount.fromRawAmount( OPTIMISM_CURRENCY, JSBI.subtract(JSBI.BigInt(99), JSBI.BigInt(MIN_OPTIMISM_FOR_GAS)), ) - const amount1Spend = maxAmountSpend(amount) + const amount1Spend = useMaxAmountSpend(amount) expect(amount1Spend?.quotient.toString()).toEqual('0') }) }) diff --git a/packages/wallet/src/features/gas/useMaxAmountSpend.ts b/packages/wallet/src/features/gas/useMaxAmountSpend.ts new file mode 100644 index 00000000000..bc5cf574204 --- /dev/null +++ b/packages/wallet/src/features/gas/useMaxAmountSpend.ts @@ -0,0 +1,128 @@ +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import JSBI from 'jsbi' +import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' +import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { logger } from 'utilities/src/logger/logger' +import { TransactionType } from 'wallet/src/features/transactions/types' +import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' + +const NATIVE_CURRENCY_DECIMAL = 18 +const NATIVE_CURRENCY_DECIMAL_OFFSET = NATIVE_CURRENCY_DECIMAL - 4 + +/** + * Given some token amount, return the max that can be spent of it + * @param currencyAmount to return max of + * @param transactionType to determine cost of transaction + */ +export function useMaxAmountSpend( + currencyAmount: Maybe<CurrencyAmount<Currency>>, + txType?: TransactionType, +): Maybe<CurrencyAmount<Currency>> { + const minAmount = useGetMinAmount(currencyAmount?.currency.chainId, txType) + + if (!currencyAmount || !minAmount) { + return undefined + } + if (!currencyAmount.currency.isNative) { + return currencyAmount + } + + // If amount is negative then set it to 0 + const amount = JSBI.greaterThan(currencyAmount.quotient, minAmount) + ? JSBI.subtract(currencyAmount.quotient, minAmount).toString() + : '0' + + return getCurrencyAmount({ + value: amount, + valueType: ValueType.Raw, + currency: currencyAmount.currency, + }) +} + +function useGetMinAmount(chainId?: UniverseChainId, txType?: TransactionType): JSBI | undefined { + const MIN_ETH_FOR_GAS = useMinEthForGas(txType) + const MIN_POLYGON_FOR_GAS = useMinPolygonForGas(txType) + const MIN_AVALANCHE_FOR_GAS = useMinAvalancheForGas(txType) + const MIN_CELO_FOR_GAS = useMinCeloForGas(txType) + const MIN_L2_FOR_GAS = useMinGenericL2ForGas(txType) + + if (!chainId) { + return undefined + } + + switch (chainId) { + case UniverseChainId.Mainnet: + return MIN_ETH_FOR_GAS + case UniverseChainId.Polygon: + return MIN_POLYGON_FOR_GAS + case UniverseChainId.Avalanche: + return MIN_AVALANCHE_FOR_GAS + case UniverseChainId.Celo: + return MIN_CELO_FOR_GAS + case UniverseChainId.ArbitrumOne: + case UniverseChainId.Optimism: + case UniverseChainId.Base: + case UniverseChainId.Bnb: + case UniverseChainId.Blast: + case UniverseChainId.Zora: + case UniverseChainId.Zksync: + return MIN_L2_FOR_GAS + default: + logger.error(new Error('unhandled chain when getting min gas amount'), { + tags: { + file: 'useMaxAmountSpend.ts', + function: 'getMinAmount', + }, + }) + return MIN_L2_FOR_GAS + } +} + +export function useMinEthForGas(txType?: TransactionType): JSBI { + return useCalculateMinForGas( + isSend(txType) ? SwapConfigKey.EthSendMinGasAmount : SwapConfigKey.EthSwapMinGasAmount, + isSend(txType) ? 150 : 20, // .015 and .002 ETH + ) +} + +export function useMinPolygonForGas(txType?: TransactionType): JSBI { + return useCalculateMinForGas( + isSend(txType) ? SwapConfigKey.PolygonSendMinGasAmount : SwapConfigKey.PolygonSwapMinGasAmount, + isSend(txType) ? 600 : 75, // .06 and .0075 MATIC + ) +} + +export function useMinAvalancheForGas(txType?: TransactionType): JSBI { + return useCalculateMinForGas( + isSend(txType) ? SwapConfigKey.AvalancheSendMinGasAmount : SwapConfigKey.AvalancheSwapMinGasAmount, + isSend(txType) ? 200 : 25, // .02 and .0025 AVAX + ) +} + +export function useMinCeloForGas(txType?: TransactionType): JSBI { + return useCalculateMinForGas( + isSend(txType) ? SwapConfigKey.CeloSendMinGasAmount : SwapConfigKey.CeloSwapMinGasAmount, + isSend(txType) ? 100 : 13, // .01 and .0013 CELO + ) +} + +export function useMinGenericL2ForGas(txType?: TransactionType): JSBI { + return useCalculateMinForGas( + isSend(txType) ? SwapConfigKey.GenericL2SendMinGasAmount : SwapConfigKey.GenericL2SwapMinGasAmount, + isSend(txType) ? 8 : 1, // .0008 and .0001 ETH + ) +} + +export function useCalculateMinForGas(amount: SwapConfigKey, defaultAmount: number): JSBI { + const multiplier = useDynamicConfigValue(DynamicConfigs.Swap, amount, defaultAmount) + + return JSBI.multiply( + JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(NATIVE_CURRENCY_DECIMAL_OFFSET)), + JSBI.BigInt(multiplier), + ) +} + +function isSend(transactionType?: TransactionType): boolean { + return transactionType === TransactionType.Send +} diff --git a/packages/wallet/src/features/images/RemoteImage.tsx b/packages/wallet/src/features/images/RemoteImage.tsx index 8d16a21de94..b6bd78293b7 100644 --- a/packages/wallet/src/features/images/RemoteImage.tsx +++ b/packages/wallet/src/features/images/RemoteImage.tsx @@ -32,7 +32,9 @@ export function RemoteImage({ const imageHttpUrl = uriToHttpUrls(uri)[0] if (!imageHttpUrl) { - logger.warn('RemoteImage', '', `Could not retrieve and format remote image for uri: ${uri}`) + logger.warn('RemoteImage', '', 'Could not retrieve and format remote image for uri', { + data: uri, + }) return fallback ?? null } diff --git a/packages/wallet/src/features/language/hooks.tsx b/packages/wallet/src/features/language/hooks.tsx index 3d1b8b8753c..18b9c0856b4 100644 --- a/packages/wallet/src/features/language/hooks.tsx +++ b/packages/wallet/src/features/language/hooks.tsx @@ -1,15 +1,15 @@ import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { AppTFunction } from 'ui/src/i18n/types' import { Language, Locale, mapLanguageToLocale } from 'wallet/src/features/language/constants' import { selectCurrentLanguage } from 'wallet/src/features/language/slice' -import { useAppSelector } from 'wallet/src/state' /** * Hook used to get the currently selected language for the app * @returns currently selected language enum */ export function useCurrentLanguage(): Language { - return useAppSelector(selectCurrentLanguage) + return useSelector(selectCurrentLanguage) } export type LanguageInfo = { diff --git a/packages/wallet/src/features/language/saga.ts b/packages/wallet/src/features/language/saga.ts index 784bbdd1ac8..88d4ff6eee9 100644 --- a/packages/wallet/src/features/language/saga.ts +++ b/packages/wallet/src/features/language/saga.ts @@ -1,11 +1,10 @@ import { I18nManager } from 'react-native' import RNRestart from 'react-native-restart' import { call, put, select, takeLatest } from 'typed-redux-saga' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' import i18n from 'uniswap/src/i18n/i18n' import { getDeviceLocales } from 'utilities/src/device/locales' import { logger } from 'utilities/src/logger/logger' +import { isMobile } from 'utilities/src/platform' import { Language, Locale, @@ -21,11 +20,6 @@ export function* appLanguageWatcherSaga() { } function* appLanguageSaga(action: ReturnType<typeof updateLanguage>) { - const featureEnabled = Statsig.checkGate(getFeatureFlagName(FeatureFlags.LanguageSelection)) - if (!featureEnabled) { - return - } - const { payload: preferredLanguage } = action const currentAppLanguage = yield* select(selectCurrentLanguage) @@ -45,7 +39,9 @@ function* appLanguageSaga(action: ReturnType<typeof updateLanguage>) { logger.warn('language/saga', 'appLanguageSaga', 'Sync of language setting state and i18n instance failed') } - yield* call(restartAppIfRTL, localeToSet) + if (isMobile) { + yield* call(restartAppIfRTL, localeToSet) + } } function getDeviceLanguage(): Language { diff --git a/packages/wallet/src/features/nfts/hooks.ts b/packages/wallet/src/features/nfts/hooks.ts index 51b6c28bf76..fa781d1f23a 100644 --- a/packages/wallet/src/features/nfts/hooks.ts +++ b/packages/wallet/src/features/nfts/hooks.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { useSelector } from 'react-redux' import { PollingInterval } from 'uniswap/src/constants/misc' import { NftsQuery, useNftsQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' @@ -10,7 +11,6 @@ import { } from 'wallet/src/features/nfts/constants' import { NFTItem } from 'wallet/src/features/nfts/types' import { getIsNftHidden } from 'wallet/src/features/nfts/utils' -import { useAppSelector } from 'wallet/src/state' export type GQLNftAsset = NonNullable< NonNullable<NonNullable<NonNullable<NftsQuery['portfolios']>[0]>['nftBalances']>[0] @@ -44,7 +44,7 @@ export function useGroupNftsByVisibility( numHidden: number numShown: number } { - const nftVisibility = useAppSelector(selectNftsVisibility) + const nftVisibility = useSelector(selectNftsVisibility) return useMemo(() => { const { shown, hidden } = (nftDataItems ?? []).reduce<{ shown: NFTItem[] diff --git a/packages/wallet/src/features/nfts/useNftContextMenu.tsx b/packages/wallet/src/features/nfts/useNftContextMenu.tsx index 1fbbbd293bf..6f359ed1a44 100644 --- a/packages/wallet/src/features/nfts/useNftContextMenu.tsx +++ b/packages/wallet/src/features/nfts/useNftContextMenu.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { NativeSyntheticEvent } from 'react-native' import { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { GeneratedIcon, isWeb, useIsDarkMode } from 'ui/src' import { Eye, EyeOff } from 'ui/src/components/icons' import { UNIVERSE_CHAIN_LOGO } from 'uniswap/src/assets/chainLogos' @@ -17,7 +17,6 @@ import { getIsNftHidden, getNFTAssetKey } from 'wallet/src/features/nfts/utils' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { useAccounts } from 'wallet/src/features/wallet/hooks' -import { useAppSelector } from 'wallet/src/state' import { getExplorerName } from 'wallet/src/utils/linking' interface NFTMenuParams { @@ -52,7 +51,7 @@ export function useNFTContextMenu({ const accounts = useAccounts() const isLocalAccount = owner && !!accounts[owner] - const nftVisibility = useAppSelector(selectNftsVisibility) + const nftVisibility = useSelector(selectNftsVisibility) const nftKey = contractAddress && tokenId ? getNFTAssetKey(contractAddress, tokenId) : undefined const hidden = getIsNftHidden({ contractAddress, tokenId, isSpam, nftVisibility }) diff --git a/packages/wallet/src/features/notifications/components/NotificationToast.tsx b/packages/wallet/src/features/notifications/components/NotificationToast.tsx index 4fcf07a8fe1..21d823e2aa1 100644 --- a/packages/wallet/src/features/notifications/components/NotificationToast.tsx +++ b/packages/wallet/src/features/notifications/components/NotificationToast.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect } from 'react' import { Directions, FlingGestureHandler, FlingGestureHandlerGestureEvent, State } from 'react-native-gesture-handler' import { useAnimatedStyle, useSharedValue, withDelay, withSpring } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Flex, Text, @@ -23,7 +23,6 @@ import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { useTimeout } from 'utilities/src/time/timing' import { selectActiveAccountNotifications } from 'wallet/src/features/notifications/selectors' import { popNotification, setNotificationViewed } from 'wallet/src/features/notifications/slice' -import { useAppSelector } from 'wallet/src/state' const NOTIFICATION_HEIGHT = 64 @@ -39,6 +38,7 @@ export interface NotificationContentProps { title: string subtitle?: string icon?: JSX.Element + iconPosition?: 'left' | 'right' actionButton?: { title: string onPress: () => void @@ -77,6 +77,7 @@ export function NotificationToast({ subtitle, title, icon, + iconPosition = 'left', onPress, onPressIn, hideDelay, @@ -86,7 +87,7 @@ export function NotificationToast({ }: NotificationToastProps): JSX.Element { const isDarkMode = useIsDarkMode() const dispatch = useDispatch() - const notifications = useAppSelector(selectActiveAccountNotifications) + const notifications = useSelector(selectActiveAccountNotifications) const currentNotification = notifications?.[0] const hasQueuedNotification = !!notifications?.[1] @@ -163,11 +164,18 @@ export function NotificationToast({ pointerEvents="auto" > {smallToast ? ( - <NotificationContentSmall icon={icon} title={title} onPress={onNotificationPress} onPressIn={onPressIn} /> + <NotificationContentSmall + icon={icon} + iconPosition={iconPosition} + title={title} + onPress={onNotificationPress} + onPressIn={onPressIn} + /> ) : ( <NotificationContent actionButton={actionButton ? { title: actionButton.title, onPress: onActionButtonPress } : undefined} icon={icon} + iconPosition={iconPosition} subtitle={subtitle} title={title} onPress={onNotificationPress} @@ -201,6 +209,7 @@ function NotificationContent({ title, subtitle, icon, + iconPosition, actionButton, onPress, onPressIn, @@ -226,7 +235,7 @@ function NotificationContent({ gap="$spacing12" justifyContent="flex-start" > - {icon} + {iconPosition === 'left' ? icon : undefined} <Flex shrink alignItems="flex-start" flexDirection="column"> <Text adjustsFontSizeToFit @@ -242,6 +251,7 @@ function NotificationContent({ </Text> )} </Flex> + {iconPosition === 'right' ? icon : undefined} </Flex> {actionButton && ( <Flex shrink alignItems="flex-end" flexBasis="25%" gap="$spacing4"> @@ -257,7 +267,13 @@ function NotificationContent({ ) } -function NotificationContentSmall({ title, icon, onPress, onPressIn }: NotificationContentProps): JSX.Element { +function NotificationContentSmall({ + title, + icon, + iconPosition, + onPress, + onPressIn, +}: NotificationContentProps): JSX.Element { return ( <Flex centered row shrink pointerEvents="box-none"> <TouchableArea @@ -268,10 +284,11 @@ function NotificationContentSmall({ title, icon, onPress, onPressIn }: Notificat onPressIn={onPressIn} > <Flex row alignItems="center" gap="$spacing8" justifyContent="flex-start" pr="$spacing4"> - <Flex>{icon}</Flex> + {iconPosition === 'left' ? <Flex>{icon}</Flex> : undefined} <Text adjustsFontSizeToFit numberOfLines={1} testID={TestID.NotificationToastTitle} variant="body2"> {title} </Text> + {iconPosition === 'right' ? <Flex>{icon}</Flex> : undefined} </Flex> </TouchableArea> </Flex> diff --git a/packages/wallet/src/features/notifications/components/PendingNotificationBadge.tsx b/packages/wallet/src/features/notifications/components/PendingNotificationBadge.tsx index 60f4354084e..1d6e89ce6c6 100644 --- a/packages/wallet/src/features/notifications/components/PendingNotificationBadge.tsx +++ b/packages/wallet/src/features/notifications/components/PendingNotificationBadge.tsx @@ -1,3 +1,4 @@ +import { useSelector } from 'react-redux' import { Flex, SpinningLoader, useSporeColors } from 'ui/src' import AlertCircle from 'ui/src/assets/icons/alert-circle.svg' import { CheckmarkCircle } from 'ui/src/components/icons' @@ -8,7 +9,6 @@ import { AppNotificationType } from 'wallet/src/features/notifications/types' import { useSortedPendingTransactions } from 'wallet/src/features/transactions/hooks' import { TransactionStatus } from 'wallet/src/features/transactions/types' import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' -import { useAppSelector } from 'wallet/src/state' const PENDING_TX_TIME_LIMIT = 60_000 * 5 // 5 mins const LOADING_SPINNER_SIZE = iconSizes.icon20 @@ -19,8 +19,8 @@ interface Props { export function PendingNotificationBadge({ size = LOADING_SPINNER_SIZE }: Props): JSX.Element | null { const colors = useSporeColors() - const activeAccountAddress = useAppSelector(selectActiveAccountAddress) - const notifications = useAppSelector(selectActiveAccountNotifications) + const activeAccountAddress = useSelector(selectActiveAccountAddress) + const notifications = useSelector(selectActiveAccountNotifications) const sortedPendingTransactions = useSortedPendingTransactions(activeAccountAddress) const hasNotifications = useSelectAddressHasNotifications(activeAccountAddress) diff --git a/packages/wallet/src/features/notifications/components/SwapPendingNotification.tsx b/packages/wallet/src/features/notifications/components/SwapPendingNotification.tsx index 5b93f2ca003..5be572798b7 100644 --- a/packages/wallet/src/features/notifications/components/SwapPendingNotification.tsx +++ b/packages/wallet/src/features/notifications/components/SwapPendingNotification.tsx @@ -20,6 +20,7 @@ export function SwapPendingNotification({ notification }: { notification: SwapPe smallToast hideDelay={TRANSACTION_PENDING_NOTIFICATION_DELAY} icon={<SpinningLoader color="$accent1" />} + iconPosition="right" title={notificationText} /> ) diff --git a/packages/wallet/src/features/notifications/hooks.ts b/packages/wallet/src/features/notifications/hooks.ts index 6ee32dda0c2..1157974ce91 100644 --- a/packages/wallet/src/features/notifications/hooks.ts +++ b/packages/wallet/src/features/notifications/hooks.ts @@ -1,8 +1,9 @@ import { useMemo } from 'react' +import { useSelector } from 'react-redux' import { makeSelectHasNotifications } from 'wallet/src/features/notifications/selectors' -import { useAppSelector } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' export function useSelectAddressHasNotifications(address: Address | null): boolean | undefined { const selectHasNotifications = useMemo(makeSelectHasNotifications, []) - return useAppSelector((state) => selectHasNotifications(state, address)) + return useSelector((state: RootState) => selectHasNotifications(state, address)) } diff --git a/packages/wallet/src/features/onboarding/OnboardingContext.tsx b/packages/wallet/src/features/onboarding/OnboardingContext.tsx index a80e9f90d2a..576f501c163 100644 --- a/packages/wallet/src/features/onboarding/OnboardingContext.tsx +++ b/packages/wallet/src/features/onboarding/OnboardingContext.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UnitagClaim } from 'uniswap/src/features/unitags/types' @@ -21,7 +21,6 @@ import { EditAccountAction, editAccountActions } from 'wallet/src/features/walle import { Account, BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' import { createAccountsActions } from 'wallet/src/features/wallet/create/createAccountsSaga' import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' -import { useAppSelector } from 'wallet/src/state' export interface OnboardingContext { generateOnboardingAccount: (password?: string) => Promise<void> @@ -101,7 +100,7 @@ export function OnboardingContextProvider({ children }: PropsWithChildren<unknow const dispatch = useDispatch() const { t } = useTranslation() const claimUnitag = useClaimUnitag() - const sortedMnemonicAccounts = useAppSelector(selectSortedSignerMnemonicAccounts) + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) const [onboardingAccount, setOnboardingAccount] = useState<SignerMnemonicAccount | undefined>() const [unitagClaim, setUnitagClaim] = useState<UnitagClaim | undefined>() diff --git a/packages/wallet/src/features/portfolio/PortfolioBalance.tsx b/packages/wallet/src/features/portfolio/PortfolioBalance.tsx index 7122ad96a6d..75ad4e9e101 100644 --- a/packages/wallet/src/features/portfolio/PortfolioBalance.tsx +++ b/packages/wallet/src/features/portfolio/PortfolioBalance.tsx @@ -1,10 +1,11 @@ import { memo } from 'react' import { Flex, Shine, Text, isWeb } from 'ui/src' import { PollingInterval } from 'uniswap/src/constants/misc' +import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances' import { NumberType } from 'utilities/src/format/types' import { RelativeChange } from 'wallet/src/components/text/RelativeChange' import { isWarmLoadingStatus } from 'wallet/src/data/utils' -import { usePortfolioTotalValue } from 'wallet/src/features/dataApi/balances' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' import { useAppFiatCurrency, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -17,11 +18,13 @@ interface PortfolioBalanceProps { } export const PortfolioBalance = memo(function _PortfolioBalance({ owner }: PortfolioBalanceProps): JSX.Element { + const valueModifiers = usePortfolioValueModifiers(owner) ?? [] const { data, loading, networkStatus } = usePortfolioTotalValue({ address: owner, // TransactionHistoryUpdater will refetch this query on new transaction. // No need to be super aggressive with polling here. pollInterval: PollingInterval.Normal, + valueModifiers, }) const currency = useAppFiatCurrency() diff --git a/packages/wallet/src/features/portfolio/PortfolioEmptyState.tsx b/packages/wallet/src/features/portfolio/PortfolioEmptyState.tsx index c234aab7605..4c417bacfa3 100644 --- a/packages/wallet/src/features/portfolio/PortfolioEmptyState.tsx +++ b/packages/wallet/src/features/portfolio/PortfolioEmptyState.tsx @@ -7,8 +7,6 @@ import { ArrowDownCircle, Buy as BuyIcon, PaperStack } from 'ui/src/components/i import { borderRadii } from 'ui/src/theme' import { ActionCard, ActionCardItem } from 'uniswap/src/components/misc/ActionCard' import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { ImageUri } from 'wallet/src/features/images/ImageUri' import { AccountType } from 'wallet/src/features/wallet/accounts/types' @@ -28,16 +26,22 @@ type WalletEmptyStateProps = { // Buying and importing are optionally supported onPressImport?: () => void onPressBuy?: () => void + // If buy is supported but not from a cex like on the extension + disableCexTransfers?: boolean } -export function PortfolioEmptyState({ onPressReceive, onPressImport, onPressBuy }: WalletEmptyStateProps): JSX.Element { +export function PortfolioEmptyState({ + onPressReceive, + onPressImport, + onPressBuy, + disableCexTransfers = false, +}: WalletEmptyStateProps): JSX.Element { const { t } = useTranslation() const isDarkMode = useIsDarkMode() const activeAccount = useActiveAccount() const isViewOnly = activeAccount?.type === AccountType.Readonly - const cexTransferEnabled = useFeatureFlag(FeatureFlags.CexTransfers) - const cexTransferProviders = useCexTransferProviders(cexTransferEnabled) + const cexTransferProviders = useCexTransferProviders({ isDisabled: disableCexTransfers }) const BackgroundImageWrapperCallback = useCallback( ({ children }: { children: React.ReactNode }) => { diff --git a/packages/wallet/src/features/portfolio/TokenBalanceListContext.tsx b/packages/wallet/src/features/portfolio/TokenBalanceListContext.tsx index ec0ddaf2234..4d3a32f5425 100644 --- a/packages/wallet/src/features/portfolio/TokenBalanceListContext.tsx +++ b/packages/wallet/src/features/portfolio/TokenBalanceListContext.tsx @@ -11,9 +11,10 @@ import { useState, } from 'react' import { PollingInterval } from 'uniswap/src/constants/misc' +import { usePortfolioBalances, useTokenBalancesGroupedByVisibility } from 'uniswap/src/features/dataApi/balances' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { isWarmLoadingStatus } from 'wallet/src/data/utils' -import { usePortfolioBalances, useTokenBalancesGroupedByVisibility } from 'wallet/src/features/dataApi/balances' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' type CurrencyId = string export const HIDDEN_TOKEN_BALANCES_ROW = 'HIDDEN_TOKEN_BALANCES_ROW' as const @@ -43,6 +44,8 @@ export function TokenBalanceListContextProvider({ isExternalProfile: boolean onPressToken: (currencyId: CurrencyId) => void }>): JSX.Element { + const valueModifiers = usePortfolioValueModifiers(owner) ?? [] + const { data: balancesById, networkStatus, @@ -51,6 +54,7 @@ export function TokenBalanceListContextProvider({ address: owner, pollInterval: PollingInterval.KindaFast, fetchPolicy: 'cache-and-network', + valueModifiers, }) // re-order token balances to visible and hidden diff --git a/packages/wallet/src/features/portfolio/useTokenContextMenu.tsx b/packages/wallet/src/features/portfolio/useTokenContextMenu.tsx index 39e2bf7808e..8f534da3217 100644 --- a/packages/wallet/src/features/portfolio/useTokenContextMenu.tsx +++ b/packages/wallet/src/features/portfolio/useTokenContextMenu.tsx @@ -5,6 +5,7 @@ import type { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-nat import { useDispatch } from 'react-redux' import { GeneratedIcon, isWeb } from 'ui/src' import { CoinConvert, Eye, EyeOff, ReceiveAlt, SendAction } from 'ui/src/components/icons' +import { usePortfolioCacheUpdater } from 'uniswap/src/features/dataApi/balances' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -14,7 +15,6 @@ import { CurrencyId } from 'uniswap/src/types/currency' import { areCurrencyIdsEqual, currencyIdToAddress, currencyIdToChain } from 'uniswap/src/utils/currencyId' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' -import { usePortfolioCacheUpdater } from 'wallet/src/features/dataApi/balances' import { toggleTokenVisibility } from 'wallet/src/features/favorites/slice' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' diff --git a/packages/wallet/src/features/providers/createEthersProvider.ts b/packages/wallet/src/features/providers/createEthersProvider.ts index 815691b0479..b84e8a21dc4 100644 --- a/packages/wallet/src/features/providers/createEthersProvider.ts +++ b/packages/wallet/src/features/providers/createEthersProvider.ts @@ -1,9 +1,7 @@ import { providers as ethersProviders } from 'ethers' -import { config } from 'uniswap/src/config' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { RPCType, WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' -import { getInfuraChainName } from 'wallet/src/features/providers/utils' // Should use ProviderManager for provider access unless being accessed outside of ProviderManagerContext (e.g., Apollo initialization) export function createEthersProvider( @@ -24,8 +22,7 @@ export function createEthersProvider( if (publicRPCUrl) { return new ethersProviders.JsonRpcProvider(publicRPCUrl) } - - return new ethersProviders.InfuraProvider(getInfuraChainName(chainId), config.infuraProjectId) + throw new Error(`No public RPC available for chain ${chainId}`) } catch (error) { const altPublicRPCUrl = UNIVERSE_CHAIN_INFO[chainId].rpcUrls?.[RPCType.PublicAlt]?.http[0] return new ethersProviders.JsonRpcProvider(altPublicRPCUrl) diff --git a/packages/wallet/src/features/providers/utils.ts b/packages/wallet/src/features/providers/utils.ts index ebb53fb14c2..6980f467c51 100644 --- a/packages/wallet/src/features/providers/utils.ts +++ b/packages/wallet/src/features/providers/utils.ts @@ -1,48 +1,5 @@ import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' -import { RPCType, UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' - -// Should match supported chains in `InfuraProvider` class within `getUrl` method -export type InfuraChainName = - | 'homestead' - | 'goerli' - | 'arbitrum' - | 'base' - | 'bnbsmartchain-mainnet' - | 'optimism' - | 'matic' - | 'maticmum' - | 'blast' - | 'avalanche-mainnet' - | 'celo-mainnet' - -export function getInfuraChainName(chainId: WalletChainId): InfuraChainName { - switch (chainId) { - case UniverseChainId.Mainnet: - return 'homestead' - case UniverseChainId.Goerli: - return 'goerli' - case UniverseChainId.ArbitrumOne: - return 'arbitrum' - case UniverseChainId.Base: - return 'base' - case UniverseChainId.Bnb: - return 'bnbsmartchain-mainnet' - case UniverseChainId.Optimism: - return 'optimism' - case UniverseChainId.Polygon: - return 'matic' - case UniverseChainId.PolygonMumbai: - return 'maticmum' - case UniverseChainId.Blast: - return 'blast' - case UniverseChainId.Avalanche: - return 'avalanche-mainnet' - case UniverseChainId.Celo: - return 'celo-mainnet' - default: - throw new Error(`Unsupported eth infura chainId for ${chainId}`) - } -} +import { RPCType, WalletChainId } from 'uniswap/src/types/chains' export function isPrivateRpcSupportedOnChain(chainId: WalletChainId): boolean { return Boolean(UNIVERSE_CHAIN_INFO[chainId]?.rpcUrls?.[RPCType.Private]) diff --git a/packages/wallet/src/features/search/SearchResult.ts b/packages/wallet/src/features/search/SearchResult.ts index d7dc214340a..e7a65be2495 100644 --- a/packages/wallet/src/features/search/SearchResult.ts +++ b/packages/wallet/src/features/search/SearchResult.ts @@ -1,5 +1,4 @@ -import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' +import { SearchResultBase, SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { WalletChainId } from 'uniswap/src/types/chains' export type SearchResult = TokenSearchResult | WalletSearchResult | EtherscanSearchResult | NFTCollectionSearchResult @@ -13,11 +12,6 @@ export function extractDomain(walletName: string, type: SearchResultType): strin return walletName.substring(index + 1) } -export interface SearchResultBase { - type: SearchResultType - searchId?: string -} - export type WalletSearchResult = ENSAddressSearchResult | UnitagSearchResult | WalletByAddressSearchResult export interface WalletByAddressSearchResult extends SearchResultBase { @@ -39,16 +33,6 @@ export interface UnitagSearchResult extends SearchResultBase { unitag: string } -export interface TokenSearchResult extends SearchResultBase { - type: SearchResultType.Token - chainId: WalletChainId - symbol: string - address: Address | null - name: string | null - logoUrl: string | null - safetyLevel: SafetyLevel | null -} - export interface NFTCollectionSearchResult extends SearchResultBase { type: SearchResultType.NFTCollection chainId: WalletChainId diff --git a/packages/wallet/src/features/telemetry/hooks.ts b/packages/wallet/src/features/telemetry/hooks.ts index 8d0f12cd78a..d24157b6929 100644 --- a/packages/wallet/src/features/telemetry/hooks.ts +++ b/packages/wallet/src/features/telemetry/hooks.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { MobileAppsFlyerEvents, MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent, sendAppsFlyerEvent } from 'uniswap/src/features/telemetry/send' import { logger } from 'utilities/src/logger/logger' @@ -22,15 +22,14 @@ import { } from 'wallet/src/features/telemetry/slice' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { useAccounts } from 'wallet/src/features/wallet/hooks' -import { useAppSelector } from 'wallet/src/state' export function useLastBalancesReporter(): void { const dispatch = useDispatch() const accounts = useAccounts() - const lastBalancesReport = useAppSelector(selectLastBalancesReport) - const lastBalancesReportValue = useAppSelector(selectLastBalancesReportValue) - const walletIsFunded = useAppSelector(selectWalletIsFunded) + const lastBalancesReport = useSelector(selectLastBalancesReport) + const lastBalancesReportValue = useSelector(selectLastBalancesReportValue) + const walletIsFunded = useSelector(selectWalletIsFunded) const signerAccountAddresses = useMemo(() => { return Object.values(accounts) @@ -89,8 +88,8 @@ export function useLastBalancesReporter(): void { // Only logs when the user has allowing product analytics off and a heartbeat has not been sent for the user's local day export function useHeartbeatReporter(): void { const dispatch = useDispatch() - const allowAnalytics = useAppSelector(selectAllowAnalytics) - const lastHeartbeat = useAppSelector(selectLastHeartbeat) + const allowAnalytics = useSelector(selectAllowAnalytics) + const lastHeartbeat = useSelector(selectLastHeartbeat) const nowDate = new Date(Date.now()) const lastHeartbeatDate = new Date(lastHeartbeat) diff --git a/packages/wallet/src/features/tokens/safetyHooks.ts b/packages/wallet/src/features/tokens/safetyHooks.ts index 8c34dcaa8ba..c5eb99cfc95 100644 --- a/packages/wallet/src/features/tokens/safetyHooks.ts +++ b/packages/wallet/src/features/tokens/safetyHooks.ts @@ -1,16 +1,15 @@ import { useCallback } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { CurrencyId } from 'uniswap/src/types/currency' import { dismissedWarningTokensSelector } from 'wallet/src/features/tokens/dismissedWarningTokensSelector' import { addDismissedWarningToken } from 'wallet/src/features/tokens/tokensSlice' -import { useAppSelector } from 'wallet/src/state' export function useTokenWarningDismissed(currencyId: Maybe<CurrencyId>): { tokenWarningDismissed: boolean // user dismissed warning dismissWarningCallback: () => void // callback to dismiss warning } { const dispatch = useDispatch() - const dismissedTokens = useAppSelector(dismissedWarningTokensSelector) + const dismissedTokens = useSelector(dismissedWarningTokensSelector) const tokenWarningDismissed = Boolean(currencyId && dismissedTokens && dismissedTokens[currencyId]) diff --git a/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/BuyNativeTokenButton.tsx b/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/BuyNativeTokenButton.tsx new file mode 100644 index 00000000000..a1f380464d9 --- /dev/null +++ b/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/BuyNativeTokenButton.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next' +import { Button, Flex, Text, isWeb } from 'ui/src' +import { opacify, validColor } from 'ui/src/theme' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { useNetworkColors } from 'uniswap/src/utils/colors' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { useIsSupportedFiatOnRampCurrency } from 'wallet/src/features/fiatOnRamp/hooks' + +export function BuyNativeTokenButton({ nativeCurrencyInfo }: { nativeCurrencyInfo: CurrencyInfo }): JSX.Element { + const { t } = useTranslation() + const { foreground, background } = useNetworkColors(nativeCurrencyInfo.currency?.chainId ?? UniverseChainId.Mainnet) + const primaryColor = validColor(foreground) + const backgroundColor = validColor(background) + const onPressColor = validColor(opacify(50, foreground)) + + const { navigateToFiatOnRamp } = useWalletNavigation() + const fiatOnRampCurrency = useIsSupportedFiatOnRampCurrency(nativeCurrencyInfo?.currencyId ?? '', !nativeCurrencyInfo) + + const onPressBuyFiatOnRamp = (): void => { + navigateToFiatOnRamp({ prefilledCurrency: fiatOnRampCurrency }) + } + + return ( + <Trace logPress element={ElementName.BuyNativeTokenButton}> + {isWeb ? ( + <Flex + backgroundColor={backgroundColor} + borderRadius="$rounded12" + cursor="pointer" + hoverStyle={{ backgroundColor: onPressColor }} + px="$spacing16" + py="$spacing8" + onPress={onPressBuyFiatOnRamp} + > + <Text color={primaryColor} variant="buttonLabel4"> + {t('swap.warning.insufficientGas.button.buy', { tokenSymbol: nativeCurrencyInfo.currency.symbol })} + </Text> + </Flex> + ) : ( + <Button + backgroundColor={backgroundColor} + color={primaryColor} + pressStyle={{ backgroundColor: onPressColor }} + size="medium" + theme="primary" + width="100%" + onPress={onPressBuyFiatOnRamp} + > + {t('swap.warning.insufficientGas.button.buy', { tokenSymbol: nativeCurrencyInfo.currency.symbol })} + </Button> + )} + </Trace> + ) +} diff --git a/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.native.tsx b/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.native.tsx index 7beef57a349..2a47e23733f 100644 --- a/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.native.tsx +++ b/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.native.tsx @@ -7,6 +7,7 @@ import { uniswapUrls } from 'uniswap/src/constants/urls' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId } from 'uniswap/src/types/chains' import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { BuyNativeTokenButton } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/BuyNativeTokenButton' import { InsufficientNativeTokenBaseComponent } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenBaseComponent' import type { InsufficientNativeTokenWarningProps } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' import { useInsufficientNativeTokenWarning } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/useInsufficientNativeTokenWarning' @@ -25,13 +26,13 @@ export function InsufficientNativeTokenWarning({ gasFee, }) - if (!parsedInsufficentNativeTokenWarning) { + const { modalOrTooltipMainMessage, nativeCurrency, nativeCurrencyInfo, networkName } = + parsedInsufficentNativeTokenWarning ?? {} + + if (!parsedInsufficentNativeTokenWarning || !nativeCurrencyInfo || !nativeCurrency) { return null } - const { modalOrTooltipMainMessage, nativeCurrency, nativeCurrencyInfo, networkName } = - parsedInsufficentNativeTokenWarning - const shouldShowNetworkName = nativeCurrency.symbol === 'ETH' && nativeCurrency.chainId !== UniverseChainId.Mainnet return ( @@ -59,12 +60,17 @@ export function InsufficientNativeTokenWarning({ } onClose={(): void => setShowModal(false)} > - <Flex centered gap="$spacing16"> + <Flex centered gap="$spacing16" width="100%"> <Text color="$neutral2" textAlign="center" variant="body3"> {modalOrTooltipMainMessage} </Text> - <LearnMoreLink url={uniswapUrls.helpArticleUrls.networkFeeInfo} /> + <BuyNativeTokenButton nativeCurrencyInfo={nativeCurrencyInfo} /> + <LearnMoreLink + textColor="$neutral2" + textVariant="buttonLabel3" + url={uniswapUrls.helpArticleUrls.networkFeeInfo} + /> </Flex> </WarningModal> )} diff --git a/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.web.tsx b/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.web.tsx index eda448d120c..3844057ea45 100644 --- a/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.web.tsx +++ b/packages/wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning.web.tsx @@ -1,6 +1,7 @@ import { Flex, Text, Tooltip } from 'ui/src' import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { BuyNativeTokenButton } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/BuyNativeTokenButton' import { InsufficientNativeTokenBaseComponent } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenBaseComponent' import type { InsufficientNativeTokenWarningProps } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' import { useInsufficientNativeTokenWarning } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/useInsufficientNativeTokenWarning' @@ -16,12 +17,12 @@ export function InsufficientNativeTokenWarning({ gasFee, }) - if (!parsedInsufficentNativeTokenWarning) { + const { modalOrTooltipMainMessage, nativeCurrencyInfo } = parsedInsufficentNativeTokenWarning ?? {} + + if (!parsedInsufficentNativeTokenWarning || !nativeCurrencyInfo) { return null } - const { modalOrTooltipMainMessage } = parsedInsufficentNativeTokenWarning - return ( <Tooltip delay={100} placement="bottom-end"> <Tooltip.Trigger cursor="default"> @@ -30,13 +31,16 @@ export function InsufficientNativeTokenWarning({ /> </Tooltip.Trigger> - <Tooltip.Content maxWidth={250} px="$spacing16" py="$spacing12"> - <Flex gap="$spacing8"> + <Tooltip.Content maxWidth={300} px="$spacing16" py="$spacing12"> + <Flex row alignItems="center" gap="$spacing12" justifyContent="space-between"> <Text color="$neutral2" variant="body4"> {modalOrTooltipMainMessage} </Text> - <LearnMoreLink textVariant="body4" url={uniswapUrls.helpArticleUrls.networkFeeInfo} /> + <Flex centered gap="$spacing8"> + <BuyNativeTokenButton nativeCurrencyInfo={nativeCurrencyInfo} /> + <LearnMoreLink textVariant="buttonLabel4" url={uniswapUrls.helpArticleUrls.networkFeeInfo} /> + </Flex> </Flex> <Tooltip.Arrow /> diff --git a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsInfoRows.tsx b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsInfoRows.tsx index 0572c2458fa..8f48d0b5eb8 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsInfoRows.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsInfoRows.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { PropsWithChildren } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' @@ -18,22 +19,36 @@ import { UniverseChainId } from 'uniswap/src/types/chains' import { setClipboard } from 'uniswap/src/utils/clipboard' import { openUri } from 'uniswap/src/utils/linking' import { shortenAddress } from 'utilities/src/addresses' +import { NumberType } from 'utilities/src/format/types' import { useENS } from 'wallet/src/features/ens/useENS' +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 { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' import { useNetworkFee } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/hooks' -import { shortenHash } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/utils' +import { SwapTypeTransactionInfo } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/types' +import { + getFormattedSwapRatio, + hasInterfaceFees, + shortenHash, +} from 'wallet/src/features/transactions/SummaryCards/DetailsModal/utils' import { ContentRow } from 'wallet/src/features/transactions/TransactionRequest/ContentRow' +import { getAmountsFromTrade } from 'wallet/src/features/transactions/getAmountsFromTrade' import { isUniswapX } from 'wallet/src/features/transactions/swap/trade/utils' import { TransactionDetails, TransactionType } from 'wallet/src/features/transactions/types' +import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' import { ExplorerDataType, getExplorerLink } from 'wallet/src/utils/linking' +const UNISWAP_FEE = 0.0025 + export function TransactionDetailsInfoRows({ transactionDetails, + isShowingMore, }: { transactionDetails: TransactionDetails + isShowingMore: boolean }): JSX.Element { - const rows = useTransactionDetailsInfoRows(transactionDetails) + const rows = useTransactionDetailsInfoRows(transactionDetails, isShowingMore) return ( <Flex gap="$spacing8" px="$spacing8"> @@ -42,7 +57,10 @@ export function TransactionDetailsInfoRows({ ) } -export function useTransactionDetailsInfoRows(transactionDetails: TransactionDetails): JSX.Element[] { +export function useTransactionDetailsInfoRows( + transactionDetails: TransactionDetails, + isShowingMore: boolean, +): JSX.Element[] { const { t } = useTranslation() const isDarkMode = useIsDarkMode() @@ -93,6 +111,18 @@ export function useTransactionDetailsInfoRows(transactionDetails: TransactionDet ) break case TransactionType.Swap: + if (isShowingMore) { + specificRows.push(<SwapRateRow key="swapRate" typeInfo={typeInfo} />) + // TODO (WALL-4189): blocked on backend. This is hard-coded to always return false for now + if ( + hasInterfaceFees({ + swapTimestampMs: transactionDetails.addedTime, + }) + ) { + specificRows.push(<UniswapFeeRow key="uniswapFee" typeInfo={typeInfo} />) + } + } + break case TransactionType.Wrap: case TransactionType.FiatPurchase: case TransactionType.NFTTrade: @@ -231,3 +261,61 @@ function TransactionParticipantRow({ address, isSend = false }: { address: strin </InfoRow> ) } + +function SwapRateRow({ typeInfo }: { typeInfo: SwapTypeTransactionInfo }): JSX.Element { + const { t } = useTranslation() + const formatter = useLocalizationContext() + + const inputCurrency = useCurrencyInfo(typeInfo.inputCurrencyId) + const outputCurrency = useCurrencyInfo(typeInfo.outputCurrencyId) + + const formattedLine = + inputCurrency && outputCurrency + ? getFormattedSwapRatio({ + typeInfo, + inputCurrency, + outputCurrency, + formatter, + }) + : '-' + + return ( + <InfoRow label={t('transaction.details.swapRate')}> + <Text variant="body3">{formattedLine}</Text> + </InfoRow> + ) +} + +function UniswapFeeRow({ typeInfo }: { typeInfo: SwapTypeTransactionInfo }): JSX.Element { + const { t } = useTranslation() + const formatter = useLocalizationContext() + + const outputCurrency = useCurrencyInfo(typeInfo.outputCurrencyId) + const { outputCurrencyAmountRaw } = getAmountsFromTrade(typeInfo) + + const currencyAmount = getCurrencyAmount({ + value: outputCurrencyAmountRaw, + valueType: ValueType.Raw, + currency: outputCurrency?.currency, + }) + + const amountExact = currencyAmount ? parseFloat(currencyAmount.toExact()) : null + + // Using the equation (1 - 0.25 / 100) * (actualOutputValue + uniswapFee) = actualOutputValue + const approximateFee = amountExact ? (UNISWAP_FEE / (1 - UNISWAP_FEE)) * amountExact : null + const feeSymbol = outputCurrency?.currency.symbol ? ' ' + outputCurrency.currency.symbol : '' + const formattedApproximateFee = approximateFee + ? '~' + + formatter.formatNumberOrString({ + value: approximateFee, + type: NumberType.TokenTx, + }) + + feeSymbol + : '-' + + return ( + <InfoRow label={t('transaction.details.uniswapFee', { feePercent: UNISWAP_FEE * 100 })}> + <Text variant="body3">{formattedApproximateFee}</Text> + </InfoRow> + ) +} diff --git a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.test.tsx b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.test.tsx index 60fc542241b..94bf9bf5292 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.test.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.test.tsx @@ -111,8 +111,16 @@ describe('TransactionDetails Components', () => { expect(tree).toMatchSnapshot() }) - it('renders TransactionDetailsInfoRows without error', () => { - const tree = render(<TransactionDetailsInfoRows transactionDetails={mockTransaction} />, { + it('renders TransactionDetailsInfoRows without error with isShowingMore false', () => { + const tree = render(<TransactionDetailsInfoRows isShowingMore={false} transactionDetails={mockTransaction} />, { + preloadedState, + }) + + expect(tree).toMatchSnapshot() + }) + + it('renders TransactionDetailsInfoRows without error with isShowingMore true', () => { + const tree = render(<TransactionDetailsInfoRows isShowingMore={true} transactionDetails={mockTransaction} />, { preloadedState, }) diff --git a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.tsx b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.tsx index c38cadab6f1..0a35dcc2231 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal.tsx @@ -1,11 +1,12 @@ import dayjs from 'dayjs' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Button, ContextMenu, Flex, Separator, Text, TouchableArea, isWeb } from 'ui/src' -import { TripleDots, UniswapX } from 'ui/src/components/icons' +import { AnglesDownUp, SortVertical, TripleDots, UniswapX } from 'ui/src/components/icons' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { AssetType } from 'uniswap/src/entities/assets' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { AuthTrigger } from 'wallet/src/features/auth/types' import { FORMAT_DATE_TIME_MEDIUM, useFormattedDateTime } from 'wallet/src/features/language/localizedDayjs' import { ApproveTransactionDetails } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/ApproveTransactionDetails' @@ -33,7 +34,7 @@ import { } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/types' import { useTransactionActions } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/useTransactionActions' import { getTransactionSummaryTitle } from 'wallet/src/features/transactions/SummaryCards/utils' -import { TransactionDetails, TransactionTypeInfo } from 'wallet/src/features/transactions/types' +import { TransactionDetails, TransactionType, TransactionTypeInfo } from 'wallet/src/features/transactions/types' import { getIsCancelable } from 'wallet/src/features/transactions/utils' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' @@ -151,6 +152,8 @@ export function TransactionDetailsModal({ }: TransactionDetailsModalProps): JSX.Element { const { t } = useTranslation() const { typeInfo } = transactionDetails + const [isShowingMore, setIsShowingMore] = useState(false) + const hasMoreInfoRows = transactionDetails.typeInfo.type === TransactionType.Swap // Hide both separators if it's an Nft transaction. Hide top separator if it's an unknown type transaction. const isNftTransaction = isNFTActivity(typeInfo) @@ -198,8 +201,11 @@ export function TransactionDetailsModal({ <TransactionDetailsHeader transactionActions={transactionActions} transactionDetails={transactionDetails} /> {!hideTopSeparator && <Separator />} <TransactionDetailsContent transactionDetails={transactionDetails} onClose={onClose} /> - {!hideBottomSeparator && <Separator />} - <TransactionDetailsInfoRows transactionDetails={transactionDetails} /> + {!hideBottomSeparator && hasMoreInfoRows && ( + <ShowMoreSeparator isShowingMore={isShowingMore} setIsShowingMore={setIsShowingMore} /> + )} + {!hideBottomSeparator && !hasMoreInfoRows && <Separator />} + <TransactionDetailsInfoRows isShowingMore={isShowingMore} transactionDetails={transactionDetails} /> {buttons.length > 0 && ( <Flex gap="$spacing8" pt="$spacing8"> {buttons} @@ -211,3 +217,36 @@ export function TransactionDetailsModal({ </> ) } + +function ShowMoreSeparator({ + isShowingMore, + setIsShowingMore, +}: { + isShowingMore: boolean + setIsShowingMore: (showMore: boolean) => void +}): JSX.Element { + const { t } = useTranslation() + + const onPressShowMore = (): void => { + setIsShowingMore(!isShowingMore) + } + + return ( + <Flex centered row gap="$spacing16"> + <Separator /> + <TouchableArea onPress={onPressShowMore}> + <Flex centered row gap="$spacing4"> + <Text color="$neutral3" variant="body4"> + {isShowingMore ? t('common.button.showLess') : t('common.button.showMore')} + </Text> + {isShowingMore ? ( + <AnglesDownUp color="$neutral3" size="$icon.16" /> + ) : ( + <SortVertical color="$neutral3" size="$icon.16" /> + )} + </Flex> + </TouchableArea> + <Separator /> + </Flex> + ) +} diff --git a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/__snapshots__/TransactionDetailsModal.test.tsx.snap b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/__snapshots__/TransactionDetailsModal.test.tsx.snap index 6aa0acee7f8..20a325d0ac2 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/__snapshots__/TransactionDetailsModal.test.tsx.snap +++ b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/__snapshots__/TransactionDetailsModal.test.tsx.snap @@ -398,7 +398,244 @@ exports[`TransactionDetails Components renders TransactionDetailsHeader without </View> `; -exports[`TransactionDetails Components renders TransactionDetailsInfoRows without error 1`] = ` +exports[`TransactionDetails Components renders TransactionDetailsInfoRows without error with isShowingMore false 1`] = ` +<View + style={ + { + "flexDirection": "column", + "gap": 8, + "paddingLeft": 8, + "paddingRight": 8, + } + } +> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + allowFontScaling={false} + maxFontSizeMultiplier={1.4} + style={ + { + "color": "#7D7D7D", + "fontFamily": "Basel-Book", + "fontSize": 14, + "lineHeight": 20, + } + } + suppressHighlighting={true} + > + Network cost + </Text> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + } + } + > + <View + style={ + { + "borderColor": "#FFFFFF", + "borderRadius": 6, + "flexDirection": "column", + "overflow": "hidden", + } + } + testID="network-logo" + > + <div + className="css-view-175oi2r r-flexBasis-1mlwlqe r-overflow-1udh08x r-zIndex-417010" + dir={null} + style={ + { + "height": "16px", + "width": "16px", + } + } + > + <div + className="css-view-175oi2r r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw r-backgroundSize-ehq7j7" + dir={null} + suppressHydrationWarning={true} + /> + </div> + </View> + <Text + allowFontScaling={false} + maxFontSizeMultiplier={1.4} + style={ + { + "color": "#222222", + "fontFamily": "Basel-Book", + "fontSize": 14, + "lineHeight": 20, + } + } + suppressHighlighting={true} + > + - + </Text> + </View> + </View> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 8, + "justifyContent": "space-between", + } + } + > + <Text + allowFontScaling={false} + maxFontSizeMultiplier={1.4} + style={ + { + "color": "#7D7D7D", + "fontFamily": "Basel-Book", + "fontSize": 14, + "lineHeight": 20, + } + } + suppressHighlighting={true} + > + Transaction ID + </Text> + <View + style={ + { + "alignItems": "center", + "flexDirection": "row", + "gap": 4, + "justifyContent": "center", + } + } + > + <Text + allowFontScaling={false} + maxFontSizeMultiplier={1.4} + style={ + { + "color": "#222222", + "fontFamily": "Basel-Book", + "fontSize": 14, + "lineHeight": 20, + } + } + suppressHighlighting={true} + > + b568a9...0600 + </Text> + <View + cancelable={true} + disabled={false} + focusable={true} + hitSlop={[Function]} + minPressDuration={0} + onBlur={[Function]} + onFocus={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} + onPress={[Function]} + onPressIn={[Function]} + onPressOut={[Function]} + style={ + { + "flexDirection": "column", + "opacity": 1, + "transform": [ + { + "scale": 1, + }, + ], + } + } + > + <RNSVGSvgView + align="xMidYMid" + bbHeight="16" + bbWidth="16" + fill="none" + focusable={false} + meetOrSlice={0} + minX={0} + minY={0} + strokeWidth={8} + style={ + [ + { + "backgroundColor": "transparent", + "borderWidth": 0, + }, + { + "color": "#CECECE", + "height": 16, + "width": 16, + }, + { + "flex": 0, + "height": 16, + "width": 16, + }, + ] + } + tintColor="#CECECE" + vbHeight={16} + vbWidth={16} + > + <RNSVGGroup + fill={null} + propList={ + [ + "fill", + "strokeWidth", + ] + } + strokeWidth="8" + > + <RNSVGPath + d="M12.25 4.16667H6.41667C4.96533 4.16667 4.16667 4.96533 4.16667 6.41667V12.25C4.16667 13.7013 4.96533 14.5 6.41667 14.5H12.25C13.7013 14.5 14.5 13.7013 14.5 12.25V6.41667C14.5 4.96533 13.7013 4.16667 12.25 4.16667ZM13.5 12.25C13.5 13.138 13.138 13.5 12.25 13.5H6.41667C5.52867 13.5 5.16667 13.138 5.16667 12.25V6.41667C5.16667 5.52867 5.52867 5.16667 6.41667 5.16667H12.25C13.138 5.16667 13.5 5.52867 13.5 6.41667V12.25ZM2.5 3.74666V9.58667C2.5 10.3853 2.82206 10.582 2.92806 10.6473C3.16406 10.7913 3.23726 11.0993 3.09326 11.3347C2.9986 11.4887 2.83468 11.5733 2.66602 11.5733C2.57735 11.5733 2.48661 11.5493 2.40527 11.5C1.80461 11.132 1.5 10.4887 1.5 9.58667V3.74666C1.5 2.31866 2.31941 1.5 3.74674 1.5H9.58659C10.7099 1.5 11.2467 1.99265 11.5 2.40531C11.644 2.64065 11.57 2.94865 11.3346 3.09265C11.0986 3.23732 10.792 3.16266 10.6473 2.92733C10.5826 2.82133 10.3853 2.49935 9.58659 2.49935H3.74674C2.86141 2.50002 2.5 2.86133 2.5 3.74666Z" + fill={ + { + "type": 2, + } + } + propList={ + [ + "fill", + "stroke", + "strokeWidth", + ] + } + stroke={ + { + "type": 2, + } + } + strokeWidth="0.5" + /> + </RNSVGGroup> + </RNSVGSvgView> + </View> + </View> + </View> +</View> +`; + +exports[`TransactionDetails Components renders TransactionDetailsInfoRows without error with isShowingMore true 1`] = ` <View style={ { diff --git a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/useTransactionActions.tsx b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/useTransactionActions.tsx index c12ebb73654..e378abcb965 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/useTransactionActions.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/useTransactionActions.tsx @@ -34,7 +34,7 @@ import { import { getIsCancelable } from 'wallet/src/features/transactions/utils' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' -import { openMoonpayTransactionLink, openTransactionLink } from 'wallet/src/utils/linking' +import { openTransactionLink } from 'wallet/src/utils/linking' export const useTransactionActions = ({ authTrigger, @@ -93,14 +93,6 @@ export const useTransactionActions = ({ return openTransactionLink(hash, chainId) }, [chainId, hash]) - const handleViewMoonpay = (): Promise<void> | undefined => { - if (transaction.typeInfo.type === TransactionType.FiatPurchase) { - setShowActionsModal(false) - return openMoonpayTransactionLink(transaction.typeInfo) - } - return undefined - } - const handleViewTokenDetails = useCallback( (currencyId: CurrencyId): void | undefined => { if (typeInfo.type === TransactionType.Swap) { @@ -151,9 +143,6 @@ export const useTransactionActions = ({ }} onClose={handleActionsModalClose} onExplore={handleExplore} - onViewMoonpay={ - typeInfo.type === TransactionType.FiatPurchase && typeInfo.explorerUrl ? handleViewMoonpay : undefined - } onViewTokenDetails={typeInfo.type === TransactionType.Swap ? handleViewTokenDetails : undefined} /> )} diff --git a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/utils.ts b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/utils.ts index 8152df36e11..0adac2f81ee 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/utils.ts +++ b/packages/wallet/src/features/transactions/SummaryCards/DetailsModal/utils.ts @@ -1,9 +1,14 @@ import { Currency } from '@uniswap/sdk-core' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { NumberType } from 'utilities/src/format/types' import { LocalizationContextState } from 'wallet/src/features/language/LocalizationContext' +import { SwapTypeTransactionInfo } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/types' +import { getAmountsFromTrade } from 'wallet/src/features/transactions/getAmountsFromTrade' import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' +const INTERFACE_FEE_SWITCH_TIMESTAMP = 1712772000000 // April 10th 2024 2pm EST + export function useFormattedCurrencyAmountAndUSDValue({ currency, currencyAmountRaw, @@ -49,3 +54,55 @@ export function shortenHash(hash: string | undefined, chars: NumberRange<1, 20> } return `${hash.substring(0, chars + 2)}...${hash.substring(hash.length - chars)}` } + +export function getFormattedSwapRatio({ + typeInfo, + inputCurrency, + outputCurrency, + formatter, +}: { + typeInfo: SwapTypeTransactionInfo + inputCurrency: CurrencyInfo + outputCurrency: CurrencyInfo + formatter: LocalizationContextState +}): string { + const { inputCurrencyAmountRaw, outputCurrencyAmountRaw } = getAmountsFromTrade(typeInfo) + + const inputCurrencyAmount = getCurrencyAmount({ + value: inputCurrencyAmountRaw, + valueType: ValueType.Raw, + currency: inputCurrency.currency, + }) + + const outputCurrencyAmount = getCurrencyAmount({ + value: outputCurrencyAmountRaw, + valueType: ValueType.Raw, + currency: outputCurrency.currency, + }) + + const inputExactAmount = inputCurrencyAmount ? parseFloat(inputCurrencyAmount.toExact()) : 0 + const outputExactAmount = outputCurrencyAmount ? parseFloat(outputCurrencyAmount.toExact()) : 0 + + const outputMoreValuable = inputExactAmount > outputExactAmount // If there are more input tokens per output token, then output token is worth more + const swapRate = outputMoreValuable ? inputExactAmount / outputExactAmount : outputExactAmount / inputExactAmount + const higherValueSymbol = outputMoreValuable ? outputCurrency.currency.symbol : inputCurrency.currency.symbol + const lowerValueSymbol = outputMoreValuable ? inputCurrency.currency.symbol : outputCurrency.currency.symbol + + const formattedSwapRate = formatter.formatNumberOrString({ + value: swapRate, + type: NumberType.TokenTx, + }) + const formattedLine = '1 ' + higherValueSymbol + ' = ' + formattedSwapRate + ' ' + lowerValueSymbol + + return formattedLine +} + +export function hasInterfaceFees({ swapTimestampMs }: { swapTimestampMs: number }): boolean { + const beforeInterfaceFeeSwitch = swapTimestampMs < INTERFACE_FEE_SWITCH_TIMESTAMP + if (beforeInterfaceFeeSwitch) { + return false + } + + // TODO (WALL-4189): blocked on backend, decided to not show fees for now so hard-codeed to always return false + return false +} diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.tsx index 63b04ad29e0..9ef38bba9d2 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ApproveSummaryItem.tsx @@ -1,4 +1,3 @@ -import { createElement } from 'react' import { useTranslation } from 'react-i18next' import { AssetType } from 'uniswap/src/entities/assets' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' @@ -7,7 +6,8 @@ import { NumberType } from 'utilities/src/format/types' import { LogoWithTxStatus } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { ApproveTransactionInfo, TransactionDetails, TransactionType } from 'wallet/src/features/transactions/types' @@ -16,7 +16,6 @@ const ZERO_AMOUNT = '0.0' export function ApproveSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: ApproveTransactionInfo } @@ -36,19 +35,21 @@ export function ApproveSummaryItem({ const caption = `${amount ? amount + ' ' : ''}${getSymbolDisplayText(currencyInfo?.currency.symbol) ?? ''}` - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon: ( - <LogoWithTxStatus - assetType={AssetType.Currency} - chainId={transaction.chainId} - currencyInfo={currencyInfo} - size={TXN_HISTORY_ICON_SIZE} - txStatus={transaction.status} - txType={TransactionType.Approve} - /> - ), - transaction, - index, - }) + return ( + <TransactionSummaryLayout + caption={caption} + icon={ + <LogoWithTxStatus + assetType={AssetType.Currency} + chainId={transaction.chainId} + currencyInfo={currencyInfo} + size={TXN_HISTORY_ICON_SIZE} + txStatus={transaction.status} + txType={TransactionType.Approve} + /> + } + index={index} + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.tsx index f623ecb1e07..a3a42f6f555 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/FiatPurchaseSummaryItem.tsx @@ -1,4 +1,3 @@ -import React, { createElement } from 'react' import { useTranslation } from 'react-i18next' import { useIsDarkMode } from 'ui/src' import { AssetType } from 'uniswap/src/entities/assets' @@ -9,13 +8,13 @@ import { NumberType } from 'utilities/src/format/types' import { LogoWithTxStatus } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { FiatPurchaseTransactionInfo, TransactionDetails } from 'wallet/src/features/transactions/types' export function FiatPurchaseSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: FiatPurchaseTransactionInfo } @@ -69,21 +68,23 @@ export function FiatPurchaseSummaryItem({ }) : formatFiatTokenPrice() - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon: ( - <LogoWithTxStatus - assetType={AssetType.Currency} - chainId={transaction.chainId} - currencyInfo={outputCurrencyInfo} - institutionLogoUrl={institutionLogoUrl} - serviceProviderLogoUrl={serviceProviderLogoUrl} - size={TXN_HISTORY_ICON_SIZE} - txStatus={transaction.status} - txType={transaction.typeInfo.type} - /> - ), - transaction, - index, - }) + return ( + <TransactionSummaryLayout + caption={caption} + icon={ + <LogoWithTxStatus + assetType={AssetType.Currency} + chainId={transaction.chainId} + currencyInfo={outputCurrencyInfo} + institutionLogoUrl={institutionLogoUrl} + serviceProviderLogoUrl={serviceProviderLogoUrl} + size={TXN_HISTORY_ICON_SIZE} + txStatus={transaction.status} + txType={transaction.typeInfo.type} + /> + } + index={index} + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.tsx index 259eeed0e31..01b6b553a04 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTApproveSummaryItem.tsx @@ -4,17 +4,9 @@ import { NFTApproveTransactionInfo, TransactionDetails, TransactionType } from ' export function NFTApproveSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: NFTApproveTransactionInfo } }): JSX.Element { - return ( - <NFTSummaryItem - index={index} - layoutElement={layoutElement} - transaction={transaction} - transactionType={TransactionType.NFTApprove} - /> - ) + return <NFTSummaryItem index={index} transaction={transaction} transactionType={TransactionType.NFTApprove} /> } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.tsx index 3528e35a2ca..0df9b18c20e 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTMintSummaryItem.tsx @@ -4,17 +4,9 @@ import { NFTMintTransactionInfo, TransactionDetails, TransactionType } from 'wal export function NFTMintSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: NFTMintTransactionInfo } }): JSX.Element { - return ( - <NFTSummaryItem - index={index} - layoutElement={layoutElement} - transaction={transaction} - transactionType={TransactionType.NFTMint} - /> - ) + return <NFTSummaryItem index={index} transaction={transaction} transactionType={TransactionType.NFTMint} /> } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTSummaryItem.tsx index a96d7d79ba0..33d9359a123 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTSummaryItem.tsx @@ -1,7 +1,7 @@ -import { createElement } from 'react' import { AssetType } from 'uniswap/src/entities/assets' import { LogoWithTxStatus } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { NFTApproveTransactionInfo, @@ -14,7 +14,6 @@ import { export function NFTSummaryItem({ transaction, transactionType, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { @@ -22,19 +21,21 @@ export function NFTSummaryItem({ } transactionType: TransactionType }): JSX.Element { - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption: transaction.typeInfo.nftSummaryInfo.name, - icon: ( - <LogoWithTxStatus - assetType={AssetType.ERC721} - chainId={transaction.chainId} - nftImageUrl={transaction.typeInfo.nftSummaryInfo.imageURL} - size={TXN_HISTORY_ICON_SIZE} - txStatus={transaction.status} - txType={transactionType} - /> - ), - transaction, - index, - }) + return ( + <TransactionSummaryLayout + caption={transaction.typeInfo.nftSummaryInfo.name} + icon={ + <LogoWithTxStatus + assetType={AssetType.ERC721} + chainId={transaction.chainId} + nftImageUrl={transaction.typeInfo.nftSummaryInfo.imageURL} + size={TXN_HISTORY_ICON_SIZE} + txStatus={transaction.status} + txType={transactionType} + /> + } + index={index} + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.tsx index 3e7cb4068d1..2a3f31d20eb 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/NFTTradeSummaryItem.tsx @@ -4,17 +4,9 @@ import { NFTTradeTransactionInfo, TransactionDetails } from 'wallet/src/features export function NFTTradeSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: NFTTradeTransactionInfo } }): JSX.Element { - return ( - <NFTSummaryItem - index={index} - layoutElement={layoutElement} - transaction={transaction} - transactionType={transaction.typeInfo.type} - /> - ) + return <NFTSummaryItem index={index} transaction={transaction} transactionType={transaction.typeInfo.type} /> } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/OnRampTransferSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/OnRampTransferSummaryItem.tsx index 8ad80c9408a..0d1163866e8 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/OnRampTransferSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/OnRampTransferSummaryItem.tsx @@ -1,4 +1,3 @@ -import React, { createElement } from 'react' import { useTranslation } from 'react-i18next' import { AssetType } from 'uniswap/src/entities/assets' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' @@ -6,7 +5,8 @@ import { NumberType } from 'utilities/src/format/types' import { LogoWithTxStatus } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { OnRampPurchaseInfo, @@ -17,7 +17,6 @@ import { export function OnRampTransferSummaryItem({ transaction, - layoutElement, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: OnRampPurchaseInfo | OnRampTransferInfo } }): JSX.Element { @@ -47,18 +46,20 @@ export function OnRampTransferSummaryItem({ }) : cryptoPurchaseAmount - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon: ( - <LogoWithTxStatus - assetType={AssetType.Currency} - chainId={transaction.chainId} - currencyInfo={outputCurrencyInfo} - size={TXN_HISTORY_ICON_SIZE} - txStatus={transaction.status} - txType={transaction.typeInfo.type} - /> - ), - transaction, - }) + return ( + <TransactionSummaryLayout + caption={caption} + icon={ + <LogoWithTxStatus + assetType={AssetType.Currency} + chainId={transaction.chainId} + currencyInfo={outputCurrencyInfo} + size={TXN_HISTORY_ICON_SIZE} + txStatus={transaction.status} + txType={transaction.typeInfo.type} + /> + } + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.tsx index 1a0520597b4..f31de18d69a 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.tsx @@ -8,7 +8,6 @@ import { export function ReceiveSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: ReceiveTokenTransactionInfo } @@ -16,7 +15,6 @@ export function ReceiveSummaryItem({ return ( <TransferTokenSummaryItem index={index} - layoutElement={layoutElement} otherAddress={transaction.typeInfo.sender} transaction={transaction} transactionType={TransactionType.Receive} diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.tsx index acda93df460..3d4e0f5061d 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.tsx @@ -4,7 +4,6 @@ import { SendTokenTransactionInfo, TransactionDetails, TransactionType } from 'w export function SendSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: SendTokenTransactionInfo } @@ -12,7 +11,6 @@ export function SendSummaryItem({ return ( <TransferTokenSummaryItem index={index} - layoutElement={layoutElement} otherAddress={transaction.typeInfo.recipient} transaction={transaction} transactionType={TransactionType.Send} diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.tsx index 3e8bd0642be..986854bf96e 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/SwapSummaryItem.tsx @@ -1,11 +1,12 @@ import { TradeType } from '@uniswap/sdk-core' -import { createElement, useMemo } from 'react' +import { useMemo } from 'react' import { SplitLogo } from 'uniswap/src/components/CurrencyLogo/SplitLogo' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { ONE_MINUTE_MS } from 'utilities/src/time/time' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { getAmountsFromTrade } from 'wallet/src/features/transactions/getAmountsFromTrade' import { @@ -20,7 +21,6 @@ const MAX_SHOW_RETRY_TIME = 15 * ONE_MINUTE_MS export function SwapSummaryItem({ transaction, - layoutElement, swapCallbacks, index, }: SummaryItemProps & { @@ -75,18 +75,20 @@ export function SwapSummaryItem({ const onRetry = swapCallbacks?.onRetryGenerator?.(swapFormState) - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon: ( - <SplitLogo - chainId={transaction.chainId} - inputCurrencyInfo={inputCurrencyInfo} - outputCurrencyInfo={outputCurrencyInfo} - size={TXN_HISTORY_ICON_SIZE} - /> - ), - transaction, - onRetry: swapFormState && shouldShowRetry ? onRetry : undefined, - index, - }) + return ( + <TransactionSummaryLayout + caption={caption} + icon={ + <SplitLogo + chainId={transaction.chainId} + inputCurrencyInfo={inputCurrencyInfo} + outputCurrencyInfo={outputCurrencyInfo} + size={TXN_HISTORY_ICON_SIZE} + /> + } + index={index} + transaction={transaction} + onRetry={swapFormState && shouldShowRetry ? onRetry : undefined} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionActionsModal.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionActionsModal.tsx index 9fb1af300ab..20f4c5ba345 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionActionsModal.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionActionsModal.tsx @@ -35,7 +35,6 @@ function renderOptionItem(label: string, textColorOverride?: ColorTokens): () => interface TransactionActionModalProps { onExplore: () => void onViewTokenDetails?: (currencyId: CurrencyId) => void - onViewMoonpay?: () => void onClose: () => void onCancel: () => void msTimestampAdded: number @@ -50,7 +49,6 @@ export default function TransactionActionsModal({ onClose, onViewTokenDetails, onExplore, - onViewMoonpay, showCancelButton, transactionDetails, }: TransactionActionModalProps): JSX.Element { @@ -94,16 +92,6 @@ export default function TransactionActionsModal({ ] : [] - const maybeViewOnMoonpayOption = onViewMoonpay - ? [ - { - key: ElementName.MoonpayExplorerView, - onPress: onViewMoonpay, - render: renderOptionItem(t('transaction.action.viewMoonPay')), - }, - ] - : [] - const chainInfo = UNIVERSE_CHAIN_INFO[transactionDetails.chainId] const maybeViewOnEtherscanOption = transactionDetails.hash @@ -122,6 +110,12 @@ export default function TransactionActionsModal({ const transactionId = getTransactionId(transactionDetails) + const onRampProviderName = + transactionDetails.typeInfo.type === TransactionType.OnRampPurchase || + transactionDetails.typeInfo.type === TransactionType.OnRampTransfer + ? transactionDetails.typeInfo.serviceProvider?.name + : undefined + const maybeCopyTransactionIdOption = transactionId ? [ { @@ -136,8 +130,12 @@ export default function TransactionActionsModal({ ) handleClose() }, - render: onViewMoonpay - ? renderOptionItem(t('transaction.action.copyMoonPay')) + render: onRampProviderName + ? renderOptionItem( + t('transaction.action.copyProvider', { + providerName: onRampProviderName, + }), + ) : renderOptionItem(t('transaction.action.copy')), }, ] @@ -145,7 +143,6 @@ export default function TransactionActionsModal({ const transactionActionOptions: MenuItemProp[] = [ ...maybeViewSwapToken, - ...maybeViewOnMoonpayOption, ...maybeViewOnEtherscanOption, ...maybeCopyTransactionIdOption, { @@ -169,7 +166,6 @@ export default function TransactionActionsModal({ transactionDetails, inputCurrencyInfo, outputCurrencyInfo, - onViewMoonpay, onViewTokenDetails, t, onExplore, diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout.tsx index b3112c9012d..76d09bea593 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout.tsx @@ -1,12 +1,12 @@ import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Flex, SpinningLoader, Text, TouchableArea, isWeb, useSporeColors } from 'ui/src' +import { AnimatePresence, Flex, SpinningLoader, Text, TouchableArea, isWeb, useSporeColors } from 'ui/src' import SlashCircleIcon from 'ui/src/assets/icons/slash-circle.svg' import { AlertTriangle, UniswapX } from 'ui/src/components/icons' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { TransactionDetailsModal } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/TransactionDetailsModal' import { useTransactionActions } from 'wallet/src/features/transactions/SummaryCards/DetailsModal/useTransactionActions' import { TransactionSummaryTitle } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryTitle' @@ -158,13 +158,15 @@ function TransactionSummaryLayout({ )} </Flex> </TouchableArea> - {showDetailsModal && ( - <TransactionDetailsModal - authTrigger={authTrigger} - transactionDetails={transaction} - onClose={(): void => setShowDetailsModal(false)} - /> - )} + <AnimatePresence> + {showDetailsModal && ( + <TransactionDetailsModal + authTrigger={authTrigger} + transactionDetails={transaction} + onClose={(): void => setShowDetailsModal(false)} + /> + )} + </AnimatePresence> {renderModals()} </> ) diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransferTokenSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransferTokenSummaryItem.tsx index 30d46a52ab7..0bc5aa930fd 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransferTokenSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/TransferTokenSummaryItem.tsx @@ -1,4 +1,4 @@ -import { createElement, useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Unitag } from 'ui/src/components/icons' import { AssetType } from 'uniswap/src/entities/assets' @@ -11,7 +11,8 @@ import { LogoWithTxStatus } from 'wallet/src/components/CurrencyLogo/LogoWithTxS import { useENS } from 'wallet/src/features/ens/useENS' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { ReceiveTokenTransactionInfo, @@ -25,7 +26,6 @@ export function TransferTokenSummaryItem({ transactionType, otherAddress, transaction, - layoutElement, index, }: SummaryItemProps & { transactionType: TransactionType.Send | TransactionType.Receive @@ -104,11 +104,13 @@ export function TransferTokenSummaryItem({ }) } - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon, - transaction, - postCaptionElement: unitag?.username ? <Unitag size="$icon.24" /> : undefined, - index, - }) + return ( + <TransactionSummaryLayout + caption={caption} + icon={icon} + index={index} + postCaptionElement={unitag?.username ? <Unitag size="$icon.24" /> : undefined} + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.tsx index c5549127b06..dd9f347f437 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem.tsx @@ -1,15 +1,15 @@ -import { createElement, useMemo } from 'react' +import { useMemo } from 'react' import { useSporeColors } from 'ui/src' import { ContractInteraction } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { getValidAddress, shortenAddress } from 'uniswap/src/utils/addresses' import { DappLogoWithWCBadge } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TransactionDetails, UnknownTransactionInfo } from 'wallet/src/features/transactions/types' export function UnknownSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: UnknownTransactionInfo } @@ -22,20 +22,17 @@ export function UnknownSummaryItem({ : '' }, [transaction.typeInfo.tokenAddress]) - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon: transaction.typeInfo.dappInfo?.icon ? ( - <DappLogoWithWCBadge - hideWCBadge - chainId={transaction.chainId} - dappImageUrl={transaction.typeInfo.dappInfo.icon} - dappName={transaction.typeInfo.dappInfo.name ?? ''} - size={iconSizes.icon40} - /> - ) : ( - <ContractInteraction color="$neutral2" fill={colors.surface1.get()} size="$icon.40" /> - ), - transaction, - index, - }) + const icon = transaction.typeInfo.dappInfo?.icon ? ( + <DappLogoWithWCBadge + hideWCBadge + chainId={transaction.chainId} + dappImageUrl={transaction.typeInfo.dappInfo.icon} + dappName={transaction.typeInfo.dappInfo.name ?? ''} + size={iconSizes.icon40} + /> + ) : ( + <ContractInteraction color="$neutral2" fill={colors.surface1.get()} size="$icon.40" /> + ) + + return <TransactionSummaryLayout caption={caption} icon={icon} index={index} transaction={transaction} /> } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.tsx index 301ce9c23e0..ee865ce359a 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem.tsx @@ -1,27 +1,28 @@ -import { createElement } from 'react' import { DappLogoWithWCBadge } from 'wallet/src/components/CurrencyLogo/LogoWithTxStatus' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { TransactionDetails, WCConfirmInfo } from 'wallet/src/features/transactions/types' export function WCSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: WCConfirmInfo } }): JSX.Element { - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption: transaction.typeInfo.dapp.name, - icon: ( - <DappLogoWithWCBadge - chainId={transaction.chainId} - dappImageUrl={transaction.typeInfo.dapp.icon} - dappName={transaction.typeInfo.dapp.name} - size={TXN_HISTORY_ICON_SIZE} - /> - ), - transaction, - index, - }) + return ( + <TransactionSummaryLayout + caption={transaction.typeInfo.dapp.name} + icon={ + <DappLogoWithWCBadge + chainId={transaction.chainId} + dappImageUrl={transaction.typeInfo.dapp.icon} + dappName={transaction.typeInfo.dapp.name} + size={TXN_HISTORY_ICON_SIZE} + /> + } + index={index} + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.tsx b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.tsx index a292cea8034..32e13d139e9 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.tsx +++ b/packages/wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem.tsx @@ -1,15 +1,15 @@ -import { createElement, useMemo } from 'react' +import { useMemo } from 'react' import { SplitLogo } from 'uniswap/src/components/CurrencyLogo/SplitLogo' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useNativeCurrencyInfo, useWrappedNativeCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' -import { SummaryItemProps, TransactionSummaryLayoutProps } from 'wallet/src/features/transactions/SummaryCards/types' +import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' +import { SummaryItemProps } from 'wallet/src/features/transactions/SummaryCards/types' import { TXN_HISTORY_ICON_SIZE } from 'wallet/src/features/transactions/SummaryCards/utils' import { TransactionDetails, WrapTransactionInfo } from 'wallet/src/features/transactions/types' import { getFormattedCurrencyAmount } from 'wallet/src/utils/currency' export function WrapSummaryItem({ transaction, - layoutElement, index, }: SummaryItemProps & { transaction: TransactionDetails & { typeInfo: WrapTransactionInfo } @@ -39,17 +39,19 @@ export function WrapSummaryItem({ return `${currencyAmount}${inputCurrency.symbol} → ${otherCurrencyAmount}${outputCurrency.symbol}` }, [nativeCurrencyInfo, transaction.typeInfo.currencyAmountRaw, unwrapped, wrappedCurrencyInfo, formatter]) - return createElement(layoutElement as React.FunctionComponent<TransactionSummaryLayoutProps>, { - caption, - icon: ( - <SplitLogo - chainId={transaction.chainId} - inputCurrencyInfo={unwrapped ? wrappedCurrencyInfo : nativeCurrencyInfo} - outputCurrencyInfo={unwrapped ? nativeCurrencyInfo : wrappedCurrencyInfo} - size={TXN_HISTORY_ICON_SIZE} - /> - ), - transaction, - index, - }) + return ( + <TransactionSummaryLayout + caption={caption} + icon={ + <SplitLogo + chainId={transaction.chainId} + inputCurrencyInfo={unwrapped ? wrappedCurrencyInfo : nativeCurrencyInfo} + outputCurrencyInfo={unwrapped ? nativeCurrencyInfo : wrappedCurrencyInfo} + size={TXN_HISTORY_ICON_SIZE} + /> + } + index={index} + transaction={transaction} + /> + ) } diff --git a/packages/wallet/src/features/transactions/SummaryCards/types.ts b/packages/wallet/src/features/transactions/SummaryCards/types.ts index a98393ccd22..7c1e366382b 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/types.ts +++ b/packages/wallet/src/features/transactions/SummaryCards/types.ts @@ -17,7 +17,6 @@ export interface TransactionSummaryLayoutProps { export interface SummaryItemProps { authTrigger?: AuthTrigger transaction: TransactionDetails - layoutElement: React.FunctionComponent<TransactionSummaryLayoutProps> swapCallbacks?: SwapSummaryCallbacks index?: number } diff --git a/packages/wallet/src/features/transactions/SummaryCards/utils.ts b/packages/wallet/src/features/transactions/SummaryCards/utils.ts index 9773894d8e6..14f8acda0cc 100644 --- a/packages/wallet/src/features/transactions/SummaryCards/utils.ts +++ b/packages/wallet/src/features/transactions/SummaryCards/utils.ts @@ -23,11 +23,7 @@ import { SwapSummaryItem } from 'wallet/src/features/transactions/SummaryCards/S import { UnknownSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/UnknownSummaryItem' import { WCSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/WCSummaryItem' import { WrapSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/WrapSummaryItem' -import { - SummaryItemProps, - SwapSummaryCallbacks, - TransactionSummaryLayoutProps, -} from 'wallet/src/features/transactions/SummaryCards/types' +import { SummaryItemProps, SwapSummaryCallbacks } from 'wallet/src/features/transactions/SummaryCards/types' import { NFTTradeType, TransactionDetails, @@ -42,7 +38,6 @@ export type ActivityItem = TransactionDetails | SectionHeader | LoadingItem export type ActivityItemRenderer = ({ item, index }: { item: ActivityItem; index: number }) => JSX.Element export function generateActivityItemRenderer( - layoutElement: React.FunctionComponent<TransactionSummaryLayoutProps>, loadingItem: JSX.Element, sectionHeaderElement: React.FunctionComponent<{ title: string; index?: number }>, swapCallbacks: SwapSummaryCallbacks | undefined, @@ -102,7 +97,6 @@ export function generateActivityItemRenderer( key: item.id, authTrigger, transaction: item, - layoutElement, swapCallbacks, index, }) diff --git a/packages/wallet/src/features/transactions/TransactionDetails/TransactionDetails.tsx b/packages/wallet/src/features/transactions/TransactionDetails/TransactionDetails.tsx index 8007231c327..1b35756a774 100644 --- a/packages/wallet/src/features/transactions/TransactionDetails/TransactionDetails.tsx +++ b/packages/wallet/src/features/transactions/TransactionDetails/TransactionDetails.tsx @@ -108,7 +108,7 @@ export function TransactionDetails({ onPress={onPressToggleShowChildren} > <Text color="$neutral3" variant="body3"> - {showChildren ? t('swap.details.action.less') : t('swap.details.action.more')} + {showChildren ? t('common.button.showLess') : t('common.button.showMore')} </Text> {showChildren ? ( <AnglesMinimize color={colors.neutral3.get()} height={iconSizes.icon20} width={iconSizes.icon20} /> diff --git a/packages/wallet/src/features/transactions/TransactionHistoryUpdater.tsx b/packages/wallet/src/features/transactions/TransactionHistoryUpdater.tsx index 23dc8ffb340..462f6036838 100644 --- a/packages/wallet/src/features/transactions/TransactionHistoryUpdater.tsx +++ b/packages/wallet/src/features/transactions/TransactionHistoryUpdater.tsx @@ -2,7 +2,7 @@ import { useApolloClient } from '@apollo/client' import dayjs from 'dayjs' import { useEffect, useMemo } from 'react' import { View } from 'react-native' -import { batch, useDispatch } from 'react-redux' +import { batch, useDispatch, useSelector } from 'react-redux' import { PollingInterval } from 'uniswap/src/constants/misc' import { TransactionHistoryUpdaterQueryResult, @@ -25,7 +25,6 @@ import { useSelectAddressTransactions } from 'wallet/src/features/transactions/s import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' import { useAccounts, useActiveAccountAddress, useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' -import { useAppSelector } from 'wallet/src/state' export const GQL_QUERIES_TO_REFETCH_ON_TXN_UPDATE = [ GQLQueries.PortfolioBalances, @@ -98,10 +97,10 @@ function AddressTransactionHistoryUpdater({ const dispatch = useDispatch() const apolloClient = useApolloClient() - const activeAccountAddress = useAppSelector(selectActiveAccountAddress) + const activeAccountAddress = useSelector(selectActiveAccountAddress) // Current txn count for all addresses - const lastTxNotificationUpdateTimestamp = useAppSelector(selectLastTxNotificationUpdate)[address] + const lastTxNotificationUpdateTimestamp = useSelector(selectLastTxNotificationUpdate)[address] const fetchAndDispatchReceiveNotification = useFetchAndDispatchReceiveNotification() @@ -115,7 +114,7 @@ function AddressTransactionHistoryUpdater({ let newTransactionsFound = false // Parse txns and address from portfolio. - activities.map((activity) => { + activities.forEach((activity) => { if (!activity) { return } diff --git a/packages/wallet/src/features/transactions/TransactionRequest/SpendingDetails.tsx b/packages/wallet/src/features/transactions/TransactionRequest/SpendingDetails.tsx index a40a37bcdaf..7293a8395a3 100644 --- a/packages/wallet/src/features/transactions/TransactionRequest/SpendingDetails.tsx +++ b/packages/wallet/src/features/transactions/TransactionRequest/SpendingDetails.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next' import { Flex, Text } from 'ui/src' import { iconSizes } from 'ui/src/theme' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { WalletChainId } from 'uniswap/src/types/chains' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { NumberType } from 'utilities/src/format/types' @@ -12,7 +13,7 @@ import { useNativeCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInf import { ContentRow } from 'wallet/src/features/transactions/TransactionRequest/ContentRow' import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' -export function SpendingDetails({ value, chainId }: { value: string; chainId: WalletChainId }): JSX.Element { +export function SpendingEthDetails({ value, chainId }: { value: string; chainId: WalletChainId }): JSX.Element { const variant = isMobileApp ? 'body3' : 'body4' const { t } = useTranslation() @@ -46,3 +47,28 @@ export function SpendingDetails({ value, chainId }: { value: string; chainId: Wa </ContentRow> ) } + +export function SpendingDetails({ + currencyInfo, + showLabel, + tokenCount, +}: { + currencyInfo: CurrencyInfo + showLabel: boolean + tokenCount: number +}): JSX.Element { + const variant = isMobileApp ? 'body3' : 'body4' + + const { t } = useTranslation() + const labelCopy = + tokenCount > 1 ? t('walletConnect.request.details.label.tokens') : t('walletConnect.request.details.label.token') + + return ( + <ContentRow label={showLabel ? labelCopy : ''} variant={variant}> + <Flex row alignItems="center" gap="$spacing4"> + <CurrencyLogo currencyInfo={currencyInfo} size={iconSizes.icon16} /> + <Text variant={variant}>{getSymbolDisplayText(currencyInfo?.currency.symbol)}</Text> + </Flex> + </ContentRow> + ) +} diff --git a/packages/wallet/src/features/transactions/cancelTransactionSaga.ts b/packages/wallet/src/features/transactions/cancelTransactionSaga.ts index a06bef2887c..02df6e7cae4 100644 --- a/packages/wallet/src/features/transactions/cancelTransactionSaga.ts +++ b/packages/wallet/src/features/transactions/cancelTransactionSaga.ts @@ -1,7 +1,7 @@ import { PERMIT2_ADDRESS } from '@uniswap/permit2-sdk' import { CosignedV2DutchOrder, getCancelSingleParams } from '@uniswap/uniswapx-sdk' import { Contract, providers } from 'ethers' -import { call } from 'typed-redux-saga' +import { call, select } from 'typed-redux-saga' import PERMIT2_ABI from 'uniswap/src/abis/permit2.json' import { Permit2 } from 'uniswap/src/abis/types' import { getValidAddress } from 'uniswap/src/utils/addresses' @@ -13,7 +13,6 @@ import { isClassic } from 'wallet/src/features/transactions/swap/trade/utils' import { TransactionDetails, UniswapXOrderDetails } from 'wallet/src/features/transactions/types' import { getProvider, getSignerManager } from 'wallet/src/features/wallet/context' import { selectAccounts } from 'wallet/src/features/wallet/selectors' -import { appSelect } from 'wallet/src/state' // Note, transaction cancellation on Ethereum is inherently flaky // The best we can do is replace the transaction and hope the original isn't mined first @@ -61,7 +60,7 @@ function* cancelOrder(order: UniswapXOrderDetails, cancelRequest: providers.Tran } try { - const accounts = yield* appSelect(selectAccounts) + const accounts = yield* select(selectAccounts) const checksummedAddress = getValidAddress(order.from, true, false) if (!checksummedAddress) { throw new Error(`Cannot cancel order, address is invalid: ${checksummedAddress}`) diff --git a/packages/wallet/src/features/transactions/history/conversion/conversion.test.ts b/packages/wallet/src/features/transactions/history/conversion/conversion.test.ts index c08758a8637..2fd8425c491 100644 --- a/packages/wallet/src/features/transactions/history/conversion/conversion.test.ts +++ b/packages/wallet/src/features/transactions/history/conversion/conversion.test.ts @@ -147,6 +147,7 @@ const ONRAMP_TRANSFER_ASSET_CHANGE = { __typename: 'OnRampTransfer' as const, id: ASSET_CHANGE_ID, transactionReferenceId: 'transaction_reference_id', + externalSessionId: 'external_session_id', token: { id: 'asset_id', symbol: 'asset_symbol', diff --git a/packages/wallet/src/features/transactions/history/conversion/extractFiatOnRampTransactionDetails.ts b/packages/wallet/src/features/transactions/history/conversion/extractFiatOnRampTransactionDetails.ts index 522031446ae..c7784ef8b0d 100644 --- a/packages/wallet/src/features/transactions/history/conversion/extractFiatOnRampTransactionDetails.ts +++ b/packages/wallet/src/features/transactions/history/conversion/extractFiatOnRampTransactionDetails.ts @@ -1,9 +1,9 @@ import { TransactionType as RemoteTransactionType } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { fromGraphQLChain, toSupportedChainId } from 'uniswap/src/features/chains/utils' import { FORTransaction } from 'uniswap/src/features/fiatOnRamp/types' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { FiatOnRampTransactionDetails } from 'wallet/src/features/fiatOnRamp/types' import parseOnRampTransaction from 'wallet/src/features/transactions/history/conversion/parseOnRampTransaction' import { remoteTxStatusToLocalTxStatus } from 'wallet/src/features/transactions/history/utils' @@ -103,7 +103,7 @@ export function extractOnRampTransactionDetails(transaction: TransactionListQuer return { routing: Routing.CLASSIC, - id: transaction.details.id, + id: transaction.details.onRampTransfer.externalSessionId, chainId: fromGraphQLChain(transaction.chain) ?? UniverseChainId.Mainnet, addedTime: transaction.timestamp * 1000, // convert to ms, status: remoteTxStatusToLocalTxStatus(RemoteTransactionType.OnRamp, transaction.details.status), diff --git a/packages/wallet/src/features/transactions/history/conversion/extractTransactionDetails.ts b/packages/wallet/src/features/transactions/history/conversion/extractTransactionDetails.ts index ccd4156f76d..1346f5e93db 100644 --- a/packages/wallet/src/features/transactions/history/conversion/extractTransactionDetails.ts +++ b/packages/wallet/src/features/transactions/history/conversion/extractTransactionDetails.ts @@ -1,8 +1,8 @@ import { DEFAULT_NATIVE_ADDRESS } from 'uniswap/src/constants/chains' import { TransactionType as RemoteTransactionType } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { UniverseChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { SpamCode } from 'wallet/src/data/types' import parseApproveTransaction from 'wallet/src/features/transactions/history/conversion/parseApproveTransaction' import parseNFTMintTransaction from 'wallet/src/features/transactions/history/conversion/parseMintTransaction' diff --git a/packages/wallet/src/features/transactions/history/conversion/extractUniswapXOrderDetails.ts b/packages/wallet/src/features/transactions/history/conversion/extractUniswapXOrderDetails.ts index 7286b1ef1bf..207ce03f176 100644 --- a/packages/wallet/src/features/transactions/history/conversion/extractUniswapXOrderDetails.ts +++ b/packages/wallet/src/features/transactions/history/conversion/extractUniswapXOrderDetails.ts @@ -3,10 +3,10 @@ import { SwapOrderType, TokenStandard, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { UniverseChainId } from 'uniswap/src/types/chains' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { deriveCurrencyAmountFromAssetResponse } from 'wallet/src/features/transactions/history/utils' import { ConfirmedSwapTransactionInfo, diff --git a/packages/wallet/src/features/transactions/hooks.ts b/packages/wallet/src/features/transactions/hooks.ts index 5abb149bdb7..965dbc95fbb 100644 --- a/packages/wallet/src/features/transactions/hooks.ts +++ b/packages/wallet/src/features/transactions/hooks.ts @@ -1,7 +1,9 @@ import { Currency } from '@uniswap/sdk-core' import { BigNumberish } from 'ethers' import { useMemo } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { WalletChainId } from 'uniswap/src/types/chains' import { ensureLeading0x } from 'uniswap/src/utils/addresses' @@ -23,7 +25,7 @@ import { isFinalizedTx, } from 'wallet/src/features/transactions/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' -import { useAppSelector } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' type HashToTxMap = Map<string, TransactionDetails> @@ -98,7 +100,7 @@ export function useSelectTransaction( txId: string | undefined, ): TransactionDetails | undefined { const selectTransaction = useMemo(makeSelectTransaction, []) - return useAppSelector((state) => selectTransaction(state, { address, chainId, txId })) + return useSelector((state: RootState) => selectTransaction(state, { address, chainId, txId })) } export function useCreateSwapFormState( @@ -162,6 +164,8 @@ export function useMergeLocalAndRemoteTransactions( const dispatch = useDispatch() const localTransactions = useSelectAddressTransactions(address) + const onRampTransactionsFromGraphQL = useFeatureFlag(FeatureFlags.ForTransactionsFromGraphQL) + // Merge local and remote txs into one array and reconcile data discrepancies return useMemo((): TransactionDetails[] | undefined => { if (!remoteTransactions?.length) { @@ -195,21 +199,27 @@ export function useMergeLocalAndRemoteTransactions( } const hashes = new Set<string>() - const offChainFiatOnRampTxs: TransactionDetails[] = [] + const offChainFiatOnRampTxs = new Map<string, TransactionDetails>() function addToMap(map: HashToTxMap, tx: TransactionDetails): HashToTxMap { const hash = getTrackingHash(tx) if (hash) { map.set(hash, tx) hashes.add(hash) - } else { - offChainFiatOnRampTxs.push(tx) + } else if ( + tx.typeInfo.type === TransactionType.FiatPurchase || + tx.typeInfo.type === TransactionType.OnRampPurchase || + tx.typeInfo.type === TransactionType.OnRampTransfer + ) { + offChainFiatOnRampTxs.set(tx.id, tx) } return map } + // First iterate over remote transactions, then local transactions + // This ensures that local transactions overwrite remote transactions const remoteTxMap = remoteTransactions.reduce(addToMap, new Map<string, TransactionDetails>()) const localTxMap = localTransactions.reduce(addToMap, new Map<string, TransactionDetails>()) - const deDupedTxs: TransactionDetails[] = [...offChainFiatOnRampTxs] + const deDupedTxs: TransactionDetails[] = [...offChainFiatOnRampTxs.values()] for (const hash of [...hashes]) { const remoteTx = remoteTxMap.get(hash) @@ -252,7 +262,7 @@ export function useMergeLocalAndRemoteTransactions( } // If the tx is FiatPurchase and it's already on-chain, then use locally stored data, which comes from FOR provider API - if (localTx.typeInfo.type === TransactionType.FiatPurchase) { + if (!onRampTransactionsFromGraphQL && localTx.typeInfo.type === TransactionType.FiatPurchase) { deDupedTxs.push(localTx) continue } @@ -283,7 +293,7 @@ export function useMergeLocalAndRemoteTransactions( return a.addedTime > b.addedTime ? -1 : 1 }) - }, [dispatch, localTransactions, remoteTransactions]) + }, [dispatch, localTransactions, onRampTransactionsFromGraphQL, remoteTransactions]) } function useLowestPendingNonce(): BigNumberish | undefined { diff --git a/packages/wallet/src/features/transactions/hooks/useParsedTransactionWarnings.tsx b/packages/wallet/src/features/transactions/hooks/useParsedTransactionWarnings.tsx index f8ca869e4f6..c4c2fb1791e 100644 --- a/packages/wallet/src/features/transactions/hooks/useParsedTransactionWarnings.tsx +++ b/packages/wallet/src/features/transactions/hooks/useParsedTransactionWarnings.tsx @@ -14,7 +14,7 @@ import { WarningSeverity, } from 'wallet/src/features/transactions/WarningModal/types' -type WarningWithStyle = { +export type WarningWithStyle = { warning: Warning color: WarningColor Icon: FunctionComponent<SvgProps> | typeof AlertTriangle | null diff --git a/packages/wallet/src/features/transactions/hooks/useSyncFiatAndTokenAmountUpdater.tsx b/packages/wallet/src/features/transactions/hooks/useSyncFiatAndTokenAmountUpdater.tsx index 88dfb9c7cb7..77d7957b1cf 100644 --- a/packages/wallet/src/features/transactions/hooks/useSyncFiatAndTokenAmountUpdater.tsx +++ b/packages/wallet/src/features/transactions/hooks/useSyncFiatAndTokenAmountUpdater.tsx @@ -16,19 +16,19 @@ const NUM_DECIMALS_DISPLAY_FIAT = 2 * we reference the current fiat input amount, and update the token amount. If not enabled, we update the fiat amount based on token * amount. This allows us to toggle between 2 modes, without losing the entered amount. */ -export function useSyncFiatAndTokenAmountUpdater(): void { +export function useSyncFiatAndTokenAmountUpdater({ skip = false }: { skip?: boolean }): void { const { isFiatMode, updateSwapForm, exactAmountToken, exactAmountFiat, derivedSwapInfo, exactCurrencyField } = useSwapFormContext() const exactCurrency = derivedSwapInfo.currencies[exactCurrencyField] - const usdPriceOfCurrency = useUSDCPrice(exactCurrency?.currency ?? undefined) + const usdPriceOfCurrency = useUSDCPrice(skip ? undefined : exactCurrency?.currency ?? undefined) const { convertFiatAmount } = useLocalizationContext() const conversionRate = convertFiatAmount(1).amount const chainId = currencyIdToChain(exactCurrency?.currencyId ?? '') useEffect(() => { - if (!exactCurrency || !usdPriceOfCurrency || !chainId) { + if (skip || !exactCurrency || !usdPriceOfCurrency || !chainId) { return } @@ -67,5 +67,6 @@ export function useSyncFiatAndTokenAmountUpdater(): void { chainId, usdPriceOfCurrency, isFiatMode, + skip, ]) } diff --git a/packages/wallet/src/features/transactions/hooks/useTokenAndFiatDisplayAmounts.tsx b/packages/wallet/src/features/transactions/hooks/useTokenAndFiatDisplayAmounts.tsx index 7c08f8bab1c..163b72ec95f 100644 --- a/packages/wallet/src/features/transactions/hooks/useTokenAndFiatDisplayAmounts.tsx +++ b/packages/wallet/src/features/transactions/hooks/useTokenAndFiatDisplayAmounts.tsx @@ -15,11 +15,11 @@ interface FormattedDisplayAmountsProps { } /** - * Used to get sub-text display amoounts for the non-active input mode. + * Used to get sub-text display amounts for the non-active input mode. * - * If fiat mode, returns the equivilent token amount string. + * If fiat mode, returns the equivalent token amount string. * - * If token mode, returns the equivilent fiat amount formatted based on app currency settings. + * If token mode, returns the equivalent fiat amount formatted based on app currency settings. * */ export function useTokenAndFiatDisplayAmounts({ @@ -36,7 +36,7 @@ export function useTokenAndFiatDisplayAmounts({ ? formatCurrencyAmount({ value: currencyAmount, type: NumberType.TokenTx }) : '' - const formattedFiatValue: string = convertFiatAmountFormatted(usdValue?.toExact(), NumberType.FiatTokenQuantity) + const formattedFiatValue: string = convertFiatAmountFormatted(usdValue?.toExact() || 0, NumberType.FiatTokenQuantity) // In fiat mode, show equivalent token amount. In token mode, show equivalent fiat amount return useMemo((): string => { @@ -59,12 +59,13 @@ export function useTokenAndFiatDisplayAmounts({ return `${formattedCurrencyAmount} ${currencySymbol}` } } else { - if (formattedFiatValue && usdValue) { + if (formattedFiatValue) { return formattedFiatValue } } + // Fallback for no formatted value case - return '' + return '0' }, [ addFiatSymbolToNumber, appFiatCurrency.code, @@ -73,7 +74,6 @@ export function useTokenAndFiatDisplayAmounts({ formattedCurrencyAmount, formattedFiatValue, isFiatMode, - usdValue, value, ]) } diff --git a/packages/wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers.ts b/packages/wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers.ts index ce1a4158995..9247597b0de 100644 --- a/packages/wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers.ts +++ b/packages/wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers.ts @@ -1,6 +1,7 @@ import { AnyAction } from '@reduxjs/toolkit' import { Currency } from '@uniswap/sdk-core' import { useCallback } from 'react' +import { flowToModalName } from 'uniswap/src/components/TokenSelector/flowToModalName' import { AssetType } from 'uniswap/src/entities/assets' import { SearchContext } from 'uniswap/src/features/search/SearchContext' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' @@ -8,7 +9,6 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' import { currencyAddress } from 'uniswap/src/utils/currencyId' -import { flowToModalName } from 'wallet/src/components/TokenSelector/flowToModalName' import { transactionStateActions } from 'wallet/src/features/transactions/transactionState/transactionState' export function useTokenSelectorActionHandlers( diff --git a/packages/wallet/src/features/transactions/orderWatcherSaga.ts b/packages/wallet/src/features/transactions/orderWatcherSaga.ts index bb7a34ce241..f6b5e093125 100644 --- a/packages/wallet/src/features/transactions/orderWatcherSaga.ts +++ b/packages/wallet/src/features/transactions/orderWatcherSaga.ts @@ -1,9 +1,9 @@ import axios from 'axios' import { call, delay, fork, select, take } from 'typed-redux-saga' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { GetOrdersResponse } from 'uniswap/src/data/tradingApi/__generated__/index' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' -import { GetOrdersResponse } from 'wallet/src/data/tradingApi/__generated__/index' import { makeSelectUniswapXOrder } from 'wallet/src/features/transactions/selectors' import { updateTransaction } from 'wallet/src/features/transactions/slice' import { TRADING_API_HEADERS } from 'wallet/src/features/transactions/swap/trade/api/client' diff --git a/packages/wallet/src/features/transactions/refetchGQLQueriesSaga.ts b/packages/wallet/src/features/transactions/refetchGQLQueriesSaga.ts index 0e82775e53f..94e07e18978 100644 --- a/packages/wallet/src/features/transactions/refetchGQLQueriesSaga.ts +++ b/packages/wallet/src/features/transactions/refetchGQLQueriesSaga.ts @@ -5,6 +5,7 @@ import { PortfolioBalancesDocument, PortfolioBalancesQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { GQLQueries } from 'uniswap/src/data/graphql/uniswap-data-api/queries' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -41,19 +42,19 @@ export function* refetchGQLQueries({ return } - // when there is a new local tx wait REFETCH_INTERVAL then proactively refresh portfolio and activity queries + // When there is a new local tx, we wait `REFETCH_INTERVAL` and then refetch all queries. yield* delay(REFETCH_INTERVAL) - yield* call([apolloClient, apolloClient.refetchQueries], { - include: GQL_QUERIES_TO_REFETCH_ON_TXN_UPDATE, - }) + // We refetch all queries for the Tokens, NFT and Activity tabs. + yield* call([apolloClient, apolloClient.refetchQueries], { include: GQL_QUERIES_TO_REFETCH_ON_TXN_UPDATE }) if (!currencyIdToStartingBalance) { return } let freshnessLag = REFETCH_INTERVAL - // poll every REFETCH_INTERVAL until the cache has updated balances for the relevant currencies + + // We poll every `REFETCH_INTERVAL` until we see updated balances for the relevant currencies. for (let i = 0; i < MAX_REFETCH_ATTEMPTS; i += 1) { const currencyIdToUpdatedBalance = readBalancesFromCache({ owner, @@ -74,9 +75,8 @@ export function* refetchGQLQueries({ break } - yield* call([apolloClient, apolloClient.refetchQueries], { - include: GQL_QUERIES_TO_REFETCH_ON_TXN_UPDATE, - }) + // We only want to refetch `PortfolioBalances`, as this is the only query needed to check the updated balances. + yield* call([apolloClient, apolloClient.refetchQueries], { include: [GQLQueries.PortfolioBalances] }) freshnessLag += REFETCH_INTERVAL } diff --git a/packages/wallet/src/features/transactions/replaceTransactionSaga.test.ts b/packages/wallet/src/features/transactions/replaceTransactionSaga.test.ts index e6c8a0a5d25..f73e2c10f67 100644 --- a/packages/wallet/src/features/transactions/replaceTransactionSaga.test.ts +++ b/packages/wallet/src/features/transactions/replaceTransactionSaga.test.ts @@ -4,7 +4,7 @@ import { BigNumber, providers } from 'ethers' import MockDate from 'mockdate' import { expectSaga } from 'redux-saga-test-plan' import { call } from 'redux-saga/effects' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { attemptReplaceTransaction } from 'wallet/src/features/transactions/replaceTransactionSaga' import { sendTransaction, signAndSendTransaction } from 'wallet/src/features/transactions/sendTransactionSaga' import { addTransaction } from 'wallet/src/features/transactions/slice' diff --git a/packages/wallet/src/features/transactions/replaceTransactionSaga.ts b/packages/wallet/src/features/transactions/replaceTransactionSaga.ts index d0c404b0391..988be537c00 100644 --- a/packages/wallet/src/features/transactions/replaceTransactionSaga.ts +++ b/packages/wallet/src/features/transactions/replaceTransactionSaga.ts @@ -1,5 +1,5 @@ import { BigNumber, providers } from 'ethers' -import { call, put } from 'typed-redux-saga' +import { call, put, select } from 'typed-redux-saga' import i18n from 'uniswap/src/i18n/i18n' import { getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' @@ -15,7 +15,6 @@ import { import { createTransactionId, getSerializableTransactionRequest } from 'wallet/src/features/transactions/utils' import { getProvider, getSignerManager } from 'wallet/src/features/wallet/context' import { selectAccounts } from 'wallet/src/features/wallet/selectors' -import { appSelect } from 'wallet/src/state' export function* attemptReplaceTransaction( transaction: ClassicTransactionDetails, @@ -32,7 +31,7 @@ export function* attemptReplaceTransaction( throw new Error(`Cannot replace invalid transaction: ${hash}`) } - const accounts = yield* appSelect(selectAccounts) + const accounts = yield* select(selectAccounts) const checksummedAddress = getValidAddress(from, true, false) if (!checksummedAddress) { throw new Error(`Cannot replace transaction, address is invalid: ${checksummedAddress}`) diff --git a/packages/wallet/src/features/transactions/selectors.ts b/packages/wallet/src/features/transactions/selectors.ts index 3dc67af0f17..778137fbf05 100644 --- a/packages/wallet/src/features/transactions/selectors.ts +++ b/packages/wallet/src/features/transactions/selectors.ts @@ -1,5 +1,6 @@ import { createSelector, Selector } from '@reduxjs/toolkit' import { useMemo } from 'react' +import { useSelector } from 'react-redux' import { WalletChainId } from 'uniswap/src/types/chains' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' import { unique } from 'utilities/src/primitives/array' @@ -18,7 +19,7 @@ import { UniswapXOrderDetails, } from 'wallet/src/features/transactions/types' import { useAccounts } from 'wallet/src/features/wallet/hooks' -import { RootState, useAppSelector } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' export const selectTransactions = (state: RootState): TransactionStateMap => state.transactions @@ -82,19 +83,19 @@ export const makeSelectAddressTransactions = (): Selector< export function useSelectAddressTransactions(address: Address | null): TransactionDetails[] | undefined { const selectAddressTransactions = useMemo(makeSelectAddressTransactions, []) - return useAppSelector((state) => selectAddressTransactions(state, address)) + return useSelector((state: RootState) => selectAddressTransactions(state, address)) } export function useCurrencyIdToVisibility(): CurrencyIdToVisibility { const accounts = useAccounts() const addresses = Object.values(accounts).map((account) => account.address) - const manuallySetTokenVisibility = useAppSelector(selectTokensVisibility) + const manuallySetTokenVisibility = useSelector(selectTokensVisibility) const selectLocalTxCurrencyIds: (state: RootState, addresses: Address[]) => CurrencyIdToVisibility = useMemo( makeSelectTokenVisibilityFromLocalTxs, [], ) - const tokenVisibilityFromLocalTxs = useAppSelector((state) => selectLocalTxCurrencyIds(state, addresses)) + const tokenVisibilityFromLocalTxs = useSelector((state: RootState) => selectLocalTxCurrencyIds(state, addresses)) return { ...tokenVisibilityFromLocalTxs, diff --git a/packages/wallet/src/features/transactions/sendTransactionSaga.test.ts b/packages/wallet/src/features/transactions/sendTransactionSaga.test.ts index 7b580debea3..8d0f1087aea 100644 --- a/packages/wallet/src/features/transactions/sendTransactionSaga.test.ts +++ b/packages/wallet/src/features/transactions/sendTransactionSaga.test.ts @@ -2,8 +2,8 @@ import dayjs from 'dayjs' import { BigNumber, providers } from 'ethers' import { expectSaga } from 'redux-saga-test-plan' import { call } from 'redux-saga/effects' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { sendTransaction, signAndSendTransaction } from 'wallet/src/features/transactions/sendTransactionSaga' import { addTransaction } from 'wallet/src/features/transactions/slice' import { TransactionStatus } from 'wallet/src/features/transactions/types' diff --git a/packages/wallet/src/features/transactions/sendTransactionSaga.ts b/packages/wallet/src/features/transactions/sendTransactionSaga.ts index ee50915027b..10acf3a52e8 100644 --- a/packages/wallet/src/features/transactions/sendTransactionSaga.ts +++ b/packages/wallet/src/features/transactions/sendTransactionSaga.ts @@ -1,11 +1,11 @@ import { providers } from 'ethers' import { call, put } from 'typed-redux-saga' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { RPCType, WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { transactionActions } from 'wallet/src/features/transactions/slice' import { getBaseTradeAnalyticsProperties } from 'wallet/src/features/transactions/swap/analytics' import { diff --git a/packages/wallet/src/features/transactions/slice.test.ts b/packages/wallet/src/features/transactions/slice.test.ts index c9eaf9d7696..0c4e089af39 100644 --- a/packages/wallet/src/features/transactions/slice.test.ts +++ b/packages/wallet/src/features/transactions/slice.test.ts @@ -1,6 +1,6 @@ import { createStore, Store } from '@reduxjs/toolkit' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { addTransaction, cancelTransaction, diff --git a/packages/wallet/src/features/transactions/swap/CurrencyInputPanel.tsx b/packages/wallet/src/features/transactions/swap/CurrencyInputPanel.tsx index 2ea4c74f097..f41a9591ce6 100644 --- a/packages/wallet/src/features/transactions/swap/CurrencyInputPanel.tsx +++ b/packages/wallet/src/features/transactions/swap/CurrencyInputPanel.tsx @@ -9,6 +9,7 @@ import { fonts } from 'ui/src/theme' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { isDetoxBuild } from 'utilities/src/environment/constants' import { NumberType } from 'utilities/src/format/types' import { usePrevious } from 'utilities/src/react/hooks' @@ -334,7 +335,7 @@ export const CurrencyInputPanel = memo( value: currencyBalance, type: NumberType.TokenNonTx, })}{' '} - {currencyInfo.currency.symbol} + {getSymbolDisplayText(currencyInfo.currency.symbol)} </Text> )} {showMaxButton && onSetMax && ( diff --git a/packages/wallet/src/features/transactions/swap/DecimalPadInput.tsx b/packages/wallet/src/features/transactions/swap/DecimalPadInput.tsx index 78e4427b3eb..5c49b1093c3 100644 --- a/packages/wallet/src/features/transactions/swap/DecimalPadInput.tsx +++ b/packages/wallet/src/features/transactions/swap/DecimalPadInput.tsx @@ -1,4 +1,6 @@ -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { RefObject, forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { LayoutChangeEvent } from 'react-native' +import { Flex } from 'ui/src' import { TextInputProps } from 'uniswap/src/components/input/TextInput' import { maxDecimalsReached } from 'utilities/src/format/truncateToMaxDecimals' import { DecimalPad, KeyAction, KeyLabel } from 'wallet/src/features/transactions/swap/DecimalPad' @@ -22,6 +24,29 @@ export type DecimalPadInputRef = { setMaxHeight(height: number): void } +/* +This component is used to calculate the space that the `DecimalPad` can use. +We position the `DecimalPad` with `position: absolute` at the bottom of the screen instead of +putting it inside this container in order to avoid any overflows while the `DecimalPad` +is automatically resizing to find the right size for the screen. +*/ +export function DecimalPadCalculateSpace({ + isShortMobileDevice, + decimalPadRef, +}: { + isShortMobileDevice: boolean + decimalPadRef: RefObject<DecimalPadInputRef> +}): JSX.Element { + const onBottomScreenLayout = useCallback( + (event: LayoutChangeEvent): void => { + decimalPadRef.current?.setMaxHeight(event.nativeEvent.layout.height) + }, + [decimalPadRef], + ) + + return <Flex fill mt={isShortMobileDevice ? '$spacing2' : '$spacing8'} onLayout={onBottomScreenLayout} /> +} + export const DecimalPadInput = memo( forwardRef<DecimalPadInputRef, DecimalPadInputProps>(function DecimalPadInput( { diff --git a/packages/wallet/src/features/transactions/swap/SwapDetails.tsx b/packages/wallet/src/features/transactions/swap/SwapDetails.tsx index 01f93754c78..4931af67720 100644 --- a/packages/wallet/src/features/transactions/swap/SwapDetails.tsx +++ b/packages/wallet/src/features/transactions/swap/SwapDetails.tsx @@ -9,6 +9,7 @@ import { ElementName } from 'uniswap/src/features/telemetry/constants' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { NumberType } from 'utilities/src/format/types' +import { logger } from 'utilities/src/logger/logger' import { GasFeeResult } from 'wallet/src/features/gas/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { FeeOnTransferFeeGroupProps } from 'wallet/src/features/transactions/TransactionDetails/FeeOnTransferFee' @@ -90,7 +91,11 @@ export function SwapDetails({ } const swapFeeUsd = getFeeAmountUsd(trade, outputCurrencyPricePerUnitExact) - const swapFeeFiatFormatted = convertFiatAmountFormatted(swapFeeUsd, NumberType.FiatGasPrice) + if (swapFeeUsd && isNaN(swapFeeUsd)) { + logger.warn('SwapDetails', '', `swapFeeUsd is NaN`, { trade, outputCurrencyPricePerUnitExact }) + } + const formattedAmountFiat = + swapFeeUsd && !isNaN(swapFeeUsd) ? convertFiatAmountFormatted(swapFeeUsd, NumberType.FiatGasPrice) : undefined const swapFeeInfo = trade.swapFee ? { @@ -99,7 +104,7 @@ export function SwapDetails({ formattedAmount: getFormattedCurrencyAmount(trade.outputAmount.currency, trade.swapFee.amount, formatter) + getSymbolDisplayText(trade.outputAmount.currency.symbol), - formattedAmountFiat: swapFeeUsd ? swapFeeFiatFormatted : undefined, + formattedAmountFiat, } : undefined diff --git a/packages/wallet/src/features/transactions/swap/SwapFormButton.tsx b/packages/wallet/src/features/transactions/swap/SwapFormButton.tsx index caa0b42d163..ec1ab9ccaf4 100644 --- a/packages/wallet/src/features/transactions/swap/SwapFormButton.tsx +++ b/packages/wallet/src/features/transactions/swap/SwapFormButton.tsx @@ -2,6 +2,7 @@ import { TFunction } from 'i18next' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { AnimatePresence, Button, Flex, SpinningLoader, Text, isWeb, useIsShortMobileDevice } from 'ui/src' import { GraduationCap } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' @@ -29,7 +30,6 @@ import { createTransactionId } from 'wallet/src/features/transactions/utils' import { useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' -import { useAppSelector } from 'wallet/src/state' export const HOLD_TO_SWAP_TIMEOUT = 3 * ONE_SECOND_MS const KEEP_OPEN_MSG_DELAY = 3 * ONE_SECOND_MS @@ -58,8 +58,8 @@ export function SwapFormButton(): JSX.Element { const isHoldToSwapPressed = screen === SwapScreen.SwapReviewHoldingToSwap - const hasViewedReviewScreen = useAppSelector(selectHasViewedReviewScreen) - const hasSubmittedHoldToSwap = useAppSelector(selectHasSubmittedHoldToSwap) + const hasViewedReviewScreen = useSelector(selectHasViewedReviewScreen) + const hasSubmittedHoldToSwap = useSelector(selectHasSubmittedHoldToSwap) const isViewOnlyWallet = activeAccount.type === AccountType.Readonly const showHoldToSwapTip = hasViewedReviewScreen && !hasSubmittedHoldToSwap && !isViewOnlyWallet diff --git a/packages/wallet/src/features/transactions/swap/SwapFormScreen.tsx b/packages/wallet/src/features/transactions/swap/SwapFormScreen.tsx index 7c04652c240..ea84e0147ab 100644 --- a/packages/wallet/src/features/transactions/swap/SwapFormScreen.tsx +++ b/packages/wallet/src/features/transactions/swap/SwapFormScreen.tsx @@ -2,7 +2,7 @@ /* eslint-disable max-lines */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { LayoutChangeEvent, StyleSheet, TextInputProps } from 'react-native' +import { StyleSheet, TextInputProps } from 'react-native' import { AnimatePresence, Flex, Text, TouchableArea, isWeb, useIsShortMobileDevice, useSporeColors } from 'ui/src' import { InfoCircleFilled } from 'ui/src/components/icons' import { iconSizes, spacing } from 'ui/src/theme' @@ -16,10 +16,15 @@ import { formatCurrencyAmount } from 'utilities/src/format/localeBased' import { truncateToMaxDecimals } from 'utilities/src/format/truncateToMaxDecimals' import { NumberType } from 'utilities/src/format/types' import { useSwapFormContext } from 'wallet/src/features/transactions/contexts/SwapFormContext' +import { SwapScreen, useSwapScreenContext } from 'wallet/src/features/transactions/contexts/SwapScreenContext' import { useTransactionModalContext } from 'wallet/src/features/transactions/contexts/TransactionModalContext' import { useSyncFiatAndTokenAmountUpdater } from 'wallet/src/features/transactions/hooks/useSyncFiatAndTokenAmountUpdater' import { CurrencyInputPanel, CurrencyInputPanelRef } from 'wallet/src/features/transactions/swap/CurrencyInputPanel' -import { DecimalPadInput, DecimalPadInputRef } from 'wallet/src/features/transactions/swap/DecimalPadInput' +import { + DecimalPadCalculateSpace, + DecimalPadInput, + DecimalPadInputRef, +} from 'wallet/src/features/transactions/swap/DecimalPadInput' import { GasAndWarningRows } from 'wallet/src/features/transactions/swap/GasAndWarningRows' import { SwapArrowButton } from 'wallet/src/features/transactions/swap/SwapArrowButton' import { SwapFormButton } from 'wallet/src/features/transactions/swap/SwapFormButton' @@ -50,6 +55,10 @@ const WEB_CURRENCY_PANEL_INACTIVE_OPACITY = 0.6 const ON_SELECTION_CHANGE_WAIT_TIME_MS = 500 +/** + * IMPORTANT: In the Extension, this component remains mounted when the user moves to the `SwapReview` screen. + * Make sure you take this into consideration when adding/modifying any hooks that run on this component. + */ export function SwapFormScreen({ hideContent }: { hideContent: boolean }): JSX.Element { const { bottomSheetViewStyles } = useTransactionModalContext() const { selectingCurrencyField } = useSwapFormContext() @@ -74,6 +83,8 @@ function SwapFormContent(): JSX.Element { const { walletNeedsRestore, openWalletRestoreModal } = useTransactionModalContext() + const { screen } = useSwapScreenContext() + const { amountUpdatedTimeRef, derivedSwapInfo, @@ -104,8 +115,13 @@ function SwapFormContent(): JSX.Element { const showWebOutputTokenSelector = selectingCurrencyField === CurrencyField.OUTPUT && isWeb const showSwitchCurrencies = !showWebInputTokenSelector && !showWebOutputTokenSelector - // Updaters - useSyncFiatAndTokenAmountUpdater() + // When using fiat input mode, this hook updates the token amount based on the latest fiat conversion rate (currently polled every 15s). + // In the Extension, the `SwapForm` is not unmounted when the user moves to the `SwapReview` screen, + // so we need to skip these updates because we don't want the amounts being reviewed to keep changing. + // If we don't skip this, it also causes a cache-miss on `useTrade`, which would trigger a loading spinner because of a missing `trade`. + useSyncFiatAndTokenAmountUpdater({ skip: screen !== SwapScreen.SwapForm }) + + // Display a toast notification when the user switches networks. useShowSwapNetworkNotification(chainId) const onRestorePress = (): void => { @@ -229,10 +245,6 @@ function SwapFormContent(): JSX.Element { const [decimalPadReady, setDecimalPadReady] = useState(false) - const onBottomScreenLayout = useCallback((event: LayoutChangeEvent): void => { - decimalPadRef.current?.setMaxHeight(event.nativeEvent.layout.height) - }, []) - const onDecimalPadReady = useCallback(() => setDecimalPadReady(true), []) const onDecimalPadTriggerInputShake = useCallback(() => { @@ -594,17 +606,10 @@ function SwapFormContent(): JSX.Element { )} </> )} - {isWeb && <Flex mt="$spacing48" />} </Flex> {!isWeb && ( <> - {/* - This container is used to calculate the space that the `DecimalPad` can use. - We position the `DecimalPad` with `position: absolute` at the bottom of the screen instead of - putting it inside this container in order to avoid any overflows while the `DecimalPad` - is automatically resizing to find the right size for the screen. - */} - <Flex fill mt={isShortMobileDevice ? '$spacing2' : '$spacing8'} onLayout={onBottomScreenLayout} /> + <DecimalPadCalculateSpace decimalPadRef={decimalPadRef} isShortMobileDevice={isShortMobileDevice} /> <Flex $short={{ gap: '$none' }} animation="quick" diff --git a/packages/wallet/src/features/transactions/swap/SwapReviewScreen.tsx b/packages/wallet/src/features/transactions/swap/SwapReviewScreen.tsx index b338bf13d66..cab3184b05e 100644 --- a/packages/wallet/src/features/transactions/swap/SwapReviewScreen.tsx +++ b/packages/wallet/src/features/transactions/swap/SwapReviewScreen.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { FadeIn } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Button, Flex, HapticFeedback, Separator, SpinningLoader, Text, isWeb, useIsShortMobileDevice } from 'ui/src' import { BackArrow } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' @@ -38,7 +38,6 @@ import { getActionName, isWrapAction } from 'wallet/src/features/transactions/sw import { createTransactionId } from 'wallet/src/features/transactions/utils' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' -import { useAppSelector } from 'wallet/src/state' // eslint-disable-next-line complexity export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX.Element | null { @@ -304,7 +303,7 @@ export function SwapReviewScreen({ hideContent }: { hideContent: boolean }): JSX }, []) // Flag review screen user behavior, used to show hold to swap tip - const hasViewedReviewScreen = useAppSelector(selectHasViewedReviewScreen) + const hasViewedReviewScreen = useSelector(selectHasViewedReviewScreen) useEffect(() => { if (!hasViewedReviewScreen) { dispatch(setHasViewedReviewScreen(true)) diff --git a/packages/wallet/src/features/transactions/swap/SwapTokenSelector.tsx b/packages/wallet/src/features/transactions/swap/SwapTokenSelector.tsx index 56b89231475..5c4403f0558 100644 --- a/packages/wallet/src/features/transactions/swap/SwapTokenSelector.tsx +++ b/packages/wallet/src/features/transactions/swap/SwapTokenSelector.tsx @@ -1,6 +1,7 @@ import { Currency } from '@uniswap/sdk-core' import { useCallback } from 'react' import { Keyboard, LayoutAnimation } from 'react-native' +import { useSelector } from 'react-redux' import { isWeb } from 'ui/src' import { TokenSelector, @@ -8,26 +9,31 @@ import { TokenSelectorProps, TokenSelectorVariation, } from 'uniswap/src/components/TokenSelector/TokenSelector' +import { flowToModalName } from 'uniswap/src/components/TokenSelector/flowToModalName' +import { + useCommonTokensOptions, + useFilterCallbacks, + usePopularTokensOptions, + usePortfolioTokenOptions, + useTokenSectionsForSearchResults, +} from 'uniswap/src/components/TokenSelector/hooks' import { AssetType, TradeableAsset } from 'uniswap/src/entities/assets' import { SearchContext } from 'uniswap/src/features/search/SearchContext' +import { TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' import { currencyAddress } from 'uniswap/src/utils/currencyId' -import { flowToModalName } from 'wallet/src/components/TokenSelector/flowToModalName' import { useAddToSearchHistory, - useCommonTokensOptions, useFavoriteTokensOptions, - useFilterCallbacks, - usePopularTokensOptions, - usePortfolioTokenOptions, useTokenSectionsForEmptySearch, - useTokenSectionsForSearchResults, } from 'wallet/src/components/TokenSelector/hooks' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { selectSearchHistory } from 'wallet/src/features/search/selectSearchHistory' import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' import { SwapFormState, useSwapFormContext } from 'wallet/src/features/transactions/contexts/SwapFormContext' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' @@ -37,9 +43,11 @@ export function SwapTokenSelector(): JSX.Element { const activeAccountAddress = useActiveAccountAddressWithThrow() const { updateSwapForm, exactCurrencyField, selectingCurrencyField, output, input } = swapContext const { navigateToBuyOrReceiveWithEmptyWallet } = useWalletNavigation() - // TODO: (MOB-3643) Share localization context with WEB + const address = useActiveAccountAddressWithThrow() const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() + const valueModifiers = usePortfolioValueModifiers(address) const { registerSearch } = useAddToSearchHistory() + const searchHistory = useSelector(selectSearchHistory) if (!selectingCurrencyField) { throw new Error('TokenSelector rendered without `selectingCurrencyField`') @@ -114,6 +122,8 @@ export function SwapTokenSelector(): JSX.Element { selectingCurrencyField === CurrencyField.INPUT ? TokenSelectorVariation.BalancesAndPopular : TokenSelectorVariation.SuggestedAndFavoritesAndPopular, + valueModifiers, + searchHistory: searchHistory as TokenSearchResult[], onClose: onHideTokenSelector, onDismiss: () => Keyboard.dismiss(), onPressAnimation: () => LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut), diff --git a/packages/wallet/src/features/transactions/swap/TransactionAmountsReview.tsx b/packages/wallet/src/features/transactions/swap/TransactionAmountsReview.tsx index b7d012ad2c4..8d00e8c5554 100644 --- a/packages/wallet/src/features/transactions/swap/TransactionAmountsReview.tsx +++ b/packages/wallet/src/features/transactions/swap/TransactionAmountsReview.tsx @@ -6,9 +6,13 @@ import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' +import { buildCurrencyId, currencyAddress } from 'uniswap/src/utils/currencyId' import { NumberType } from 'utilities/src/format/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' import { DerivedSwapInfo } from 'wallet/src/features/transactions/swap/types' +import { WrapType } from 'wallet/src/features/transactions/types' export function TransactionAmountsReview({ acceptedDerivedSwapInfo, @@ -21,55 +25,56 @@ export function TransactionAmountsReview({ }): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() - const { convertFiatAmountFormatted, formatCurrencyAmount, formatNumberOrString } = useLocalizationContext() - - const { currencies, currencyAmounts, currencyAmountsUSDValue, exactAmountToken, exactCurrencyField } = - acceptedDerivedSwapInfo - - const currencyInInfo = currencies[CurrencyField.INPUT] - const currencyOutInfo = currencies[CurrencyField.OUTPUT] - - const usdAmountIn = - exactCurrencyField === CurrencyField.INPUT - ? currencyAmountsUSDValue[CurrencyField.INPUT]?.toExact() - : acceptedDerivedSwapInfo?.currencyAmountsUSDValue[CurrencyField.INPUT]?.toExact() - - const usdAmountOut = - exactCurrencyField === CurrencyField.OUTPUT - ? currencyAmountsUSDValue[CurrencyField.OUTPUT]?.toExact() - : acceptedDerivedSwapInfo?.currencyAmountsUSDValue[CurrencyField.OUTPUT]?.toExact() - - const formattedFiatAmountIn = convertFiatAmountFormatted(usdAmountIn, NumberType.FiatTokenQuantity) - const formattedFiatAmountOut = convertFiatAmountFormatted(usdAmountOut, NumberType.FiatTokenQuantity) - - const derivedCurrencyField = exactCurrencyField === CurrencyField.INPUT ? CurrencyField.OUTPUT : CurrencyField.INPUT + const { convertFiatAmountFormatted, formatCurrencyAmount } = useLocalizationContext() + const { exactCurrencyField, trade, wrapType, currencyAmounts } = acceptedDerivedSwapInfo + + const isWrap = wrapType !== WrapType.NotApplicable + + // For wraps, we need to detect if WETH is input or output, because we have logic in `useDerivedSwapInfo` that + // sets both currencAmounts to native currency, which would result in native ETH as both tokens for this UI. + const wrapInputCurrencyAmount = + wrapType === WrapType.Wrap ? currencyAmounts[CurrencyField.INPUT] : currencyAmounts[CurrencyField.INPUT]?.wrapped + const wrapOutputCurrencyAmount = + wrapType === WrapType.Wrap ? currencyAmounts[CurrencyField.OUTPUT]?.wrapped : currencyAmounts[CurrencyField.OUTPUT] + + // Token amounts + // On review screen, always show values directly from trade object, to match exactly what is submitted on chain + // For wraps, we have no trade object so use values from form state + const inputCurrencyAmount = isWrap ? wrapInputCurrencyAmount : trade.trade?.inputAmount + const outputCurrencyAmount = isWrap ? wrapOutputCurrencyAmount : trade.trade?.outputAmount + + // This should never happen. It's just to keep TS happy. + if (!inputCurrencyAmount || !outputCurrencyAmount) { + throw new Error('Missing required `currencyAmount` to render `TransactionAmountsReview`') + } - const derivedAmount = formatCurrencyAmount({ - value: acceptedDerivedSwapInfo?.currencyAmounts[derivedCurrencyField], + const formattedTokenAmountIn = formatCurrencyAmount({ + value: inputCurrencyAmount, type: NumberType.TokenTx, }) - - const formattedExactAmountToken = formatNumberOrString({ - value: exactAmountToken, + const formattedTokenAmountOut = formatCurrencyAmount({ + value: outputCurrencyAmount, type: NumberType.TokenTx, }) - const [formattedTokenAmountIn, formattedTokenAmountOut] = - exactCurrencyField === CurrencyField.INPUT - ? [formattedExactAmountToken, derivedAmount] - : [derivedAmount, formattedExactAmountToken] + // USD amount + const usdAmountIn = useUSDCValue(inputCurrencyAmount) + const usdAmountOut = useUSDCValue(outputCurrencyAmount) + const formattedFiatAmountIn = convertFiatAmountFormatted(usdAmountIn?.toExact(), NumberType.FiatTokenQuantity) + const formattedFiatAmountOut = convertFiatAmountFormatted(usdAmountOut?.toExact(), NumberType.FiatTokenQuantity) const shouldDimInput = newTradeRequiresAcceptance && exactCurrencyField === CurrencyField.OUTPUT const shouldDimOutput = newTradeRequiresAcceptance && exactCurrencyField === CurrencyField.INPUT - if ( - !currencyInInfo || - !currencyOutInfo || - !currencyAmounts[CurrencyField.INPUT] || - !currencyAmounts[CurrencyField.OUTPUT] || - !acceptedDerivedSwapInfo.currencyAmounts[CurrencyField.INPUT] || - !acceptedDerivedSwapInfo.currencyAmounts[CurrencyField.OUTPUT] - ) { + // Rebuild currency infos directly from trade object to ensure it matches what is submitted on chain + const currencyInInfo = useCurrencyInfo( + buildCurrencyId(inputCurrencyAmount.currency.chainId, currencyAddress(inputCurrencyAmount.currency)), + ) + const currencyOutInfo = useCurrencyInfo( + buildCurrencyId(outputCurrencyAmount.currency.chainId, currencyAddress(outputCurrencyAmount.currency)), + ) + + if (!currencyInInfo || !currencyOutInfo) { // This should never happen. It's just to keep TS happy. throw new Error('Missing required props in `derivedSwapInfo` to render `TransactionAmountsReview` screen.') } diff --git a/packages/wallet/src/features/transactions/swap/analytics.ts b/packages/wallet/src/features/transactions/swap/analytics.ts index 138af2ff430..faef33a3dd7 100644 --- a/packages/wallet/src/features/transactions/swap/analytics.ts +++ b/packages/wallet/src/features/transactions/swap/analytics.ts @@ -1,5 +1,5 @@ import { SwapEventName } from '@uniswap/analytics-events' -import { Currency, TradeType } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' import { useEffect } from 'react' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { SwapTradeBaseProperties } from 'uniswap/src/features/telemetry/types' @@ -37,9 +37,13 @@ export function useSwapAnalytics(derivedSwapInfo: DerivedSwapInfo): void { export function getBaseTradeAnalyticsProperties({ formatter, trade, + currencyInAmountUSD, + currencyOutAmountUSD, }: { formatter: LocalizationContextState trade: Trade<Currency, Currency, TradeType> + currencyInAmountUSD?: Maybe<CurrencyAmount<Currency>> + currencyOutAmountUSD?: Maybe<CurrencyAmount<Currency>> }): SwapTradeBaseProperties { const portionAmount = getClassicQuoteFromResponse(trade?.quote)?.portionAmount @@ -67,6 +71,8 @@ export function getBaseTradeAnalyticsProperties({ value: finalOutputAmount, type: NumberType.SwapTradeAmount, }), + token_in_amount_usd: currencyInAmountUSD ? parseFloat(currencyInAmountUSD.toFixed(2)) : undefined, + token_out_amount_usd: currencyOutAmountUSD ? parseFloat(currencyOutAmountUSD.toFixed(2)) : undefined, allowed_slippage_basis_points: trade.slippageTolerance * 100, fee_amount: portionAmount, requestId: trade.quote?.requestId, @@ -81,9 +87,17 @@ export function getBaseTradeAnalyticsPropertiesFromSwapInfo({ derivedSwapInfo: DerivedSwapInfo formatter: LocalizationContextState }): SwapTradeBaseProperties { - const { chainId, currencyAmounts } = derivedSwapInfo + const { chainId, currencyAmounts, currencyAmountsUSDValue } = derivedSwapInfo const inputCurrencyAmount = currencyAmounts[CurrencyField.INPUT] const outputCurrencyAmount = currencyAmounts[CurrencyField.OUTPUT] + + const currencyInAmountUSD = currencyAmountsUSDValue[CurrencyField.INPUT] + ? parseFloat(currencyAmountsUSDValue[CurrencyField.INPUT].toFixed(2)) + : undefined + const currencyOutAmountUSD = currencyAmountsUSDValue[CurrencyField.OUTPUT] + ? parseFloat(currencyAmountsUSDValue[CurrencyField.OUTPUT].toFixed(2)) + : undefined + const slippageTolerance = derivedSwapInfo.customSlippageTolerance ?? derivedSwapInfo.autoSlippageTolerance const portionAmount = getClassicQuoteFromResponse(derivedSwapInfo.trade?.trade?.quote)?.portionAmount @@ -110,6 +124,8 @@ export function getBaseTradeAnalyticsPropertiesFromSwapInfo({ value: finalOutputAmount, type: NumberType.SwapTradeAmount, }), + token_in_amount_usd: currencyInAmountUSD, + token_out_amount_usd: currencyOutAmountUSD, allowed_slippage_basis_points: slippageTolerance ? slippageTolerance * 100 : undefined, fee_amount: portionAmount, } diff --git a/packages/wallet/src/features/transactions/swap/hooks/useMostRecentSwapTx.ts b/packages/wallet/src/features/transactions/swap/hooks/useMostRecentSwapTx.ts index 4c3af697678..fa282bbb740 100644 --- a/packages/wallet/src/features/transactions/swap/hooks/useMostRecentSwapTx.ts +++ b/packages/wallet/src/features/transactions/swap/hooks/useMostRecentSwapTx.ts @@ -1,10 +1,10 @@ +import { useSelector } from 'react-redux' import { flattenObjectOfObjects } from 'utilities/src/primitives/objects' import { selectTransactions } from 'wallet/src/features/transactions/selectors' import { TransactionDetails, TransactionType } from 'wallet/src/features/transactions/types' -import { useAppSelector } from 'wallet/src/state' export function useMostRecentSwapTx(address: Address): TransactionDetails | undefined { - const transactions = useAppSelector(selectTransactions) + const transactions = useSelector(selectTransactions) const addressTransactions = transactions[address] if (addressTransactions) { return flattenObjectOfObjects(addressTransactions) diff --git a/packages/wallet/src/features/transactions/swap/modals/NetworkFeeWarning.tsx b/packages/wallet/src/features/transactions/swap/modals/NetworkFeeWarning.tsx index dc4e9f5e2a4..79f6a0675d9 100644 --- a/packages/wallet/src/features/transactions/swap/modals/NetworkFeeWarning.tsx +++ b/packages/wallet/src/features/transactions/swap/modals/NetworkFeeWarning.tsx @@ -74,14 +74,15 @@ function NetworkFeeText({ if (uniswapXGasFeeInfo) { return ( <Trans - components={{ gradient: <UniswapXText height={18} variant="body3" /> }} + // TODO(WEB-4313): Remove need to manually adjust the height of the UniswapXText component for mobile. + components={{ gradient: <UniswapXText height={17} variant="body3" /> }} i18nKey="swap.warning.networkFee.message.uniswapX" /> ) } else if (showHighGasFeeUI) { - return t('swap.warning.networkFee.highRelativeToValue') + return <>{t('swap.warning.networkFee.highRelativeToValue')}</> } else { - return t('swap.warning.networkFee.message') + return <>{t('swap.warning.networkFee.message')}</> } } @@ -91,7 +92,7 @@ function UniswapXFeeContent({ uniswapXGasFeeInfo }: { uniswapXGasFeeInfo: Format return ( <Flex gap="$spacing12"> - <Flex centered={isMobile} width="100%"> + <Flex row centered={isMobile} width="100%"> <LearnMoreLink textVariant={isWeb ? 'buttonLabel4' : undefined} url={uniswapUrls.helpArticleUrls.uniswapXInfo} diff --git a/packages/wallet/src/features/transactions/swap/modals/settings/SwapSettingsModal.tsx b/packages/wallet/src/features/transactions/swap/modals/settings/SwapSettingsModal.tsx index ed3750a6a33..fcf88670446 100644 --- a/packages/wallet/src/features/transactions/swap/modals/settings/SwapSettingsModal.tsx +++ b/packages/wallet/src/features/transactions/swap/modals/settings/SwapSettingsModal.tsx @@ -158,7 +158,6 @@ function SwapSettingsOptions({ const { chainId } = derivedSwapInfo const isMevBlockerFeatureEnabled = useFeatureFlag(FeatureFlags.MevBlocker) - const isOptionalRoutingEnabled = useFeatureFlag(FeatureFlags.OptionalRouting) const tradeProtocolPreferenceTitle = getTitleFromProtocolPreference(tradeProtocolPreference, t) @@ -171,24 +170,22 @@ function SwapSettingsOptions({ /> <Separator backgroundColor="$surface3" /> {isMevBlockerFeatureEnabled && <SwapProtectionSettingsRow chainId={chainId} />} - {isOptionalRoutingEnabled && ( - <> - <Separator backgroundColor="$surface3" /> - <Flex centered row gap="$spacing16" justifyContent="space-between"> - <Text color="$neutral1" flexShrink={1} variant="subheading2"> - {t('swap.settings.routingPreference.title')} - </Text> - <TouchableArea flexShrink={1} onPress={(): void => setView(SwapSettingsModalView.RoutePreference)}> - <Flex row alignItems="center" gap="$spacing4" justifyContent="flex-end"> - <Text color="$neutral2" flexWrap="wrap" variant="subheading2"> - {tradeProtocolPreferenceTitle} - </Text> - <RotatableChevron color="$neutral3" direction="right" height={iconSizes.icon24} /> - </Flex> - </TouchableArea> - </Flex> - </> - )} + <> + <Separator backgroundColor="$surface3" /> + <Flex centered row gap="$spacing16" justifyContent="space-between"> + <Text color="$neutral1" flexShrink={1} variant="subheading2"> + {t('swap.settings.routingPreference.title')} + </Text> + <TouchableArea flexShrink={1} onPress={(): void => setView(SwapSettingsModalView.RoutePreference)}> + <Flex row alignItems="center" gap="$spacing4" justifyContent="flex-end"> + <Text color="$neutral2" flexWrap="wrap" variant="subheading2"> + {tradeProtocolPreferenceTitle} + </Text> + <RotatableChevron color="$neutral3" direction="right" height={iconSizes.icon24} /> + </Flex> + </TouchableArea> + </Flex> + </> </Flex> ) } diff --git a/packages/wallet/src/features/transactions/swap/submitOrderSaga.test.ts b/packages/wallet/src/features/transactions/swap/submitOrderSaga.test.ts new file mode 100644 index 00000000000..c94e1adbfda --- /dev/null +++ b/packages/wallet/src/features/transactions/swap/submitOrderSaga.test.ts @@ -0,0 +1,277 @@ +import { Protocol } from '@uniswap/router-sdk' +import { TradeType } from '@uniswap/sdk-core' +import axios from 'axios' +import { testSaga } from 'redux-saga-test-plan' +import { OrderRequest, Routing } from 'uniswap/src/data/tradingApi/__generated__/index' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { currencyId } from 'uniswap/src/utils/currencyId' +import { addTransaction, finalizeTransaction, updateTransaction } from 'wallet/src/features/transactions/slice' +import { + ORDER_ENDPOINT, + ORDER_STALENESS_THRESHOLD, + SubmitUniswapXOrderParams, + submitUniswapXOrder, +} from 'wallet/src/features/transactions/swap/submitOrderSaga' +import { TRADING_API_HEADERS } from 'wallet/src/features/transactions/swap/trade/api/client' +import { + QueuedOrderStatus, + TransactionStatus, + TransactionType, + UniswapXOrderDetails, +} from 'wallet/src/features/transactions/types' +import { signerMnemonicAccount } from 'wallet/src/test/fixtures' + +const baseSubmitOrderParams = { + chainId: UniverseChainId.Mainnet, + account: signerMnemonicAccount(), + typeInfo: { + type: TransactionType.Swap, + tradeType: TradeType.EXACT_INPUT, + inputCurrencyId: currencyId(NativeCurrency.onChain(UniverseChainId.Mainnet)), + outputCurrencyId: '0xabc', + inputCurrencyAmountRaw: '10000', + expectedOutputCurrencyAmountRaw: '200000', + minimumOutputCurrencyAmountRaw: '300000', + protocol: Protocol.V3, + }, + analytics: {}, + txId: '1', + orderParams: { quote: { orderId: '0xMockOrderHash' } } as unknown as OrderRequest, + onSubmit: jest.fn(), + onFailure: jest.fn(), +} satisfies SubmitUniswapXOrderParams + +const baseExpectedInitialOrderDetails: UniswapXOrderDetails = { + routing: Routing.DUTCH_V2, + orderHash: baseSubmitOrderParams.orderParams.quote.orderId, + id: baseSubmitOrderParams.txId, + chainId: baseSubmitOrderParams.chainId, + typeInfo: baseSubmitOrderParams.typeInfo, + from: baseSubmitOrderParams.account.address, + addedTime: 1, + status: TransactionStatus.Pending, + queueStatus: QueuedOrderStatus.Waiting, +} + +describe(submitUniswapXOrder, () => { + beforeEach(() => { + let mockTimestamp = 1 + Date.now = jest.fn(() => mockTimestamp++) + }) + + it('sends a uniswapx order', async () => { + const expectedSubmittedOrderDetails = { + ...baseExpectedInitialOrderDetails, + addedTime: 3, + queueStatus: QueuedOrderStatus.Submitted, + } satisfies UniswapXOrderDetails + + testSaga(submitUniswapXOrder, baseSubmitOrderParams) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .put({ type: updateTransaction.type, payload: expectedSubmittedOrderDetails }) + .next() + .call(axios.post, ORDER_ENDPOINT, baseSubmitOrderParams.orderParams, { headers: TRADING_API_HEADERS }) + .next() + .call(sendAnalyticsEvent, WalletEventName.SwapSubmitted, { + routing: Routing.DUTCH_V2, + order_hash: baseExpectedInitialOrderDetails.orderHash, + }) + .next() + .call(baseSubmitOrderParams.onSubmit) + .next() + .isDone() + }) + + it('updates an order properly if order submission fails', async () => { + const expectedSubmittedOrderDetails = { + ...baseExpectedInitialOrderDetails, + addedTime: 3, + queueStatus: QueuedOrderStatus.Submitted, + } + + testSaga(submitUniswapXOrder, baseSubmitOrderParams) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .put({ type: updateTransaction.type, payload: expectedSubmittedOrderDetails }) + .next() + .call(axios.post, ORDER_ENDPOINT, baseSubmitOrderParams.orderParams, { headers: TRADING_API_HEADERS }) + .throw(new Error('pretend the order endpoint failed')) + .put({ + type: updateTransaction.type, + payload: { + ...baseExpectedInitialOrderDetails, + queueStatus: QueuedOrderStatus.SubmissionFailed, + }, + }) + .next() + .call(baseSubmitOrderParams.onFailure) + .next() + .isDone() + }) + + describe('blocking tx edge cases', () => { + const approveTxHash = '0xMockApprovalTxHash' + const wrapTxHash = '0xMockWrapTxHash' + + it('waits for approval and then sends a uniswapx order', async () => { + const expectedSubmittedOrderDetails = { + ...baseExpectedInitialOrderDetails, + addedTime: 5, + queueStatus: QueuedOrderStatus.Submitted, + } satisfies UniswapXOrderDetails + + testSaga(submitUniswapXOrder, { ...baseSubmitOrderParams, approveTxHash }) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .take(finalizeTransaction.type) + .next({ payload: { hash: "different transaction not the one we're waiting for" } }) + .take(finalizeTransaction.type) + .next({ payload: { hash: approveTxHash, status: TransactionStatus.Success } }) + .put({ type: updateTransaction.type, payload: expectedSubmittedOrderDetails }) + .next() + .call(axios.post, ORDER_ENDPOINT, baseSubmitOrderParams.orderParams, { headers: TRADING_API_HEADERS }) + .next() + .call(sendAnalyticsEvent, WalletEventName.SwapSubmitted, { + routing: Routing.DUTCH_V2, + order_hash: baseExpectedInitialOrderDetails.orderHash, + }) + .next() + .call(baseSubmitOrderParams.onSubmit) + .next() + .isDone() + }) + + it('waits for wrap and then sends a uniswapx order', async () => { + const expectedSubmittedOrderDetails = { + ...baseExpectedInitialOrderDetails, + addedTime: 5, + queueStatus: QueuedOrderStatus.Submitted, + } satisfies UniswapXOrderDetails + + testSaga(submitUniswapXOrder, { ...baseSubmitOrderParams, wrapTxHash }) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .take(finalizeTransaction.type) + .next({ payload: { hash: "different transaction not the one we're waiting for" } }) + .take(finalizeTransaction.type) + .next({ payload: { hash: wrapTxHash, status: TransactionStatus.Success } }) + .put({ type: updateTransaction.type, payload: expectedSubmittedOrderDetails }) + .next() + .call(axios.post, ORDER_ENDPOINT, baseSubmitOrderParams.orderParams, { headers: TRADING_API_HEADERS }) + .next() + .call(sendAnalyticsEvent, WalletEventName.SwapSubmitted, { + routing: Routing.DUTCH_V2, + order_hash: baseExpectedInitialOrderDetails.orderHash, + }) + .next() + .call(baseSubmitOrderParams.onSubmit) + .next() + .isDone() + }) + + it('waits for approval and wrap and sends a uniswapx order', async () => { + const expectedSubmittedOrderDetails = { + ...baseExpectedInitialOrderDetails, + addedTime: 5, + queueStatus: QueuedOrderStatus.Submitted, + } satisfies UniswapXOrderDetails + + testSaga(submitUniswapXOrder, { ...baseSubmitOrderParams, wrapTxHash, approveTxHash }) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .take(finalizeTransaction.type) + .next({ payload: { hash: wrapTxHash, status: TransactionStatus.Success } }) + .take(finalizeTransaction.type) + .next({ payload: { hash: approveTxHash, status: TransactionStatus.Success } }) + .put({ type: updateTransaction.type, payload: expectedSubmittedOrderDetails }) + .next() + .call(axios.post, ORDER_ENDPOINT, baseSubmitOrderParams.orderParams, { headers: TRADING_API_HEADERS }) + .next() + .call(sendAnalyticsEvent, WalletEventName.SwapSubmitted, { + routing: Routing.DUTCH_V2, + order_hash: baseExpectedInitialOrderDetails.orderHash, + }) + .next() + .call(baseSubmitOrderParams.onSubmit) + .next() + .isDone() + }) + + it('updates state if an approval fails', async () => { + testSaga(submitUniswapXOrder, { ...baseSubmitOrderParams, approveTxHash }) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .take(finalizeTransaction.type) + .next({ payload: { hash: approveTxHash, status: TransactionStatus.Failed } }) + .put({ + type: updateTransaction.type, + payload: { + ...baseExpectedInitialOrderDetails, + queueStatus: QueuedOrderStatus.ApprovalFailed, + }, + }) + .next() + .call(baseSubmitOrderParams.onFailure) + .next() + .isDone() + }) + + it('updates state if an wrap fails', async () => { + testSaga(submitUniswapXOrder, { ...baseSubmitOrderParams, wrapTxHash }) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .take(finalizeTransaction.type) + .next({ payload: { hash: wrapTxHash, status: TransactionStatus.Failed } }) + .put({ + type: updateTransaction.type, + payload: { + ...baseExpectedInitialOrderDetails, + queueStatus: QueuedOrderStatus.WrapFailed, + }, + }) + .next() + .call(baseSubmitOrderParams.onFailure) + .next() + .isDone() + }) + + it('updates state if order becomes stale after waiting too long', async () => { + let nextTimestampReturnValue = 1 + // Mock more than ORDER_STALENESS_THRESHOLD seconds passing between saga start & wrap finish + Date.now = jest.fn(() => { + const timestamp = nextTimestampReturnValue + nextTimestampReturnValue += ORDER_STALENESS_THRESHOLD + 1 + return timestamp + }) + + testSaga(submitUniswapXOrder, { ...baseSubmitOrderParams, wrapTxHash }) + .next() + .put({ type: addTransaction.type, payload: baseExpectedInitialOrderDetails }) + .next() + .take(finalizeTransaction.type) + .next({ payload: { hash: wrapTxHash, status: TransactionStatus.Success } }) + .put({ + type: updateTransaction.type, + payload: { + ...baseExpectedInitialOrderDetails, + queueStatus: QueuedOrderStatus.Stale, + }, + }) + .next() + .call(baseSubmitOrderParams.onFailure) + .next() + .isDone() + }) + }) +}) diff --git a/packages/wallet/src/features/transactions/swap/submitOrderSaga.ts b/packages/wallet/src/features/transactions/swap/submitOrderSaga.ts index 267e143f463..f6b232b4f88 100644 --- a/packages/wallet/src/features/transactions/swap/submitOrderSaga.ts +++ b/packages/wallet/src/features/transactions/swap/submitOrderSaga.ts @@ -1,12 +1,12 @@ import axios from 'axios' import { call, put, take } from 'typed-redux-saga' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { OrderRequest, Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' -import { OrderRequest, Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { finalizeTransaction, transactionActions } from 'wallet/src/features/transactions/slice' import { getBaseTradeAnalyticsProperties } from 'wallet/src/features/transactions/swap/analytics' import { TRADING_API_HEADERS } from 'wallet/src/features/transactions/swap/trade/api/client' @@ -21,9 +21,9 @@ import { Account } from 'wallet/src/features/wallet/accounts/types' // If the app is closed during the waiting period and then reopened, the saga will resume; // the order should not be submitted if too much time has passed as it may be stale. -const ORDER_STALENESS_THRESHOLD = 45 * ONE_SECOND_MS +export const ORDER_STALENESS_THRESHOLD = 45 * ONE_SECOND_MS -interface SubmitUniswapXOrderParams { +export interface SubmitUniswapXOrderParams { // internal id used for tracking transactions before they're submitted txId?: string chainId: WalletChainId @@ -37,7 +37,7 @@ interface SubmitUniswapXOrderParams { onFailure: () => void } -const ORDER_ENDPOINT = uniswapUrls.tradingApiUrl + uniswapUrls.tradingApiPaths.order +export const ORDER_ENDPOINT = uniswapUrls.tradingApiUrl + uniswapUrls.tradingApiPaths.order export function* submitUniswapXOrder(params: SubmitUniswapXOrderParams) { const { orderParams, approveTxHash, wrapTxHash, txId, chainId, typeInfo, account, analytics, onSubmit, onFailure } = @@ -71,20 +71,21 @@ export function* submitUniswapXOrder(params: SubmitUniswapXOrderParams) { if (Date.now() - waitStartTime > ORDER_STALENESS_THRESHOLD) { yield* put(transactionActions.updateTransaction({ ...order, queueStatus: QueuedOrderStatus.Stale })) + yield* call(onFailure) return } if (payload.hash === approveTxHash) { if (payload.status !== TransactionStatus.Success) { yield* put(transactionActions.updateTransaction({ ...order, queueStatus: QueuedOrderStatus.ApprovalFailed })) - onFailure() + yield* call(onFailure) return } waitingForApproval = false } else if (payload.hash === wrapTxHash) { if (payload.status !== TransactionStatus.Success) { yield* put(transactionActions.updateTransaction({ ...order, queueStatus: QueuedOrderStatus.WrapFailed })) - onFailure() + yield* call(onFailure) return } waitingForWrap = false @@ -100,12 +101,13 @@ export function* submitUniswapXOrder(params: SubmitUniswapXOrderParams) { // In the rare event that submission fails, we update the order status to prompt the user. // If the app is closed before this catch block is reached, orderWatcherSaga will handle the failure upon reopening. yield* put(transactionActions.updateTransaction({ ...order, queueStatus: QueuedOrderStatus.SubmissionFailed })) - onFailure() + yield* call(onFailure) return } const properties = { routing: order.routing, order_hash: orderHash, ...analytics } yield* call(sendAnalyticsEvent, WalletEventName.SwapSubmitted, properties) - onSubmit() + // onSubmit does not need to be wrapped in yield* call() here, but doing so makes it easier to test call ordering in submitOrder.test.ts + yield* call(onSubmit) } diff --git a/packages/wallet/src/features/transactions/swap/swapSaga.test.ts b/packages/wallet/src/features/transactions/swap/swapSaga.test.ts index e28b93e39af..df7b247288c 100644 --- a/packages/wallet/src/features/transactions/swap/swapSaga.test.ts +++ b/packages/wallet/src/features/transactions/swap/swapSaga.test.ts @@ -1,24 +1,36 @@ import { MaxUint256 } from '@ethersproject/constants' import { call, select } from '@redux-saga/core/effects' +import { permit2Address } from '@uniswap/permit2-sdk' import { Protocol } from '@uniswap/router-sdk' import { TradeType } from '@uniswap/sdk-core' import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' -import { expectSaga } from 'redux-saga-test-plan' +import JSBI from 'jsbi' +import { expectSaga, testSaga } from 'redux-saga-test-plan' import { EffectProviders, StaticProvider } from 'redux-saga-test-plan/providers' -import { DAI } from 'uniswap/src/constants/tokens' +import { DAI, USDC } from 'uniswap/src/constants/tokens' +import { OrderRequest, Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' import { UniverseChainId } from 'uniswap/src/types/chains' import { currencyId } from 'uniswap/src/utils/currencyId' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { sendTransaction } from 'wallet/src/features/transactions/sendTransactionSaga' +import { SendTransactionParams, sendTransaction } from 'wallet/src/features/transactions/sendTransactionSaga' import { getBaseTradeAnalyticsProperties } from 'wallet/src/features/transactions/swap/analytics' -import { SwapParams, approveAndSwap } from 'wallet/src/features/transactions/swap/swapSaga' -import { ClassicTrade } from 'wallet/src/features/transactions/swap/trade/types' -import { ExactInputSwapTransactionInfo, TransactionType } from 'wallet/src/features/transactions/types' +import { SubmitUniswapXOrderParams, submitUniswapXOrder } from 'wallet/src/features/transactions/swap/submitOrderSaga' +import { + SwapParams, + approveAndSwap, + getNonceForApproveAndSwap, + shouldSubmitViaPrivateRpc, +} from 'wallet/src/features/transactions/swap/swapSaga' +import { ClassicTrade, UniswapXTrade } from 'wallet/src/features/transactions/swap/trade/types' +import { + ExactInputSwapTransactionInfo, + TransactionType, + TransactionTypeInfo, +} from 'wallet/src/features/transactions/types' import { getProvider } from 'wallet/src/features/wallet/context' import { selectWalletSwapProtectionSetting } from 'wallet/src/features/wallet/selectors' import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' -import { signerMnemonicAccount } from 'wallet/src/test/fixtures' +import { WETH, signerMnemonicAccount } from 'wallet/src/test/fixtures' import { getTxProvidersMocks } from 'wallet/src/test/mocks' const account = signerMnemonicAccount() @@ -28,7 +40,7 @@ const universalRouterAddress = UNIVERSAL_ROUTER_ADDRESS(CHAIN_ID) const { mockProvider } = getTxProvidersMocks() -const transactionTypeInfo: ExactInputSwapTransactionInfo = { +const mockTransactionTypeInfo: ExactInputSwapTransactionInfo = { type: TransactionType.Swap, tradeType: TradeType.EXACT_INPUT, inputCurrencyId: currencyId(NativeCurrency.onChain(CHAIN_ID)), @@ -39,24 +51,48 @@ const transactionTypeInfo: ExactInputSwapTransactionInfo = { protocol: Protocol.V3, } +jest.mock('wallet/src/features/transactions/swap/utils', () => { + return { + tradeToTransactionInfo: (): TransactionTypeInfo => mockTransactionTypeInfo, + } +}) + +// TODO(WEB-4499): Use Trade/Quote fixtures instead of casted objects const mockTrade = { + routing: Routing.CLASSIC, inputAmount: { currency: new NativeCurrency(CHAIN_ID) }, + outputAmount: { currency: USDC }, quote: { amount: MaxUint256 }, + slippageTolerance: 0.5, } as unknown as ClassicTrade +const mockUniswapXTrade = { + routing: Routing.DUTCH_V2, + inputAmount: { currency: new NativeCurrency(CHAIN_ID), quotient: JSBI.BigInt(1000) }, + outputAmount: { currency: USDC }, + quote: { amount: MaxUint256 }, + slippageTolerance: 0.5, +} as unknown as UniswapXTrade + const mockApproveTxRequest = { chainId: 1, to: DAI.address, data: '0x0', } +const mockWrapTxRequest = { + chainId: 1, + to: WETH.address, + data: '0x0', +} + const mockSwapTxRequest = { chainId: 1, to: universalRouterAddress, data: '0x0', } -const swapParams: SwapParams = { +const classicSwapParams = { txId: '1', account, analytics: {} as ReturnType<typeof getBaseTradeAnalyticsProperties>, @@ -70,56 +106,232 @@ const swapParams: SwapParams = { }, onSubmit: jest.fn(), onFailure: jest.fn(), -} +} satisfies SwapParams -const swapParamsWithoutApprove: SwapParams = { - ...swapParams, +const uniswapXSwapParams = { + txId: '1', + account, + analytics: {} as ReturnType<typeof getBaseTradeAnalyticsProperties>, swapTxContext: { - ...swapParams.swapTxContext, - approveTxRequest: undefined, + routing: Routing.DUTCH_V2, + approveTxRequest: mockApproveTxRequest, + trade: mockUniswapXTrade, + orderParams: { quote: { orderId: '0xMockOrderHash' } } as unknown as OrderRequest, + wrapTxRequest: undefined, + gasFee: { value: '5', loading: false, error: undefined }, + gasFeeBreakdown: { classicGasUseEstimateUSD: '5', approvalCost: '5', wrapCost: '0' }, + approvalError: false, }, -} + onSubmit: jest.fn(), + onFailure: jest.fn(), +} satisfies SwapParams const nonce = 1 -// TODO(WEB-4294): The saga runs in these tests are not actually finishing as `getNonceForApproveAndSwap` fails; update to work correctly +const expectedSendApprovalParams: SendTransactionParams = { + chainId: mockApproveTxRequest.chainId, + account, + options: { request: mockApproveTxRequest, submitViaPrivateRpc: false }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: mockApproveTxRequest.to, + spender: permit2Address(mockApproveTxRequest.chainId), + swapTxId: '1', + }, + analytics: {}, +} + describe(approveAndSwap, () => { const sharedProviders: (EffectProviders | StaticProvider)[] = [ [select(selectWalletSwapProtectionSetting), SwapProtectionSetting.Off], [call(getProvider, mockSwapTxRequest.chainId), mockProvider], - [ - call(sendTransaction, { - chainId: mockSwapTxRequest.chainId, - account: swapParams.account, - options: { request: mockApproveTxRequest }, - typeInfo: transactionTypeInfo, - analytics: swapParams.analytics, - }), - undefined, - ], + [call(getNonceForApproveAndSwap, classicSwapParams.account.address, mockSwapTxRequest.chainId, false), nonce], ] it('sends a swap tx', async () => { - await expectSaga(approveAndSwap, swapParamsWithoutApprove).provide(sharedProviders).silentRun() + const classicSwapParamsWithoutApprove = { + ...classicSwapParams, + swapTxContext: { + ...classicSwapParams.swapTxContext, + approveTxRequest: undefined, + }, + } satisfies SwapParams + + const expectedSendSwapParams: SendTransactionParams = { + chainId: classicSwapParamsWithoutApprove.swapTxContext.txRequest.chainId, + account: classicSwapParamsWithoutApprove.account, + options: { request: { ...mockSwapTxRequest, nonce }, submitViaPrivateRpc: false }, + typeInfo: mockTransactionTypeInfo, + analytics: classicSwapParamsWithoutApprove.analytics, + txId: classicSwapParamsWithoutApprove.txId, + } + + // `expectSaga` tests the entire saga at once w/out manually specifying all effect return values. + // It does not ensure proper ordering; this is tested by testSaga below. + await expectSaga(approveAndSwap, classicSwapParamsWithoutApprove) + .provide([ + ...sharedProviders, + [ + call(sendTransaction, expectedSendSwapParams), + { transactionResponse: { hash: '0xMockSwapTxHash' }, populatedRequest: {} }, + ], + ]) + .call(sendTransaction, expectedSendSwapParams) + .silentRun() + + // `testSaga` ensures that the saga yields specific types of effects in a particular order. + // Requires manually providing return values for each effect in `.next()`. + testSaga(approveAndSwap, classicSwapParamsWithoutApprove) + .next() + .call(classicSwapParams.onSubmit) + .next() + .call(shouldSubmitViaPrivateRpc, classicSwapParams.swapTxContext.txRequest.chainId) + .next(false) + .call(getNonceForApproveAndSwap, classicSwapParams.account.address, mockSwapTxRequest.chainId, false) + .next(nonce) + .call(sendTransaction, expectedSendSwapParams) + .next({ transactionResponse: { hash: '0xMockSwapTxHash' }, populatedRequest: {} }) + .isDone() }) it('sends a swap tx with incremented nonce if an approve tx is sent first', async () => { - await expectSaga(approveAndSwap, swapParams) + const expectedSendSwapParams: SendTransactionParams = { + chainId: classicSwapParams.swapTxContext.txRequest.chainId, + account: classicSwapParams.account, + options: { request: { ...mockSwapTxRequest, nonce: nonce + 1 }, submitViaPrivateRpc: false }, + typeInfo: mockTransactionTypeInfo, + analytics: classicSwapParams.analytics, + txId: classicSwapParams.txId, + } + + await expectSaga(approveAndSwap, classicSwapParams) .provide([ ...sharedProviders, [ - call(sendTransaction, { - chainId: mockTrade.inputAmount.currency.chainId, - account: swapParams.account, - options: { - request: { ...mockSwapTxRequest, nonce: nonce + 1 }, - }, - typeInfo: transactionTypeInfo, - analytics: swapParams.analytics, - }), - undefined, + call(sendTransaction, expectedSendApprovalParams), + { transactionResponse: { hash: '0xMockApprovalTxHash' }, populatedRequest: {} }, + ], + [ + call(sendTransaction, expectedSendSwapParams), + { transactionResponse: { hash: '0xMockSwapTxHash' }, populatedRequest: {} }, ], ]) + .call(sendTransaction, expectedSendSwapParams) .silentRun() + + testSaga(approveAndSwap, classicSwapParams) + .next() + .call(classicSwapParams.onSubmit) + .next() + .call(shouldSubmitViaPrivateRpc, classicSwapParams.swapTxContext.txRequest.chainId) + .next(false) + .call(getNonceForApproveAndSwap, classicSwapParams.account.address, mockSwapTxRequest.chainId, false) + .next(nonce) + .call(sendTransaction, expectedSendApprovalParams) + .next({ transactionResponse: { hash: '0xMockApprovalTxHash' }, populatedRequest: {} }) + .call(sendTransaction, expectedSendSwapParams) + .next({ transactionResponse: { hash: '0xMockSwapTxHash' }, populatedRequest: {} }) + .isDone() + }) + + it('sends a uniswapx order', async () => { + const expectedSubmitOrderParams: SubmitUniswapXOrderParams = { + chainId: uniswapXSwapParams.swapTxContext.trade.inputAmount.currency.chainId, + account: uniswapXSwapParams.account, + typeInfo: mockTransactionTypeInfo, + analytics: uniswapXSwapParams.analytics, + approveTxHash: '0xMockApprovalTxHash', + wrapTxHash: undefined, + txId: uniswapXSwapParams.txId, + orderParams: uniswapXSwapParams.swapTxContext.orderParams, + onSubmit: uniswapXSwapParams.onSubmit, + onFailure: uniswapXSwapParams.onFailure, + } + + await expectSaga(approveAndSwap, uniswapXSwapParams) + .provide([ + ...sharedProviders, + [ + call(sendTransaction, expectedSendApprovalParams), + { transactionResponse: { hash: '0xMockApprovalTxHash' }, populatedRequest: {} }, + ], + [call(submitUniswapXOrder, expectedSubmitOrderParams), undefined], + ]) + .call.fn(submitUniswapXOrder) + .silentRun() + + testSaga(approveAndSwap, uniswapXSwapParams) + .next() + .call(getNonceForApproveAndSwap, classicSwapParams.account.address, mockSwapTxRequest.chainId, false) + .next(nonce) + .call(sendTransaction, expectedSendApprovalParams) + .next({ transactionResponse: { hash: '0xMockApprovalTxHash' }, populatedRequest: {} }) + .call(submitUniswapXOrder, expectedSubmitOrderParams) + .next() + .isDone() + }) + + it('sends an ETH-input uniswapx order', async () => { + const uniswapXSwapEthInputParams = { + ...uniswapXSwapParams, + swapTxContext: { + ...uniswapXSwapParams.swapTxContext, + wrapTxRequest: mockWrapTxRequest, + }, + } satisfies SwapParams + + const expectedSendWrapParams: SendTransactionParams = { + chainId: mockWrapTxRequest.chainId, + account, + options: { request: { ...mockWrapTxRequest, nonce: nonce + 1 } }, + typeInfo: { + type: TransactionType.Wrap, + unwrapped: false, + currencyAmountRaw: '1000', + swapTxId: '1', + }, + txId: undefined, + } + + const expectedSubmitOrderParams: SubmitUniswapXOrderParams = { + chainId: uniswapXSwapParams.swapTxContext.trade.inputAmount.currency.chainId, + account: uniswapXSwapParams.account, + typeInfo: mockTransactionTypeInfo, + analytics: uniswapXSwapParams.analytics, + approveTxHash: '0xMockApprovalTxHash', + wrapTxHash: '0xMockWrapTxHash', + txId: uniswapXSwapParams.txId, + orderParams: uniswapXSwapParams.swapTxContext.orderParams, + onSubmit: uniswapXSwapParams.onSubmit, + onFailure: uniswapXSwapParams.onFailure, + } + + await expectSaga(approveAndSwap, uniswapXSwapEthInputParams) + .provide([ + ...sharedProviders, + [ + call(sendTransaction, expectedSendApprovalParams), + { transactionResponse: { hash: '0xMockApprovalTxHash' }, populatedRequest: {} }, + ], + [ + call(sendTransaction, expectedSendWrapParams), + { transactionResponse: { hash: '0xMockWrapTxHash' }, populatedRequest: {} }, + ], + [call(submitUniswapXOrder, expectedSubmitOrderParams), undefined], + ]) + .call.fn(submitUniswapXOrder) + .silentRun() + + testSaga(approveAndSwap, uniswapXSwapEthInputParams) + .next() + .call(getNonceForApproveAndSwap, classicSwapParams.account.address, mockSwapTxRequest.chainId, false) + .next(nonce) + .call(sendTransaction, expectedSendApprovalParams) + .next({ transactionResponse: { hash: '0xMockApprovalTxHash' }, populatedRequest: {} }) + .call(sendTransaction, expectedSendWrapParams) + .next({ transactionResponse: { hash: '0xMockWrapTxHash' }, populatedRequest: {} }) + .call(submitUniswapXOrder, expectedSubmitOrderParams) + .next() + .isDone() }) }) diff --git a/packages/wallet/src/features/transactions/swap/swapSaga.ts b/packages/wallet/src/features/transactions/swap/swapSaga.ts index a6b9d774ba0..a9953c27067 100644 --- a/packages/wallet/src/features/transactions/swap/swapSaga.ts +++ b/packages/wallet/src/features/transactions/swap/swapSaga.ts @@ -1,10 +1,10 @@ import { permit2Address } from '@uniswap/permit2-sdk' import { call, select } from 'typed-redux-saga' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' import { RPCType } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { isPrivateRpcSupportedOnChain } from 'wallet/src/features/providers' import { ValidatedSwapTxContext } from 'wallet/src/features/transactions/contexts/SwapTxContext' import { makeSelectAddressTransactions } from 'wallet/src/features/transactions/selectors' @@ -39,15 +39,16 @@ export function* approveAndSwap(params: SwapParams) { const chainId = swapTxContext.trade.inputAmount.currency.chainId - // MEV protection is not needed for UniswapX approval and/or wrap transactions. - const submitViaPrivateRpc = !isUniswapX && (yield* call(shouldSubmitViaPrivateRpc, chainId)) - let nonce = yield* call(getNonceForApproveAndSwap, address, chainId, submitViaPrivateRpc) - // For classic swaps, trigger UI changes immediately after click if (!isUniswapX) { - onSubmit() + // onSubmit does not need to be wrapped in yield* call() here, but doing so makes it easier to test call ordering in swapSaga.test.ts + yield* call(onSubmit) } + // MEV protection is not needed for UniswapX approval and/or wrap transactions. + const submitViaPrivateRpc = !isUniswapX && (yield* call(shouldSubmitViaPrivateRpc, chainId)) + let nonce = yield* call(getNonceForApproveAndSwap, address, chainId, submitViaPrivateRpc) + let approveTxHash: string | undefined // Approval Logic if (approveTxRequest) { @@ -65,7 +66,10 @@ export function* approveAndSwap(params: SwapParams) { nonce++ } - const typeInfo = tradeToTransactionInfo(swapTxContext.trade) + // Default to input for USD volume amount + const transactedUSDValue = analytics.token_in_amount_usd + + const typeInfo = tradeToTransactionInfo(swapTxContext.trade, transactedUSDValue) // Swap Logic - UniswapX if (isUniswapX) { const { orderParams, wrapTxRequest } = swapTxContext @@ -120,7 +124,7 @@ export const { actions: swapActions, } = createMonitoredSaga<SwapParams>(approveAndSwap, 'swap') -function* getNonceForApproveAndSwap(address: Address, chainId: number, submitViaPrivateRpc: boolean) { +export function* getNonceForApproveAndSwap(address: Address, chainId: number, submitViaPrivateRpc: boolean) { const rpcType = submitViaPrivateRpc ? RPCType.Private : RPCType.Public const provider = yield* call(getProvider, chainId, rpcType) const nonce = yield* call([provider, provider.getTransactionCount], address, 'pending') @@ -135,7 +139,7 @@ function* getNonceForApproveAndSwap(address: Address, chainId: number, submitVia return nonce } -function* shouldSubmitViaPrivateRpc(chainId: number) { +export function* shouldSubmitViaPrivateRpc(chainId: number) { const swapProtectionSetting = yield* select(selectWalletSwapProtectionSetting) const swapProtectionOn = swapProtectionSetting === SwapProtectionSetting.On const mevBlockerFeatureEnabled = Statsig.checkGate(getFeatureFlagName(FeatureFlags.MevBlocker)) diff --git a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useSwapTxAndGasInfo.ts b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useSwapTxAndGasInfo.ts index 03bd3210b43..6fdbeedf849 100644 --- a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useSwapTxAndGasInfo.ts +++ b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useSwapTxAndGasInfo.ts @@ -1,7 +1,7 @@ import { providers } from 'ethers' import { useMemo } from 'react' +import { OrderRequest, Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' -import { OrderRequest, Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { GasFeeResult } from 'wallet/src/features/gas/types' import { useTokenApprovalInfo } from 'wallet/src/features/transactions/swap/trade/api/hooks/useTokenApprovalInfo' import { useTransactionRequestInfo } from 'wallet/src/features/transactions/swap/trade/api/hooks/useTransactionRequestInfo' @@ -13,7 +13,7 @@ import { sumGasFees } from 'wallet/src/features/transactions/swap/utils' export type SwapTxAndGasInfo = ClassicSwapTxAndGasInfo | UniswapXSwapTxAndGasInfo export type UniswapXGasBreakdown = { - classicGasUseEstimateUSD?: number + classicGasUseEstimateUSD?: string approvalCost?: string wrapCost?: string inputTokenSymbol?: string @@ -39,7 +39,7 @@ export type UniswapXSwapTxAndGasInfo = { approvalError: boolean } -type ValidatedTransactionRequest = providers.TransactionRequest & { to: string; chainId: number } +export type ValidatedTransactionRequest = providers.TransactionRequest & { to: string; chainId: number } function validateTransactionRequest( request?: providers.TransactionRequest | null, ): ValidatedTransactionRequest | undefined { @@ -100,7 +100,6 @@ export function useSwapTxAndGasInfo({ derivedSwapInfo }: { derivedSwapInfo: Deri const signature = swapTxInfo.permitSignature const orderParams = signature ? { signature, quote: trade.quote.quote, routing: Routing.DUTCH_V2 } : undefined const gasFeeBreakdown: UniswapXGasBreakdown = { - // TODO(API-324): next version of trading api schema will break the following line; update the type's field to be a string instead classicGasUseEstimateUSD: trade.quote.quote.classicGasUseEstimateUSD, approvalCost: tokenApprovalInfo?.gasFee, wrapCost: swapTxInfo.gasFeeResult.value, diff --git a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTokenApprovalInfo.ts b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTokenApprovalInfo.ts index dd9f10a482e..78253ce2ad4 100644 --- a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTokenApprovalInfo.ts +++ b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTokenApprovalInfo.ts @@ -2,10 +2,10 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { useMemo } from 'react' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useRestQuery } from 'uniswap/src/data/rest' +import { ApprovalRequest, ApprovalResponse, Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { ONE_MINUTE_MS } from 'utilities/src/time/time' -import { ApprovalRequest, ApprovalResponse, Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { TradingApiApolloClient } from 'wallet/src/features/transactions/swap/trade/api/client' import { getTokenAddressForApi, diff --git a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTrade.ts b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTrade.ts index 57fc8ae9a44..33a079aae18 100644 --- a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTrade.ts +++ b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTrade.ts @@ -3,6 +3,7 @@ import { TradeType } from '@uniswap/sdk-core' import { useMemo } from 'react' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useRestQuery } from 'uniswap/src/data/rest' +import { QuoteRequest, TradeType as TradingApiTradeType } from 'uniswap/src/data/tradingApi/__generated__/index' import { isMainnetChainId } from 'uniswap/src/features/chains/utils' import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' import { FeatureFlags } from 'uniswap/src/features/gating/flags' @@ -11,9 +12,9 @@ import { CurrencyField } from 'uniswap/src/features/transactions/transactionStat import { WalletChainId } from 'uniswap/src/types/chains' import { areCurrencyIdsEqual, currencyId } from 'uniswap/src/utils/currencyId' import { logger } from 'utilities/src/logger/logger' +import { isMobile } from 'utilities/src/platform' import { ONE_SECOND_MS, inXMinutesUnix } from 'utilities/src/time/time' import { useDebounceWithStatus } from 'utilities/src/time/timing' -import { QuoteRequest, TradeType as TradingApiTradeType } from 'wallet/src/data/tradingApi/__generated__/index' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { TradingApiApolloClient } from 'wallet/src/features/transactions/swap/trade/api/client' import { @@ -161,7 +162,8 @@ export function useTrade(args: UseTradeArgs): TradeWithStatus { return useMemo(() => { // Error logging - if (error && !isUSDQuote) { + // We use DataDog to catch network errors on Mobile + if (error && (!isMobile || !error.networkError) && !isUSDQuote) { logger.error(error, { tags: { file: 'useTrade', function: 'quote' } }) } if (data && !data.quote) { diff --git a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTransactionRequestInfo.ts b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTransactionRequestInfo.ts index 2282eae9a1b..7c7608eb6a6 100644 --- a/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTransactionRequestInfo.ts +++ b/packages/wallet/src/features/transactions/swap/trade/api/hooks/useTransactionRequestInfo.ts @@ -3,6 +3,11 @@ import { providers } from 'ethers' import { useEffect, useMemo, useRef } from 'react' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useRestQuery } from 'uniswap/src/data/rest' +import { + CreateSwapRequest, + CreateSwapResponse, + TransactionFailureReason, +} from 'uniswap/src/data/tradingApi/__generated__/index' import { DynamicConfigs, SwapConfigKey } from 'uniswap/src/features/gating/configs' import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -10,11 +15,6 @@ import { CurrencyField } from 'uniswap/src/features/transactions/transactionStat import { isDetoxBuild } from 'utilities/src/environment/constants' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' -import { - CreateSwapRequest, - CreateSwapResponse, - TransactionFailureReason, -} from 'wallet/src/data/tradingApi/__generated__/index' import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -173,11 +173,8 @@ export function useTransactionRequestInfo({ } if (gasEstimateError) { - logger.error(gasEstimateError, { - tags: { file: 'useTransactionRequestInfo', function: 'useTransactionRequestInfo' }, - extra: { - swapRequestArgs, - }, + logger.warn('useTransactionRequestInfo', 'useTransactionRequestInfo', UNKNOWN_SIM_ERROR, { + ...getBaseTradeAnalyticsPropertiesFromSwapInfo({ derivedSwapInfo, formatter }), }) sendAnalyticsEvent(SwapEventName.SWAP_ESTIMATE_GAS_CALL_FAILED, { diff --git a/packages/wallet/src/features/transactions/swap/trade/api/utils.ts b/packages/wallet/src/features/transactions/swap/trade/api/utils.ts index 420957590b5..c1927589605 100644 --- a/packages/wallet/src/features/transactions/swap/trade/api/utils.ts +++ b/packages/wallet/src/features/transactions/swap/trade/api/utils.ts @@ -4,12 +4,6 @@ import { UnsignedV2DutchOrderInfo } from '@uniswap/uniswapx-sdk' import { Pair, Route as V2Route } from '@uniswap/v2-sdk' import { FeeAmount, Pool, Route as V3Route } from '@uniswap/v3-sdk' import { BigNumber } from 'ethers' -import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' -import { CurrencyField, TradeProtocolPreference } from 'uniswap/src/features/transactions/transactionState/types' -import { areAddressesEqual } from 'uniswap/src/utils/addresses' -import { currencyId } from 'uniswap/src/utils/currencyId' -import { logger } from 'utilities/src/logger/logger' -import { MAX_AUTO_SLIPPAGE_TOLERANCE } from 'wallet/src/constants/transactions' import { ClassicQuote, DutchOrderInfoV2, @@ -22,7 +16,13 @@ import { TokenInRoute as TradingApiTokenInRoute, V2PoolInRoute as TradingApiV2PoolInRoute, V3PoolInRoute as TradingApiV3PoolInRoute, -} from 'wallet/src/data/tradingApi/__generated__/index' +} from 'uniswap/src/data/tradingApi/__generated__/index' +import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' +import { CurrencyField, TradeProtocolPreference } from 'uniswap/src/features/transactions/transactionState/types' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' +import { currencyId } from 'uniswap/src/utils/currencyId' +import { logger } from 'utilities/src/logger/logger' +import { MAX_AUTO_SLIPPAGE_TOLERANCE } from 'wallet/src/constants/transactions' import { LocalizationContextState } from 'wallet/src/features/language/LocalizationContext' import { getBaseTradeAnalyticsProperties } from 'wallet/src/features/transactions/swap/analytics' import { diff --git a/packages/wallet/src/features/transactions/swap/trade/hooks/useDerivedSwapInfo.ts b/packages/wallet/src/features/transactions/swap/trade/hooks/useDerivedSwapInfo.ts index d75f7ee985a..21e873f075c 100644 --- a/packages/wallet/src/features/transactions/swap/trade/hooks/useDerivedSwapInfo.ts +++ b/packages/wallet/src/features/transactions/swap/trade/hooks/useDerivedSwapInfo.ts @@ -72,7 +72,6 @@ export function useDerivedSwapInfo(state: TransactionState): DerivedSwapInfo { const shouldGetQuote = !isWrapAction(wrapType) const sendPortionEnabled = useFeatureFlag(FeatureFlags.PortionFields) - const isOptionalRoutingEnabled = useFeatureFlag(FeatureFlags.OptionalRouting) const tradeParams = { amountSpecified: shouldGetQuote ? amountSpecified : null, @@ -80,8 +79,7 @@ export function useDerivedSwapInfo(state: TransactionState): DerivedSwapInfo { tradeType: isExactIn ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT, customSlippageTolerance, sendPortionEnabled, - // Only pass custom routing preference if feature is enabled - tradeProtocolPreference: isOptionalRoutingEnabled ? tradeProtocolPreference : undefined, + tradeProtocolPreference, } const tradeTradeWithoutSlippage = useTrade(tradeParams) diff --git a/packages/wallet/src/features/transactions/swap/trade/hooks/useSwapCallback.ts b/packages/wallet/src/features/transactions/swap/trade/hooks/useSwapCallback.ts index 43bd5e5c53b..8dc19cd9e7a 100644 --- a/packages/wallet/src/features/transactions/swap/trade/hooks/useSwapCallback.ts +++ b/packages/wallet/src/features/transactions/swap/trade/hooks/useSwapCallback.ts @@ -1,7 +1,7 @@ import { SwapEventName } from '@uniswap/analytics-events' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { useCallback } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { setHasSubmittedHoldToSwap } from 'wallet/src/features/behaviorHistory/slice' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -13,7 +13,6 @@ import { swapActions } from 'wallet/src/features/transactions/swap/swapSaga' import { getClassicQuoteFromResponse } from 'wallet/src/features/transactions/swap/trade/api/utils' import { isClassic } from 'wallet/src/features/transactions/swap/trade/utils' import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' -import { useAppSelector } from 'wallet/src/state' import { toStringish } from 'wallet/src/utils/number' interface SwapCallbackArgs { @@ -33,7 +32,7 @@ interface SwapCallbackArgs { export function useSwapCallback(): (args: SwapCallbackArgs) => void { const appDispatch = useDispatch() const formatter = useLocalizationContext() - const swapStartTimestamp = useAppSelector(selectSwapStartTimestamp) + const swapStartTimestamp = useSelector(selectSwapStartTimestamp) return useCallback( (args: SwapCallbackArgs) => { @@ -51,7 +50,7 @@ export function useSwapCallback(): (args: SwapCallbackArgs) => void { } = args const { trade, gasFee } = swapTxContext - const analytics = getBaseTradeAnalyticsProperties({ formatter, trade }) + const analytics = getBaseTradeAnalyticsProperties({ formatter, trade, currencyInAmountUSD, currencyOutAmountUSD }) appDispatch(swapActions.trigger({ swapTxContext, txId, account, analytics, onSubmit, onFailure })) const blockNumber = getClassicQuoteFromResponse(trade?.quote)?.blockNumber?.toString() @@ -60,8 +59,6 @@ export function useSwapCallback(): (args: SwapCallbackArgs) => void { ...analytics, estimated_network_fee_wei: gasFee.value, gas_limit: isClassic(swapTxContext) ? toStringish(swapTxContext.txRequest.gasLimit) : undefined, - token_in_amount_usd: currencyInAmountUSD ? parseFloat(currencyInAmountUSD.toFixed(2)) : undefined, - token_out_amount_usd: currencyOutAmountUSD ? parseFloat(currencyOutAmountUSD.toFixed(2)) : undefined, transaction_deadline_seconds: trade.deadline, swap_quote_block_number: blockNumber, is_auto_slippage: isAutoSlippage, diff --git a/packages/wallet/src/features/transactions/swap/trade/types.ts b/packages/wallet/src/features/transactions/swap/trade/types.ts index 8b27b52a038..36018b2cdc9 100644 --- a/packages/wallet/src/features/transactions/swap/trade/types.ts +++ b/packages/wallet/src/features/transactions/swap/trade/types.ts @@ -7,8 +7,8 @@ import { Route as V2RouteSDK } from '@uniswap/v2-sdk' import { Route as V3RouteSDK } from '@uniswap/v3-sdk' import { providers } from 'ethers' import { PollingInterval } from 'uniswap/src/constants/misc' +import { ClassicQuote, DutchQuoteV2, QuoteResponse, Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { TradeProtocolPreference } from 'uniswap/src/features/transactions/transactionState/types' -import { ClassicQuote, DutchQuoteV2, QuoteResponse, Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { getSwapFee, transformToDutchOrderInfo } from 'wallet/src/features/transactions/swap/trade/api/utils' // TradingAPI team is looking into updating type generation to produce the following types for it's current QuoteResponse type: diff --git a/packages/wallet/src/features/transactions/swap/trade/utils.ts b/packages/wallet/src/features/transactions/swap/trade/utils.ts index a566e17d657..6adc9155908 100644 --- a/packages/wallet/src/features/transactions/swap/trade/utils.ts +++ b/packages/wallet/src/features/transactions/swap/trade/utils.ts @@ -1,4 +1,4 @@ -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' export function isUniswapX<T extends { routing: Routing }>( obj: T, diff --git a/packages/wallet/src/features/transactions/swap/usePermit2Signature.ts b/packages/wallet/src/features/transactions/swap/usePermit2Signature.ts index 4f4aaed3f89..4b5451ff4af 100644 --- a/packages/wallet/src/features/transactions/swap/usePermit2Signature.ts +++ b/packages/wallet/src/features/transactions/swap/usePermit2Signature.ts @@ -4,11 +4,11 @@ import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' import dayjs from 'dayjs' import { BigNumber, providers, TypedDataField } from 'ethers' import { useCallback } from 'react' +import { Permit } from 'uniswap/src/data/tradingApi/__generated__/index' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { useAsyncData } from 'utilities/src/react/hooks' import { currentTimeInSeconds, inXMinutesUnix } from 'utilities/src/time/time' -import { Permit } from 'wallet/src/data/tradingApi/__generated__/index' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' import { useProvider, useWalletSigners } from 'wallet/src/features/wallet/context' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' diff --git a/packages/wallet/src/features/transactions/swap/utils.ts b/packages/wallet/src/features/transactions/swap/utils.ts index de391ffcf76..6b03c92aa9a 100644 --- a/packages/wallet/src/features/transactions/swap/utils.ts +++ b/packages/wallet/src/features/transactions/swap/utils.ts @@ -66,7 +66,10 @@ export function isWrapAction(wrapType: WrapType): wrapType is WrapType.Unwrap | return wrapType === WrapType.Unwrap || wrapType === WrapType.Wrap } -export function tradeToTransactionInfo(trade: Trade): ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo { +export function tradeToTransactionInfo( + trade: Trade, + transactedUSDValue?: number, +): ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo { const slippageTolerancePercent = slippageToleranceToPercent(trade.slippageTolerance) const { quote, slippageTolerance } = trade @@ -84,6 +87,7 @@ export function tradeToTransactionInfo(trade: Trade): ExactInputSwapTransactionI gasUseEstimate, routeString, protocol: getProtocolVersionFromTrade(trade), + transactedUSDValue, } return trade.tradeType === TradeType.EXACT_INPUT diff --git a/packages/wallet/src/features/transactions/transactionWatcherSaga.test.ts b/packages/wallet/src/features/transactions/transactionWatcherSaga.test.ts index 2fc88d96ece..6e66a98e26d 100644 --- a/packages/wallet/src/features/transactions/transactionWatcherSaga.test.ts +++ b/packages/wallet/src/features/transactions/transactionWatcherSaga.test.ts @@ -55,9 +55,6 @@ describe(transactionWatcher, () => { return expectSaga(transactionWatcher, { apolloClient: mockApolloClient }) .withState({ - behaviorHistory: { - extensionBetaFeedbackState: undefined, - }, transactions: { byChainId: { [UniverseChainId.Mainnet]: { diff --git a/packages/wallet/src/features/transactions/transactionWatcherSaga.ts b/packages/wallet/src/features/transactions/transactionWatcherSaga.ts index 92dcb3fe420..018eeddb7e5 100644 --- a/packages/wallet/src/features/transactions/transactionWatcherSaga.ts +++ b/packages/wallet/src/features/transactions/transactionWatcherSaga.ts @@ -1,13 +1,9 @@ -/* eslint-disable max-lines */ import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { SwapEventName } from '@uniswap/analytics-events' import { TradeType } from '@uniswap/sdk-core' import { BigNumberish, providers } from 'ethers' -import { call, delay, fork, put, race, take, takeEvery } from 'typed-redux-saga' -import { isWeb } from 'ui/src' +import { call, delay, fork, put, race, select, take, takeEvery } from 'typed-redux-saga' import { PollingInterval } from 'uniswap/src/constants/misc' -import { FeatureFlags, getFeatureFlagName } from 'uniswap/src/features/gating/flags' -import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' import { FiatOnRampEventName, InstitutionTransferEventName, @@ -18,8 +14,6 @@ import { sendAnalyticsEvent, sendAppsFlyerEvent } from 'uniswap/src/features/tel import i18n from 'uniswap/src/i18n/i18n' import { WalletChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' -import { selectExtensionBetaFeedbackState } from 'wallet/src/features/behaviorHistory/selectors' -import { ExtensionBetaFeedbackState, setExtensionBetaFeedbackState } from 'wallet/src/features/behaviorHistory/slice' import { fetchFiatOnRampTransaction } from 'wallet/src/features/fiatOnRamp/api' import { FiatOnRampTransactionDetails } from 'wallet/src/features/fiatOnRamp/types' import { pushNotification, setNotificationStatus } from 'wallet/src/features/notifications/slice' @@ -51,18 +45,16 @@ import { } from 'wallet/src/features/transactions/types' import { getFinalizedTransactionStatus, receiptFromEthersReceipt } from 'wallet/src/features/transactions/utils' import { getProvider } from 'wallet/src/features/wallet/context' -import { appSelect } from 'wallet/src/state' export function* transactionWatcher({ apolloClient }: { apolloClient: ApolloClient<NormalizedCacheObject> }) { logger.debug('transactionWatcherSaga', 'transactionWatcher', 'Starting tx watcher') - yield* fork(watchForFinalizedTransactions) // Start the order watcher to allow off-chain order updates to propagate to watchTransaction yield* fork(OrderWatcher.initialize) // First, fork off watchers for any incomplete txs that are already in store // This allows us to detect completions if a user closed the app before a tx finished - const incompleteTransactions = yield* appSelect(selectIncompleteTransactions) + const incompleteTransactions = yield* select(selectIncompleteTransactions) for (const transaction of incompleteTransactions) { if (transaction.typeInfo.type === TransactionType.FiatPurchase) { yield* fork(watchFiatOnRampTransaction, transaction as FiatOnRampTransactionDetails) @@ -443,46 +435,19 @@ export function logTransactionEvent(actionData: ReturnType<typeof transactionAct // Log metrics for confirmed transfers if (type === TransactionType.Send) { - const { tokenAddress, recipient: toAddress } = typeInfo as SendTokenTransactionInfo + const { tokenAddress, recipient: toAddress, currencyAmountUSD } = typeInfo as SendTokenTransactionInfo + + const amountUSD = currencyAmountUSD ? parseFloat(currencyAmountUSD?.toFixed(2)) : undefined + sendAnalyticsEvent(WalletEventName.TransferCompleted, { chainId, tokenAddress, toAddress, + amountUSD, }) } } -function* watchForFinalizedTransactions() { - const state = yield* appSelect(selectExtensionBetaFeedbackState) - const extensionBetaFeedbackPromptEnabled = Statsig.checkGate( - getFeatureFlagName(FeatureFlags.ExtensionBetaFeedbackPrompt), - ) - - if (isWeb && extensionBetaFeedbackPromptEnabled && state === undefined) { - yield* takeEvery(transactionActions.finalizeTransaction.type, maybeLaunchFeedbackModal) - } -} - -function* maybeLaunchFeedbackModal(actionData: ReturnType<typeof transactionActions.finalizeTransaction>) { - const { payload } = actionData - const { typeInfo, status } = payload - const { type } = typeInfo - const state = yield* appSelect(selectExtensionBetaFeedbackState) - const extensionBetaFeedbackPromptEnabled = Statsig.checkGate( - getFeatureFlagName(FeatureFlags.ExtensionBetaFeedbackPrompt), - ) - - if ( - extensionBetaFeedbackPromptEnabled && - status === TransactionStatus.Success && - [TransactionType.Swap, TransactionType.Send].includes(type) && - state === undefined - ) { - yield* delay(3000) - yield* put(setExtensionBetaFeedbackState(ExtensionBetaFeedbackState.ReadyToShow)) - } -} - export function* finalizeTransaction({ apolloClient, transaction, @@ -498,7 +463,7 @@ export function* finalizeTransaction({ yield* refetchGQLQueries({ transaction, apolloClient }) if (transaction.typeInfo.type === TransactionType.Swap) { - const hasDoneOneSwap = (yield* appSelect(selectSwapTransactionsCount)) === 1 + const hasDoneOneSwap = (yield* select(selectSwapTransactionsCount)) === 1 if (hasDoneOneSwap) { // Only log event if it's a user's first ever swap // TODO: Add $ amount to swap event once transaction type supports it diff --git a/packages/wallet/src/features/transactions/transfer/TokenSelectorPanel.tsx b/packages/wallet/src/features/transactions/transfer/TokenSelectorPanel.tsx index b2b222468bd..b59b7e0c1f7 100644 --- a/packages/wallet/src/features/transactions/transfer/TokenSelectorPanel.tsx +++ b/packages/wallet/src/features/transactions/transfer/TokenSelectorPanel.tsx @@ -1,30 +1,37 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { useTranslation } from 'react-i18next' import { Keyboard, LayoutAnimation } from 'react-native' +import { useSelector } from 'react-redux' import { Flex, Text, TouchableArea } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { TokenSelector, TokenSelectorVariation } from 'uniswap/src/components/TokenSelector/TokenSelector' +import { + useCommonTokensOptions, + useFilterCallbacks, + usePopularTokensOptions, + usePortfolioTokenOptions, + useTokenSectionsForSearchResults, +} from 'uniswap/src/components/TokenSelector/hooks' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { SearchContext } from 'uniswap/src/features/search/SearchContext' +import { TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' import { NumberType } from 'utilities/src/format/types' import { useAddToSearchHistory, - useCommonTokensOptions, useFavoriteTokensOptions, - useFilterCallbacks, - usePopularTokensOptions, - usePortfolioTokenOptions, useTokenSectionsForEmptySearch, - useTokenSectionsForSearchResults, } from 'wallet/src/components/TokenSelector/hooks' import { MaxAmountButton } from 'wallet/src/components/input/MaxAmountButton' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { usePortfolioValueModifiers } from 'wallet/src/features/dataApi/balances' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { selectSearchHistory } from 'wallet/src/features/search/selectSearchHistory' import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' +import { TransactionType } from 'wallet/src/features/transactions/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' interface TokenSelectorPanelProps { @@ -49,9 +56,12 @@ export function TokenSelectorPanel({ showTokenSelector, }: TokenSelectorPanelProps): JSX.Element { const { t } = useTranslation() + const address = useActiveAccountAddressWithThrow() + const valueModifiers = usePortfolioValueModifiers(address) const { formatNumberOrString, convertFiatAmountFormatted, formatCurrencyAmount } = useLocalizationContext() const { navigateToBuyOrReceiveWithEmptyWallet } = useWalletNavigation() const { registerSearch } = useAddToSearchHistory() + const searchHistory = useSelector(selectSearchHistory) const activeAccountAddress = useActiveAccountAddressWithThrow() @@ -73,6 +83,7 @@ export function TokenSelectorPanel({ formatNumberOrStringCallback={formatNumberOrString} isSurfaceReady={true} navigateToBuyOrReceiveWithEmptyWalletCallback={navigateToBuyOrReceiveWithEmptyWallet} + searchHistory={searchHistory as TokenSearchResult[]} useCommonTokensOptionsHook={useCommonTokensOptions} useFavoriteTokensOptionsHook={useFavoriteTokensOptions} useFilterCallbacksHook={useFilterCallbacks} @@ -81,6 +92,7 @@ export function TokenSelectorPanel({ useTokenSectionsForEmptySearchHook={useTokenSectionsForEmptySearch} useTokenSectionsForSearchResultsHook={useTokenSectionsForSearchResults} useTokenWarningDismissedHook={useTokenWarningDismissed} + valueModifiers={valueModifiers} variation={TokenSelectorVariation.BalancesOnly} onClose={onHideTokenSelector} onDismiss={() => Keyboard.dismiss()} @@ -116,6 +128,7 @@ export function TokenSelectorPanel({ currencyAmount={currencyAmount} currencyBalance={currencyBalance} currencyField={CurrencyField.INPUT} + transactionType={TransactionType.Send} onSetMax={onSetMax} /> )} diff --git a/packages/wallet/src/features/transactions/transfer/TransferAmountInput.tsx b/packages/wallet/src/features/transactions/transfer/TransferAmountInput.tsx index 211a9458092..ccf433af561 100644 --- a/packages/wallet/src/features/transactions/transfer/TransferAmountInput.tsx +++ b/packages/wallet/src/features/transactions/transfer/TransferAmountInput.tsx @@ -98,16 +98,7 @@ export function TransferAmountInput({ // We ignore this specific warning type because we have dedicated UI for this in the review button const warning = insufficientGasFunds ? undefined : formScreenWarning - - const subTextValue = warning - ? warning.warning.title - : !tokenOrFiatEquivalentAmount - ? // Override empty string from useTokenAndFiatDisplayAmounts to keep UI placeholder text consistent - isFiatInput - ? '0' - : '$0' - : tokenOrFiatEquivalentAmount - + const subTextValue = warning ? warning.warning.title : tokenOrFiatEquivalentAmount const subTextValueColor = warning ? '$statusCritical' : '$neutral2' const inputColor = !value ? '$neutral3' : '$neutral1' diff --git a/packages/wallet/src/features/transactions/transfer/TransferFormWarnings.tsx b/packages/wallet/src/features/transactions/transfer/TransferFormWarnings.tsx deleted file mode 100644 index a84eed94076..00000000000 --- a/packages/wallet/src/features/transactions/transfer/TransferFormWarnings.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { Flex, Text } from 'ui/src' -import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' -import { isSameAddress } from 'utilities/src/addresses' -import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' -import { useIsErc20Contract } from 'wallet/src/features/contracts/hooks' -import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' -import { useAllTransactionsBetweenAddresses } from 'wallet/src/features/transactions/hooks/useAllTransactionsBetweenAddresses' -import { useIsSmartContractAddress } from 'wallet/src/features/transactions/transfer/hooks/useIsSmartContractAddress' -import { TransferSpeedbump } from 'wallet/src/features/transactions/transfer/types' -import { useActiveAccountAddressWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' -import { DisplayNameType } from 'wallet/src/features/wallet/types' - -interface TransferFormWarningProps { - recipient?: string - chainId?: WalletChainId - showSpeedbumpModal: boolean - onNext: () => void - setTransferSpeedbump: (w: TransferSpeedbump) => void - setShowSpeedbumpModal: (b: boolean) => void -} - -export function TransferFormSpeedbumps({ - recipient, - chainId, - showSpeedbumpModal, - onNext, - setTransferSpeedbump, - setShowSpeedbumpModal, -}: TransferFormWarningProps): JSX.Element | null { - const { t } = useTranslation() - - const activeAddress = useActiveAccountAddressWithThrow() - const previousTransactions = useAllTransactionsBetweenAddresses(activeAddress, recipient) - const isNewRecipient = !previousTransactions || previousTransactions.length === 0 - const currentSignerAccounts = useSignerAccounts() - const isSignerRecipient = useMemo( - () => currentSignerAccounts.some((a) => a.address === recipient), - [currentSignerAccounts, recipient], - ) - - const { isSmartContractAddress, loading: addressLoading } = useIsSmartContractAddress( - recipient, - chainId ?? UniverseChainId.Mainnet, - ) - - const shouldWarnSelfSend = isSameAddress(activeAddress, recipient) - const shouldWarnSmartContract = isNewRecipient && !isSignerRecipient && isSmartContractAddress - const shouldWarnNewAddress = isNewRecipient && !isSignerRecipient && !shouldWarnSmartContract - const shouldWarnErc20 = useIsErc20Contract(recipient, chainId ?? UniverseChainId.Mainnet) - - useEffect(() => { - setTransferSpeedbump({ - hasWarning: shouldWarnSmartContract || shouldWarnNewAddress || shouldWarnErc20 || shouldWarnSelfSend, - loading: addressLoading, - }) - }, [ - setTransferSpeedbump, - addressLoading, - shouldWarnSmartContract, - shouldWarnNewAddress, - shouldWarnErc20, - shouldWarnSelfSend, - ]) - - const onCloseWarning = (): void => { - setShowSpeedbumpModal(false) - } - - const displayName = useDisplayName(recipient) - - if (!showSpeedbumpModal) { - return null - } - if (shouldWarnSelfSend) { - return ( - <WarningModal - caption={t('send.warning.self.message')} - closeText={t('common.button.cancel')} - confirmText={t('common.button.understand')} - modalName={ModalName.SendWarning} - severity={WarningSeverity.High} - title={t('send.warning.self.title')} - onClose={onCloseWarning} - onConfirm={onNext} - /> - ) - } - if (shouldWarnErc20) { - return ( - <WarningModal - caption={t('send.warning.erc20.message')} - closeText={t('common.button.cancel')} - confirmText={t('common.button.understand')} - modalName={ModalName.SendWarning} - severity={WarningSeverity.High} - title={t('send.warning.erc20.title')} - onClose={onCloseWarning} - onConfirm={onNext} - /> - ) - } - if (shouldWarnSmartContract) { - return ( - <WarningModal - caption={t('send.warning.smartContract.message')} - closeText={t('common.button.cancel')} - confirmText={t('common.button.understand')} - modalName={ModalName.SendWarning} - severity={WarningSeverity.None} - title={t('send.warning.smartContract.title')} - onClose={onCloseWarning} - onConfirm={onNext} - /> - ) - } - if (shouldWarnNewAddress) { - return ( - <WarningModal - caption={t('send.warning.newAddress.message')} - closeText={t('common.button.cancel')} - confirmText={t('common.button.confirm')} - modalName={ModalName.SendWarning} - severity={WarningSeverity.Medium} - title={t('send.warning.newAddress.title')} - onClose={onCloseWarning} - onConfirm={onNext} - > - <TransferRecipient address={recipient} displayName={displayName?.name} type={displayName?.type} /> - </WarningModal> - ) - } - return null -} - -interface TransferRecipientProps { - displayName?: string - address?: string - type?: DisplayNameType -} - -const TransferRecipient = ({ - displayName, - address, - type = DisplayNameType.Address, -}: TransferRecipientProps): JSX.Element => { - return ( - <Flex - borderColor="$surface3" - borderRadius="$rounded12" - borderWidth={1} - gap="$spacing8" - px="$spacing16" - py="$spacing12" - width="100%" - > - <Text color="$neutral1" textAlign="center" variant="subheading2"> - {type === DisplayNameType.ENS ? displayName : address} - </Text> - {type === DisplayNameType.ENS && ( - <Text color="$neutral2" textAlign="center" variant="buttonLabel4"> - {address} - </Text> - )} - </Flex> - ) -} diff --git a/packages/wallet/src/features/transactions/transfer/TransferTokenForm.tsx b/packages/wallet/src/features/transactions/transfer/TransferTokenForm.tsx index 8f3fe70d4e6..5cbe6c657da 100644 --- a/packages/wallet/src/features/transactions/transfer/TransferTokenForm.tsx +++ b/packages/wallet/src/features/transactions/transfer/TransferTokenForm.tsx @@ -30,10 +30,10 @@ import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' import { useUSDTokenUpdater } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDTokenUpdater' import { transactionStateActions } from 'wallet/src/features/transactions/transactionState/transactionState' -import { TransferFormSpeedbumps } from 'wallet/src/features/transactions/transfer/TransferFormWarnings' import { useSetShowRecipientSelector } from 'wallet/src/features/transactions/transfer/hooks/useOnToggleShowRecipientSelector' import { useShowSendNetworkNotification } from 'wallet/src/features/transactions/transfer/hooks/useShowSendNetworkNotification' -import { DerivedTransferInfo, TransferSpeedbump } from 'wallet/src/features/transactions/transfer/types' +import { DerivedTransferInfo } from 'wallet/src/features/transactions/transfer/types' +import { TransactionType } from 'wallet/src/features/transactions/types' import { createTransactionId } from 'wallet/src/features/transactions/utils' import { BlockedAddressWarning } from 'wallet/src/features/trm/BlockedAddressWarning' import { useIsBlocked, useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' @@ -83,7 +83,6 @@ export function TransferTokenForm({ isFiatInput = false, currencyInInfo, nftIn, - chainId, } = derivedTransferInfo const currencyIn = currencyInInfo?.currency @@ -95,11 +94,6 @@ export function TransferTokenForm({ const [currencyFieldFocused, setCurrencyFieldFocused] = useState(true) const [showWarningModal, setShowWarningModal] = useState(false) - const [showSpeedbumpModal, setShowSpeedbumpModal] = useState(false) - const [transferSpeedbump, setTransferSpeedbump] = useState<TransferSpeedbump>({ - loading: true, - hasWarning: false, - }) const { onShowTokenSelector } = useTokenSelectorActionHandlers(dispatch, TokenSelectorFlow.Transfer) const { onSetExactAmount, onSetMax } = useTokenFormActionHandlers(dispatch) @@ -125,7 +119,6 @@ export function TransferTokenForm({ const isViewOnlyWallet = account.type === AccountType.Readonly const actionButtonDisabled = warnings.warnings.some((warning) => warning.action === WarningAction.DisableReview) || - transferSpeedbump.loading || isBlocked || isBlockedLoading || walletNeedsRestore @@ -139,20 +132,10 @@ export function TransferTokenForm({ const onPressReview = useCallback(() => { if (isViewOnlyWallet) { setShowViewOnlyModal(true) - } else if (transferSpeedbump.hasWarning) { - setShowSpeedbumpModal(true) } else { goToNext() } - }, [goToNext, transferSpeedbump.hasWarning, isViewOnlyWallet, setShowViewOnlyModal]) - - const onSetTransferSpeedbump = useCallback(({ hasWarning, loading }: TransferSpeedbump) => { - setTransferSpeedbump({ hasWarning, loading }) - }, []) - - const onSetShowSpeedbumpModal = useCallback((showModal: boolean) => { - setShowSpeedbumpModal(showModal) - }, []) + }, [isViewOnlyWallet, setShowViewOnlyModal, goToNext]) const [inputSelection, setInputSelection] = useState<TextInputProps['selection']>() @@ -227,14 +210,6 @@ export function TransferTokenForm({ onConfirm={(): void => setShowWarningModal(false)} /> )} - <TransferFormSpeedbumps - chainId={chainId} - recipient={recipient} - setShowSpeedbumpModal={onSetShowSpeedbumpModal} - setTransferSpeedbump={onSetTransferSpeedbump} - showSpeedbumpModal={showSpeedbumpModal} - onNext={goToNext} - /> <Flex grow gap="$spacing8" justifyContent="space-between"> <AnimatedFlex entering={FadeIn} @@ -255,6 +230,7 @@ export function TransferTokenForm({ isFiatInput={isFiatInput} isOnScreen={!showingSelectorScreen} showSoftInputOnFocus={showNativeKeyboard} + transactionType={TransactionType.Send} usdValue={inputCurrencyUSDValue} value={isFiatInput ? exactAmountFiat : exactAmountToken} warnings={warnings.warnings} diff --git a/packages/wallet/src/features/transactions/transfer/hooks/useTransferCallback.ts b/packages/wallet/src/features/transactions/transfer/hooks/useTransferCallback.ts index 5a73c29d6a0..a9f90d8ef11 100644 --- a/packages/wallet/src/features/transactions/transfer/hooks/useTransferCallback.ts +++ b/packages/wallet/src/features/transactions/transfer/hooks/useTransferCallback.ts @@ -1,3 +1,4 @@ +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { providers } from 'ethers' import { useMemo } from 'react' import { useDispatch } from 'react-redux' @@ -16,6 +17,7 @@ export function useTransferERC20Callback( amountInWei?: string, transferTxWithGasSettings?: providers.TransactionRequest, onSubmit?: () => void, + currencyAmountUSD?: Maybe<CurrencyAmount<Currency>>, // for analytics ): (() => void) | null { const account = useActiveAccount() @@ -29,6 +31,7 @@ export function useTransferERC20Callback( amountInWei, type: AssetType.Currency, txId, + currencyAmountUSD, } : undefined, transferTxWithGasSettings, diff --git a/packages/wallet/src/features/transactions/transfer/transferTokenSaga.test.ts b/packages/wallet/src/features/transactions/transfer/transferTokenSaga.test.ts index d84bfa6e864..8e1d7823d20 100644 --- a/packages/wallet/src/features/transactions/transfer/transferTokenSaga.test.ts +++ b/packages/wallet/src/features/transactions/transfer/transferTokenSaga.test.ts @@ -29,6 +29,7 @@ const erc20TranferParams: TransferCurrencyParams = { chainId: UniverseChainId.Goerli, toAddress: '0xdefaced', amountInWei: '100000000000000000', + currencyAmountUSD: undefined, } const nativeTranferParams: TransferCurrencyParams = { ...erc20TranferParams, @@ -42,6 +43,7 @@ const erc721TransferParams: TransferNFTParams = { toAddress: '0xdefaced', tokenAddress: '0xdeadbeef', tokenId: '123567', + currencyAmountUSD: undefined, } const erc1155TransferParams: TransferNFTParams = { ...erc721TransferParams, @@ -54,6 +56,7 @@ const typeInfo: SendTokenTransactionInfo = { recipient: erc20TranferParams.toAddress, tokenAddress: erc20TranferParams.tokenAddress, type: TransactionType.Send, + currencyAmountUSD: undefined, } describe('transferTokenSaga', () => { @@ -126,6 +129,7 @@ describe('transferTokenSaga', () => { tokenAddress: erc721TransferParams.tokenAddress, tokenId: erc721TransferParams.tokenId, type: TransactionType.Send, + currencyAmountUSD: undefined, }, txId: '1', }) @@ -150,6 +154,7 @@ describe('transferTokenSaga', () => { tokenAddress: erc1155TransferParams.tokenAddress, tokenId: erc1155TransferParams.tokenId, type: TransactionType.Send, + currencyAmountUSD: undefined, }, txId: '1', }) diff --git a/packages/wallet/src/features/transactions/transfer/transferTokenSaga.ts b/packages/wallet/src/features/transactions/transfer/transferTokenSaga.ts index 91d70001ed9..df576d2b655 100644 --- a/packages/wallet/src/features/transactions/transfer/transferTokenSaga.ts +++ b/packages/wallet/src/features/transactions/transfer/transferTokenSaga.ts @@ -33,10 +33,16 @@ export function* transferToken(params: Params) { options: { request: txRequest }, typeInfo, }) + + const amountUSD = params.transferTokenParams.currencyAmountUSD + ? parseFloat(params.transferTokenParams.currencyAmountUSD.toFixed(2)) + : undefined + sendAnalyticsEvent(WalletEventName.TransferSubmitted, { chainId: params.transferTokenParams.chainId, tokenAddress: params.transferTokenParams.tokenAddress, toAddress: params.transferTokenParams.toAddress, + amountUSD, }) logger.debug('transferTokenSaga', 'transferToken', 'Transfer submitted') } catch (error) { @@ -91,12 +97,13 @@ function* validateTransfer(transferTokenParams: TransferTokenParams) { } function getTransferTypeInfo(params: TransferTokenParams): SendTokenTransactionInfo { - const { type: assetType, toAddress, tokenAddress } = params + const { type: assetType, toAddress, tokenAddress, currencyAmountUSD } = params const typeInfo: SendTokenTransactionInfo = { assetType, recipient: toAddress, tokenAddress, type: TransactionType.Send, + currencyAmountUSD, } if (assetType === AssetType.ERC721 || assetType === AssetType.ERC1155) { diff --git a/packages/wallet/src/features/transactions/transfer/types.ts b/packages/wallet/src/features/transactions/transfer/types.ts index 2ec37ba6422..3e316ea92d4 100644 --- a/packages/wallet/src/features/transactions/transfer/types.ts +++ b/packages/wallet/src/features/transactions/transfer/types.ts @@ -13,6 +13,7 @@ interface BaseTransferParams { chainId: WalletChainId toAddress: Address tokenAddress: Address + currencyAmountUSD?: Maybe<CurrencyAmount<Currency>> // for analytics } export interface TransferCurrencyParams extends BaseTransferParams { @@ -58,3 +59,8 @@ export interface TransferSpeedbump { hasWarning: boolean loading: boolean } + +export enum TokenSelectorFlow { + Swap, + Transfer, +} diff --git a/packages/wallet/src/features/transactions/types.ts b/packages/wallet/src/features/transactions/types.ts index e3cbe5bc74e..7ff09f62b53 100644 --- a/packages/wallet/src/features/transactions/types.ts +++ b/packages/wallet/src/features/transactions/types.ts @@ -1,14 +1,14 @@ import { AnyAction } from '@reduxjs/toolkit' import { Protocol } from '@uniswap/router-sdk' -import { TradeType } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core' import { providers } from 'ethers' import { Dispatch } from 'react' import { TransactionListQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { AssetType } from 'uniswap/src/entities/assets' import { FORLogo } from 'uniswap/src/features/fiatOnRamp/types' import { WalletChainId } from 'uniswap/src/types/chains' import { DappInfo } from 'uniswap/src/types/walletConnect' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types' import { GasFeeResult } from 'wallet/src/features/gas/types' import { ParsedWarnings } from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' @@ -256,6 +256,7 @@ export interface SendTokenTransactionInfo extends BaseTransactionInfo { currencyAmountRaw?: string tokenId?: string // optional. NFT token id nftSummaryInfo?: NFTSummaryInfo // optional. NFT metadata + currencyAmountUSD?: Maybe<CurrencyAmount<Currency>> // optional, for analytics } export interface ReceiveTokenTransactionInfo extends BaseTransactionInfo { diff --git a/packages/wallet/src/features/unitags/api.ts b/packages/wallet/src/features/unitags/api.ts index c4fffadc3e0..f4d55d75887 100644 --- a/packages/wallet/src/features/unitags/api.ts +++ b/packages/wallet/src/features/unitags/api.ts @@ -16,7 +16,6 @@ import { UnitagResponse, UnitagUpdateMetadataRequestBody, UnitagUpdateMetadataResponse, - UnitagWaitlistPositionResponse, } from 'uniswap/src/features/unitags/types' import { isMobileApp } from 'utilities/src/platform' import { ONE_MINUTE_MS } from 'utilities/src/time/time' @@ -213,23 +212,3 @@ export async function fetchUnitagByAddresses(addresses: Address[]): Promise<{ return { error } } } - -export async function fetchExtensionWaitlistEligibity(username: string): Promise<{ - data?: UnitagWaitlistPositionResponse - error?: unknown -}> { - const unitagWaitlistPositionUrl = `${uniswapUrls.unitagsApiUrl}/waitlist/position?username=${encodeURIComponent( - username, - )}` - - try { - const response = await axios.get<UnitagWaitlistPositionResponse>(unitagWaitlistPositionUrl, { - headers: BASE_HEADERS, - }) - return { - data: response.data, - } - } catch (error) { - return { error } - } -} diff --git a/packages/wallet/src/features/unitags/hooks.ts b/packages/wallet/src/features/unitags/hooks.ts index 22a67a4eaea..c1255d207e3 100644 --- a/packages/wallet/src/features/unitags/hooks.ts +++ b/packages/wallet/src/features/unitags/hooks.ts @@ -7,7 +7,7 @@ import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { UnitagEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { useUnitagQuery, useWaitlistPositionQuery } from 'uniswap/src/features/unitags/api' +import { useUnitagQuery } from 'uniswap/src/features/unitags/api' import { useUnitagUpdater } from 'uniswap/src/features/unitags/context' import { UnitagClaim, @@ -21,8 +21,6 @@ import { logger } from 'utilities/src/logger/logger' import { useAsyncData } from 'utilities/src/react/hooks' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { getFirebaseAppCheckToken } from 'wallet/src/features/appCheck' -import { selectExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/selectors' -import { ExtensionOnboardingState, setExtensionOnboardingState } from 'wallet/src/features/behaviorHistory/slice' import { useENS } from 'wallet/src/features/ens/useENS' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' @@ -31,11 +29,10 @@ import { claimUnitag, getUnitagAvatarUploadUrl, useUnitagClaimEligibilityQuery } import { isLocalFileUri, uploadAndUpdateAvatarAfterClaim } from 'wallet/src/features/unitags/avatars' import { AVATAR_UPLOAD_CREDS_EXPIRY_SECONDS, UNITAG_VALID_REGEX } from 'wallet/src/features/unitags/constants' import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils' -import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' import { useWalletSigners } from 'wallet/src/features/wallet/context' -import { useAccounts, useActiveAccount, useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' +import { useAccounts, useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' import { SignerManager } from 'wallet/src/features/wallet/signing/SignerManager' -import { useAppSelector } from 'wallet/src/state' const MIN_UNITAG_LENGTH = 3 const MAX_UNITAG_LENGTH = 20 @@ -284,68 +281,3 @@ export const useAvatarUploadCredsWithRefresh = ({ return { avatarUploadUrlLoading, avatarUploadUrlResponse } } - -export const useShowExtensionPromoBanner = (): { - error: string | undefined - loading: boolean - showExtensionPromoBanner: boolean -} => { - const dispatch = useDispatch() - const extensionOnboardingEnabledBeta = useFeatureFlag(FeatureFlags.ExtensionOnboarding) - const extensionPromotionGAEnabled = useFeatureFlag(FeatureFlags.ExtensionPromotionGA) - const extensionOnboardingState = useAppSelector(selectExtensionOnboardingState) - const activeAccount = useActiveAccount() - - const skipBetaWaitlistQuery = - extensionPromotionGAEnabled || // ignore waitlist if GA enabled - !extensionOnboardingEnabledBeta || - extensionOnboardingState === ExtensionOnboardingState.Completed || - !activeAccount || - activeAccount.type !== AccountType.SignerMnemonic - - const { data, error, loading } = useWaitlistPositionQuery([activeAccount?.address || ''], skipBetaWaitlistQuery) - - /** Onboarding completed, skip all checks **/ - if (extensionOnboardingState === ExtensionOnboardingState.Completed) { - return { - error: undefined, - loading: false, - showExtensionPromoBanner: false, - } - } - - /*** GA checks first ***/ - if (extensionPromotionGAEnabled) { - if (extensionOnboardingState === ExtensionOnboardingState.Undefined) { - dispatch(setExtensionOnboardingState(ExtensionOnboardingState.ReadyToOnboard)) - } - - return { - error: undefined, - loading: false, - showExtensionPromoBanner: true, - } - } - - /*** Skip beta checks if we didn't query for the data ***/ - if (skipBetaWaitlistQuery) { - return { - error: undefined, - loading: false, - showExtensionPromoBanner: false, - } - } - - const canOnboardToExtensionBeta = data?.isAccepted ?? false - - if (canOnboardToExtensionBeta && extensionOnboardingState === ExtensionOnboardingState.Undefined) { - // Store the information locally so that we don't need to check again during onboarding - dispatch(setExtensionOnboardingState(ExtensionOnboardingState.ReadyToOnboard)) - } - - return { - error: error?.message, - loading, - showExtensionPromoBanner: canOnboardToExtensionBeta, - } -} diff --git a/packages/wallet/src/features/wallet/Keyring/Keyring.web.ts b/packages/wallet/src/features/wallet/Keyring/Keyring.web.ts new file mode 100644 index 00000000000..f48a9f1ea81 --- /dev/null +++ b/packages/wallet/src/features/wallet/Keyring/Keyring.web.ts @@ -0,0 +1,548 @@ +/* eslint-disable max-lines */ +import { Signature, Wallet } from 'ethers' +import { SigningKey, defaultPath, joinSignature } from 'ethers/lib/utils' +import { logger } from 'utilities/src/logger/logger' +import { IKeyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { + PBKDF2_PARAMS, + SecretPayload, + convertBase64SeedToCryptoKey, + decodeFromStorage, + decrypt, + encodeForStorage, + encrypt, + exportKey, + generateNewIV, + generateNewSalt, + getEncryptionKeyFromPassword, +} from 'wallet/src/features/wallet/Keyring/crypto' +import { ENCRYPTION_KEY_STORAGE_KEY, PersistedStorage, prefix } from 'wallet/src/utils/persistedStorage' + +const mnemonicPrefix = '.mnemonic.' +const privateKeyPrefix = '.privateKey.' +const entireMnemonicPrefix = prefix + mnemonicPrefix +const entirePrivateKeyPrefix = prefix + privateKeyPrefix + +enum ErrorType { + StoreMnemonicError = 'storeMnemonicError', + RetrieveMnemonicError = 'retrieveMnemonicError', + RetrievePasswordError = 'retrievePasswordError', +} + +/** + * Provides the generation, storage, and signing logic for mnemonics and private keys on web. + * + * Mnemonics and private keys are stored and accessed in secure local key-value store via associated keys formed from concatenating a constant prefix with the associated public address. + * + * @link https://github.com/Uniswap/universe/blob/main/apps/mobile/ios/RNEthersRS.swift + */ +export class WebKeyring implements IKeyring { + constructor( + private storage = new PersistedStorage('local'), + private session = new PersistedStorage('session'), + ) { + this.generateAndStoreMnemonic = this.generateAndStoreMnemonic.bind(this) + this.generateAddressForMnemonic = this.generateAddressForMnemonic.bind(this) + this.generateAndStorePrivateKey = this.generateAndStorePrivateKey.bind(this) + this.getMnemonicIds = this.getMnemonicIds.bind(this) + this.importMnemonic = this.importMnemonic.bind(this) + this.keyForMnemonicId = this.keyForMnemonicId.bind(this) + this.keyForPrivateKey = this.keyForPrivateKey.bind(this) + this.retrieveMnemonic = this.retrieveMnemonic.bind(this) + this.retrieveMnemonicUnlocked = this.retrieveMnemonicUnlocked.bind(this) + this.storeNewMnemonic = this.storeNewMnemonic.bind(this) + this.unlock = this.unlock.bind(this) + this.lock = this.lock.bind(this) + } + + /** + * Fetches all mnemonic IDs, which are used as keys to access the actual mnemonics + * in key-value store. + * @param mnemonic + * @returns array of mnemonic IDs + */ + async getMnemonicIds(): Promise<string[]> { + const allKeys = Object.keys(await this.storage.getAll()) + + const mnemonicIds = allKeys + .filter((k) => k.includes(mnemonicPrefix)) + .map((k) => k.replaceAll(entireMnemonicPrefix, '')) + + return mnemonicIds + } + + async removeAllMnemonicsAndPrivateKeys(): Promise<boolean> { + const allKeys = Object.keys(await this.storage.getAll()) + + const mnemonicStorageKeys = allKeys.filter((k) => k.includes(mnemonicPrefix)) + const privateKeyStorageKeys = allKeys.filter((k) => k.includes(privateKeyPrefix)) + + await this.storage.removeItem(mnemonicStorageKeys) + await this.storage.removeItem(privateKeyStorageKeys) + + return true + } + + async isUnlocked(): Promise<boolean> { + const firstMnemonicId = (await this.getMnemonicIds())[0] + + if (!firstMnemonicId) { + return false + } + + try { + const mnemonic = await this.retrieveMnemonicUnlocked(firstMnemonicId) + return !!mnemonic + } catch { + return false + } + } + + /** Tries to unlock the wallet with the provided password. */ + async unlock(password: string): Promise<boolean> { + // assumes every mnemonic is encrypted withe same password + const firstMnemonicId = (await this.getMnemonicIds())[0] + + if (!firstMnemonicId) { + throw new Error(`${ErrorType.RetrieveMnemonicError}: Attempted to unlock wallet, but storage is empty.`) + } + + const mnemonicKey = this.keyForMnemonicId(firstMnemonicId) + const storedSecretPayload = await this.storage.getItem(mnemonicKey) + if (!storedSecretPayload) { + return false + } + const secretPayload = JSON.parse(storedSecretPayload) as Maybe<SecretPayload> + if (!secretPayload) { + return false + } + const encryptionKey = await getEncryptionKeyFromPassword(password, secretPayload) + const mnemonic = await this.retrieveMnemonic(secretPayload, encryptionKey, firstMnemonicId) + if (!mnemonic) { + return false + } + + const keyBase64 = await exportKey(encryptionKey) + + await this.session.setItem(ENCRYPTION_KEY_STORAGE_KEY, keyBase64) + return true + } + + async lock(): Promise<boolean> { + await this.session.removeItem(ENCRYPTION_KEY_STORAGE_KEY) // Clear password + return true + } + + async checkPassword(password: string): Promise<boolean> { + try { + const currentPasswordBase64String = await this.session.getItem(ENCRYPTION_KEY_STORAGE_KEY) + const firstMnemonicId = (await this.getMnemonicIds())[0] + if (!firstMnemonicId) { + return false + } + const keyForMnemonicId = this.keyForMnemonicId(firstMnemonicId) + const storedSecretPayload = await this.storage.getItem(keyForMnemonicId) + if (!storedSecretPayload) { + return false + } + const secretPayload = JSON.parse(storedSecretPayload) as Maybe<SecretPayload> + if (!secretPayload || !secretPayload.ciphertext) { + return false + } + const passwordPasswordEncryptionKey = await getEncryptionKeyFromPassword(password, secretPayload) + const passwordPasswordBase64String = await exportKey(passwordPasswordEncryptionKey) + return currentPasswordBase64String === passwordPasswordBase64String + } catch (_e) { + return false + } + } + + async removePassword(): Promise<boolean> { + await this.session.removeItem(ENCRYPTION_KEY_STORAGE_KEY) + return true + } + + /** + * Changes the password by re-encrypting the mnemonic with a new password + * @param newPassword new password to encrypt with + * @returns true if successful + */ + async changePassword(newPassword: string): Promise<boolean> { + try { + const firstMnemonicId = (await this.getMnemonicIds())[0] + + if (!firstMnemonicId) { + throw new Error(`${ErrorType.RetrieveMnemonicError}: Attempted to change password, but storage is empty.`) + } + + const mnemonic = await this.retrieveMnemonicUnlocked(firstMnemonicId) + if (!mnemonic) { + return false + } + + await this.importMnemonic(mnemonic, newPassword, true) + return true + } catch (err) { + logger.error(err, { tags: { file: 'Keyring.web.ts', function: 'changePassword' } }) + return false + } + } + + /** + * Derives private key from mnemonic with derivation index 0 and retrieves + * associated public address. Stores imported mnemonic in store with the + * mnemonic ID key as the public address. + + * @param mnemonic the mnemonic phrase to import + * @param password the password to encrypt the mnemonic. Marked as optional depending on the platform. +* Currently only used on web. + * @returns public address from the mnemonic's first derived private key + */ + async importMnemonic(mnemonic: string, password: string, changingPassword = false): Promise<string> { + const wallet = Wallet.fromMnemonic(mnemonic) + + const address = wallet.address + + const mnemonicId = await this.storeNewMnemonic(mnemonic, password, address, changingPassword) + if (!mnemonicId) { + throw changingPassword + ? new Error(`${ErrorType.StoreMnemonicError}: Failed to store mnemonic with new password`) + : new Error(`${ErrorType.StoreMnemonicError}: Failed to import mnemonic`) + } + + return mnemonicId + } + + /** + * Removes the mnemonic from storage. + * @param mnemonicId key string associated with mnemonic to remove + */ + async removeMnemonic(mnemonicId: string): Promise<boolean> { + const key = this.keyForMnemonicId(mnemonicId) + await this.storage.removeItem(key) + return true + } + + /** + * Generates a new mnemonic and retrieves associated public address. Stores new mnemonic in native keychain with the mnemonic ID key as the public address. + * @param password the password to encrypt the mnemonic + * @returns public address from the mnemonic's first derived private key + */ + async generateAndStoreMnemonic(password: string): Promise<string> { + const newWallet = Wallet.createRandom() + + const mnemonic = newWallet.mnemonic.phrase + const address = newWallet.address + + if (!(await this.storeNewMnemonic(mnemonic, password, address))) { + throw new Error(`${ErrorType.StoreMnemonicError}: Failed to generate and store mnemonic`) + } + return address + } + + private async storeNewMnemonic( + mnemonic: string, + password: string, + address: string, + forceOverwrite = false, + ): Promise<string | undefined> { + const mnemonicKey = this.keyForMnemonicId(address) + const mnemonicStorageValue = await this.storage.getItem(mnemonicKey) + + if (mnemonicStorageValue !== undefined && !forceOverwrite) { + logger.debug('Keyring.web', 'storeNewMnemonic', 'mnemonic already stored. Did you mean to reimport?') + + return address + } + + const salt = generateNewSalt() + const iv = generateNewIV() + const secretPayload: SecretPayload = { + ...PBKDF2_PARAMS, + iv: encodeForStorage(iv), + salt: encodeForStorage(salt), + } + const encryptionKey = await getEncryptionKeyFromPassword(password, secretPayload) + const ciphertext = await encrypt({ + plaintext: mnemonic, + encryptionKey, + iv, + additionalData: address, + }) + secretPayload.ciphertext = ciphertext + + await this.storage.setItem(mnemonicKey, JSON.stringify(secretPayload)) + const keyBase64 = await exportKey(encryptionKey) + await this.session.setItem(ENCRYPTION_KEY_STORAGE_KEY, keyBase64) + + return address + } + + private keyForMnemonicId(mnemonicId: string): string { + // NOTE: small difference with mobile implementation--native keychain prepends a custom prefix, but we must do it ourselves here. + return entireMnemonicPrefix + mnemonicId + } + + private async retrieveMnemonic( + secretPayload: SecretPayload, + encryptionKey: CryptoKey, + expectedAddress: string, + ): Promise<string | undefined> { + try { + if (!secretPayload.ciphertext) { + return undefined + } + + const mnemonic = await decrypt({ + encryptionKey, + ciphertext: decodeFromStorage(secretPayload.ciphertext), + iv: decodeFromStorage(secretPayload.iv), + additionalData: expectedAddress, + }) + + if (!mnemonic) { + return undefined + } + + // validate mnemonic (will throw if invalid) + Wallet.fromMnemonic(mnemonic) + + return mnemonic + } catch (e) { + throw new Error(`${ErrorType.RetrieveMnemonicError}: ${e}`) + } + } + + async retrieveMnemonicUnlocked(mnemonicId: string): Promise<string | undefined> { + const encryptionKeyString = await this.session.getItem(ENCRYPTION_KEY_STORAGE_KEY) + const mnemonicStorageKey = this.keyForMnemonicId(mnemonicId) + const storedSecret = await this.storage.getItem(mnemonicStorageKey) + + if (!storedSecret || !encryptionKeyString) { + return undefined + } + const encryptionKey = await convertBase64SeedToCryptoKey(encryptionKeyString) + + try { + const secretPayload = JSON.parse(storedSecret) as Maybe<SecretPayload> + if (!secretPayload || !secretPayload.ciphertext) { + return undefined + } + const mnemonic = await decrypt({ + encryptionKey, + ciphertext: decodeFromStorage(secretPayload.ciphertext), + iv: decodeFromStorage(secretPayload.iv), + additionalData: mnemonicId, + }) + + if (!mnemonic) { + return undefined + } + + // validate mnemonic (will throw if invalid) + Wallet.fromMnemonic(mnemonic) + + return mnemonic + } catch (e) { + throw new Error(`${ErrorType.RetrieveMnemonicError}: ${e}`) + } + } + + /** + * Fetches all public addresses from private keys stored under `privateKeyPrefix` in store. + * Used from to verify the store has the private key for an account that is attempting create a NativeSigner that calls signing methods + * @returns public addresses for all stored private keys + */ + async getAddressesForStoredPrivateKeys(): Promise<string[]> { + const addresses = Object.keys(await this.storage.getAll()) + .filter((k) => k.includes(privateKeyPrefix)) + .map((k) => k.replaceAll(entirePrivateKeyPrefix, '')) + + return addresses + } + + /** + * Derives public address from mnemonic for a given `derivationIndex`. + * @param mnemonic mnemonic to generate public address for + * @param derivationIndex number used to specify a which derivation index to use for deriving a private key from the mnemonic + * @returns public address associated with private key generated from the mnemonic at given derivation index + */ + async generateAddressForMnemonic(mnemonic: string, derivationIndex: number): Promise<string> { + const derivationPath = defaultPath + derivationIndex + const walletAtIndex = Wallet.fromMnemonic(mnemonic, derivationPath) + return walletAtIndex.address + } + + /** + * Derives private key and public address from mnemonic associated with `mnemonicId` for given `derivationIndex`. Stores the private key in store with key. + * @param mnemonicId key string associated with mnemonic to generate private key for (currently convention is to use public address associated with mnemonic) + * @param derivationIndex number used to specify a which derivation index to use for deriving a private key from the mnemonic + * @returns public address associated with private key generated from the mnemonic at given derivation index + */ + async generateAndStorePrivateKey(mnemonicId: string, derivationIndex: number): Promise<string> { + const encryptionKeyString = await this.session.getItem(ENCRYPTION_KEY_STORAGE_KEY) + if (!encryptionKeyString) { + throw new Error(ErrorType.RetrievePasswordError) + } + const encryptionKey = await convertBase64SeedToCryptoKey(encryptionKeyString) + const mnemonicKey = this.keyForMnemonicId(mnemonicId) + const storedSecretPayload = await this.storage.getItem(mnemonicKey) + if (!storedSecretPayload) { + throw new Error(ErrorType.RetrieveMnemonicError) + } + const secretPayload = JSON.parse(storedSecretPayload) as Maybe<SecretPayload> + if (!secretPayload || !secretPayload.ciphertext) { + throw new Error(ErrorType.RetrieveMnemonicError) + } + const mnemonic = await this.retrieveMnemonic(secretPayload, encryptionKey, mnemonicId) + + if (!mnemonic) { + throw new Error(ErrorType.RetrieveMnemonicError) + } + + const derivationPath = defaultPath + derivationIndex + const walletAtIndex = Wallet.fromMnemonic(mnemonic, derivationPath) + + const privateKey = walletAtIndex.privateKey + const address = walletAtIndex.address + + return await this.storeNewPrivateKey(address, privateKey) + } + + /** + * Removes the private key from storage for the given address. + * @param address account address to remove private key for + * @returns success of removal + */ + async removePrivateKey(address: string): Promise<boolean> { + const key = this.keyForPrivateKey(address) + try { + await this.storage.removeItem(key) + return true + } catch (e) { + return false + } + } + + /** @returns address is store call was successfull. */ + private async storeNewPrivateKey(address: string, privateKey: string): Promise<string> { + const checkStored = await this.retrievePrivateKey(address) + + if (checkStored !== undefined) { + logger.debug('Keyring.web', 'storeNewPrivateKey', 'privateKey already stored. Did you mean to reimport?') + + return address + } + + const encryptionKeyString = await this.session.getItem(ENCRYPTION_KEY_STORAGE_KEY) + if (!encryptionKeyString) { + throw new Error(ErrorType.RetrievePasswordError) + } + + const encryptionKey = await convertBase64SeedToCryptoKey(encryptionKeyString) + + try { + const salt = generateNewSalt() + const iv = generateNewIV() + const secretPayload: SecretPayload = { + ...PBKDF2_PARAMS, + iv: encodeForStorage(iv), + salt: encodeForStorage(salt), + } + const ciphertext = await encrypt({ + plaintext: privateKey, + encryptionKey, + iv, + additionalData: address, + }) + secretPayload.ciphertext = ciphertext + + const newPrivateKeyStorageKey = this.keyForPrivateKey(address) + logger.debug('Keyring.web', 'storeNewPrivateKey', 'storing new private key') + await this.storage.setItem(newPrivateKeyStorageKey, JSON.stringify(secretPayload)) + + return address + } catch (e) { + throw new Error(ErrorType.StoreMnemonicError + `: ${e}`) + } + } + + private async retrievePrivateKey(address: string): Promise<string | undefined> { + const key = this.keyForPrivateKey(address) + const result = await this.storage.getItem(key) + + if (!result) { + return undefined + } + + try { + const maybeSecretPayload = JSON.parse(result) as Maybe<SecretPayload> + const encryptionKeyString = await this.session.getItem(ENCRYPTION_KEY_STORAGE_KEY) + if (!encryptionKeyString || !maybeSecretPayload?.ciphertext) { + throw new Error(ErrorType.RetrievePasswordError) + } + + const encryptionKey = await convertBase64SeedToCryptoKey(encryptionKeyString) + + const privateKey = await decrypt({ + encryptionKey, + ciphertext: decodeFromStorage(maybeSecretPayload.ciphertext), + iv: decodeFromStorage(maybeSecretPayload.iv), + additionalData: address, + }) + + if (!privateKey) { + return undefined + } + + // validate private key (will throw if invalid) + const wallet = new Wallet(privateKey) + if (!wallet) { + throw new Error('Invalid private key') + } + + return privateKey + } catch (e) { + throw new Error(`${ErrorType.RetrieveMnemonicError}: ${e}`) + } + } + + private keyForPrivateKey(address: string): string { + return entirePrivateKeyPrefix + address + } + + /** + * @returns the Signature of the signed transaction in string form. + **/ + async signTransactionHashForAddress(address: string, hash: string, chainId: number): Promise<string> { + // Ethers.js doesn't differentiate between signing a random hash and signing a transaction hash + return this.signHashForAddress(address, hash, chainId) + } + + // adds EIP-191 prefix + // https://docs.ethers.org/v5/api/signer/#Signer-signMessage + async signMessageForAddress(address: string, message: string): Promise<string> { + const privateKey = await this.retrievePrivateKey(address) + if (!privateKey) { + throw Error('No private key found for address') + } + const wallet = new Wallet(privateKey) + return wallet.signMessage(message) + } + + /** + * signs raw 32-byte hashes + * @returns the Signature of the signed hash in string form. + **/ + async signHashForAddress(address: string, hash: string, _chainId: number): Promise<string> { + const privateKey = await this.retrievePrivateKey(address) + if (!privateKey) { + throw Error('No private key found for address') + } + const signingKey = new SigningKey(privateKey) + const signature: Signature = signingKey.signDigest(hash) + return joinSignature(signature) + } +} + +export const Keyring = new WebKeyring() diff --git a/packages/wallet/src/features/wallet/accounts/editAccountSaga.ts b/packages/wallet/src/features/wallet/accounts/editAccountSaga.ts index 487663735e1..457b53a5fa7 100644 --- a/packages/wallet/src/features/wallet/accounts/editAccountSaga.ts +++ b/packages/wallet/src/features/wallet/accounts/editAccountSaga.ts @@ -1,11 +1,10 @@ -import { all, call, put } from 'typed-redux-saga' +import { all, call, put, select } from 'typed-redux-saga' import { logger } from 'utilities/src/logger/logger' import { unique } from 'utilities/src/primitives/array' import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' import { Account, AccountType, BackupType } from 'wallet/src/features/wallet/accounts/types' import { selectAccounts } from 'wallet/src/features/wallet/selectors' import { editAccount as editInStore, removeAccounts as removeAccountsInStore } from 'wallet/src/features/wallet/slice' -import { appSelect } from 'wallet/src/state' import { createMonitoredSaga } from 'wallet/src/utils/saga' export enum EditAccountAction { @@ -78,7 +77,7 @@ function* editAccount(params: EditAccountParams) { throw new Error('Address is required for editAccount actions other than Remove') } - const accounts = yield* appSelect(selectAccounts) + const accounts = yield* select(selectAccounts) const account = accounts[address] if (!account) { @@ -132,7 +131,7 @@ function* addBackupMethod(params: AddBackupMethodParams, account: Account) { const { backupMethod } = params - const accounts = yield* appSelect(selectAccounts) + const accounts = yield* select(selectAccounts) const mnemonicAccounts = Object.values(accounts).filter( (a) => a.type === AccountType.SignerMnemonic && a.mnemonicId === account.mnemonicId, ) @@ -168,7 +167,7 @@ function* removeBackupMethod(params: RemoveBackupMethodParams, account: Account) const { backupMethod } = params - const accounts = yield* appSelect(selectAccounts) + const accounts = yield* select(selectAccounts) const mnemonicAccounts = Object.values(accounts).filter( (a) => a.type === AccountType.SignerMnemonic && a.mnemonicId === account.mnemonicId, ) diff --git a/packages/wallet/src/features/wallet/hooks.ts b/packages/wallet/src/features/wallet/hooks.ts index 5a2df8231af..38f0d647283 100644 --- a/packages/wallet/src/features/wallet/hooks.ts +++ b/packages/wallet/src/features/wallet/hooks.ts @@ -1,4 +1,5 @@ import { useMemo, useRef } from 'react' +import { useSelector } from 'react-redux' import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' import { getValidAddress, sanitizeAddressText, shortenAddress } from 'uniswap/src/utils/addresses' import { trimToLength } from 'utilities/src/primitives/string' @@ -21,16 +22,16 @@ import { } from 'wallet/src/features/wallet/selectors' import { SwapProtectionSetting } from 'wallet/src/features/wallet/slice' import { DisplayName, DisplayNameType } from 'wallet/src/features/wallet/types' -import { useAppSelector } from 'wallet/src/state' +import { RootState } from 'wallet/src/state' const ENS_TRIM_LENGTH = 8 export function useAccounts(): Record<string, Account> { - return useAppSelector<Record<string, Account>>(selectAccounts) + return useSelector(selectAccounts) } export function useAccount(address: Address): Account { - const account = useAppSelector<Record<string, Account>>(selectAccounts)[address] + const account = useSelector(selectAccounts)[address] if (!account) { throw new Error(`No account found for address ${address}`) } @@ -38,44 +39,44 @@ export function useAccount(address: Address): Account { } export function useAccountIfExists(address: Address): Account | undefined { - const account = useAppSelector<Record<string, Account>>(selectAccounts)[address] + const account = useSelector(selectAccounts)[address] return account } export function useSignerAccountIfExists(address: Address): SignerMnemonicAccount | undefined { - const signerAccounts = useAppSelector<SignerMnemonicAccount[]>(selectSignerMnemonicAccounts) + const signerAccounts = useSelector(selectSignerMnemonicAccounts) return signerAccounts.find((account) => account.address === address) } export function useSignerAccounts(): SignerMnemonicAccount[] { - return useAppSelector<SignerMnemonicAccount[]>(selectSignerMnemonicAccounts) + return useSelector(selectSignerMnemonicAccounts) } export function useViewOnlyAccounts(): Account[] { - return useAppSelector<Account[]>(selectViewOnlyAccounts) + return useSelector(selectViewOnlyAccounts) } export function useActiveAccount(): Account | null { - return useAppSelector(selectActiveAccount) + return useSelector(selectActiveAccount) } export function useActiveSignerAccount(): Account | null { - const activeAccount = useAppSelector(selectActiveAccount) + const activeAccount = useSelector(selectActiveAccount) return activeAccount?.type === AccountType.SignerMnemonic ? activeAccount : null } export function useActiveAccountAddress(): Address | null { - return useAppSelector(selectActiveAccountAddress) + return useSelector(selectActiveAccountAddress) } export function useNativeAccountExists(): boolean { - return useAppSelector(selectSignerMnemonicAccountExists) + return useSelector(selectSignerMnemonicAccountExists) } export function useActiveAccountAddressWithThrow(): Address { const addressRef = useRef<string | null>(null) const isFocused = useIsFocused() - const activeAccountAddress = useAppSelector(selectActiveAccountAddress) + const activeAccountAddress = useSelector(selectActiveAccountAddress) // Update the account address only when the screen is focused // or the address haven't been set yet @@ -94,7 +95,7 @@ export function useActiveAccountAddressWithThrow(): Address { } export function useActiveAccountWithThrow(): Account { - const activeAccount = useAppSelector(selectActiveAccount) + const activeAccount = useSelector(selectActiveAccount) if (!activeAccount) { throw new Error('No active account') } @@ -102,20 +103,20 @@ export function useActiveAccountWithThrow(): Account { } export function useSwapProtectionSetting(): SwapProtectionSetting { - return useAppSelector(selectWalletSwapProtectionSetting) + return useSelector(selectWalletSwapProtectionSetting) } export function useSelectAccountNotificationSetting(address: Address): boolean { const selectAccountNotificationSetting = useMemo(() => makeSelectAccountNotificationSetting(), []) - return useAppSelector((state) => selectAccountNotificationSetting(state, address)) + return useSelector((state: RootState) => selectAccountNotificationSetting(state, address)) } export function useHideSmallBalancesSetting(): boolean { - return useAppSelector(selectWalletHideSmallBalancesSetting) + return useSelector(selectWalletHideSmallBalancesSetting) } export function useHideSpamTokensSetting(): boolean { - return useAppSelector(selectWalletHideSpamTokensSetting) + return useSelector(selectWalletHideSpamTokensSetting) } type DisplayNameOptions = { diff --git a/packages/wallet/src/i18n/locales/@types/resources.d.ts b/packages/wallet/src/i18n/locales/@types/resources.d.ts deleted file mode 100644 index 6e8f4064b58..00000000000 --- a/packages/wallet/src/i18n/locales/@types/resources.d.ts +++ /dev/null @@ -1,1974 +0,0 @@ -interface Resources { - "en-US": { - "account": { - "cloud": { - "backup": { - "subtitle": "There are multiple recovery phrases backed up to your {{cloudProviderName}}.", - "title": "Select a backup to restore" - }, - "button": { - "restore": { - "android": "Restore from Google Drive", - "ios": "Restore from iCloud" - } - }, - "empty": { - "description": "It looks like you haven’t backed up any of your recovery phrases to {{cloudProviderName}}.", - "title": "0 backups found" - }, - "error": { - "backup": { - "message": "Failed to import backups due to lack of permissions, interruption of authorization, or due to a cloud error", - "title": "Error while importing backups" - }, - "password": { - "title": "Invalid password. Please try again." - }, - "unavailable": { - "button": { - "cancel": "Not now", - "settings": "Go to settings" - }, - "message": { - "android": "Please verify that you are logged in to a Google account with Google Drive enabled on this device and try again.", - "ios": "Please verify that you are logged in to an Apple ID with iCloud Drive enabled on this device and try again." - }, - "title": { - "android": "Google Drive not available", - "ios": "iCloud Drive not available" - } - } - }, - "loading": { - "title": "Searching for backups..." - }, - "lockout": { - "time": { - "hours_one": "Too many attempts. Try again in 1 hour.", - "hours_other": "Too many attempts. Try again in {{count}} hours.", - "minutes_one": "Too many attempts. Try again in 1 minute.", - "minutes_other": "Too many attempts. Try again in {{count}} minutes." - } - }, - "password": { - "input": "Enter password", - "recoveryPhrase": "Enter your recovery phrase instead", - "subtitle": "This password is required to recover your recovery phrase backup from {{cloudProviderName}}.", - "title": "Enter backup password" - } - }, - "recoveryPhrase": { - "education": { - "part1": "A recovery phrase (or seed phrase) is a <highlight>set of words</highlight> required to access your wallet, <highlight>like a password.</highlight>", - "part2": "You can <highlight>enter</highlight> your recovery phrase on a new device <highlight>to restore your wallet</highlight> and its contents.", - "part3": "But, if you <highlight>lose your recovery phrase</highlight>, you’ll <highlight>lose access</highlight> to your wallet.", - "part4": "Instead of memorizing your recovery phrase, you can <highlight>back it up to {{cloudProviderName}}</highlight> and protect it with a password.", - "part5": "You can also manually back up your recovery phrase by <highlight>writing it down</highlight> and storing it in a safe place.", - "part6": "We recommend using <highlight>both types of backups</highlight>, because if you lose your recovery phrase, you won’t be able to restore your wallet." - }, - "error": { - "invalid": "Invalid phrase", - "invalidWord": "Invalid word: {{word}}", - "phraseLength": "Recovery phrase must be 12-24 words", - "wrong": "Wrong recovery phrase" - }, - "helpText": { - "import": "How do I find my recovery phrase?", - "restoring": "Try searching again" - }, - "input": "Type your recovery phrase", - "remove": { - "final": { - "description": "Make sure you’ve written down your recovery phrase or backed it up on {{cloudProviderName}}. <highlight>You will not be able to access your funds otherwise.</highlight>", - "title": "You’re removing your <highlight>recovery phrase</highlight>" - }, - "import": { - "description": "You can only store one recovery phrase at a time. To continue importing a new one, you’ll need to remove your current recovery phrase and any associated wallets from this device." - }, - "initial": { - "description": "This will remove your wallet from this device along with your recovery phrase.", - "title": "You’re removing <highlight>{{walletName}}</highlight>" - }, - "mnemonic": { - "description": "It shares the same recovery phrase as <highlight>{{walletNames}}</highlight>. Your recovery phrase will remain stored until you delete all remaining wallets." - } - }, - "subtitle": { - "import": "Your recovery phrase will only be stored locally on your device.", - "restoring": "Enter your recovery phrase below, or try searching for backups again." - }, - "title": { - "import": "Enter your recovery phrase", - "restoring": "No backups found" - } - }, - "wallet": { - "action": { - "copy": "Copy wallet address", - "report": "Report profile", - "settings": "Wallet settings", - "viewExplorer": "View on {{blockExplorerName}}" - }, - "button": { - "add": "Add wallet", - "addViewOnly": "Add a view-only wallet", - "create": "Create a new wallet", - "import": "Import a new wallet", - "manage": "Manage wallet", - "remove": "Remove wallet", - "restore": "Restore wallet", - "watch": "Watch a wallet" - }, - "header": { - "other": "Your other wallets", - "viewOnly": "View only wallets" - }, - "remove": { - "check": "I backed up my recovery phrase and understand that Uniswap Labs can’t help me recover my wallets if I failed to do so.", - "viewOnly": "You can always add back view-only wallets by entering the wallet’s address." - }, - "restore": { - "description": "Because you’re on a new device, you’ll need to restore your recovery phrase. This will allow you to swap and send tokens." - }, - "select": { - "error": "Couldn’t load addresses", - "loading": { - "subtitle": "Your wallets will appear below.", - "title": "Searching for wallets" - }, - "title_one_one": "One wallet found", - "title_one_other": "Select wallets to import" - }, - "title": "Your wallets", - "viewOnly": { - "button": "Import wallet", - "description": "To swap, buy, send, and receive tokens, you need to import this wallet’s recovery phrase.", - "title": "This wallet is view-only" - }, - "watch": { - "error": { - "alreadyImported": "This address is already imported", - "notFound": "Address not found", - "smartContract": "Address is a smart contract" - }, - "message": "Adding a view-only wallet allows you to try out the app or track a wallet. You will not be able to swap or send funds.", - "placeholder": "ENS or address", - "title": "Enter a wallet address" - } - } - }, - "common": { - "app": "Uniswap Wallet", - "button": { - "accept": "Accept", - "back": "Back", - "buy": "Buy", - "cancel": "Cancel", - "close": "Close", - "confirm": "Confirm", - "connect": "Connect", - "continue": "Continue", - "copied": "Copied", - "copy": "Copy", - "delete": "Delete", - "disconnect": "Disconnect", - "dismiss": "Dismiss", - "done": "Done", - "enable": "Enable", - "hide": "Hide", - "later": "Maybe later", - "learn": "Learn more", - "next": "Next", - "notNow": "Not now", - "ok": "OK", - "paste": "Paste", - "receive": "Receive", - "remove": "Remove", - "restore": "Restore", - "retry": "Retry", - "review": "Review", - "save": "Save", - "sell": "Sell", - "send": "Send", - "setup": "Set up", - "share": "Share", - "show": "Show", - "sign": "Sign", - "skip": "Skip", - "swap": "Swap", - "tryAgain": "Try again", - "understand": "I understand", - "view": "View" - }, - "card": { - "error": { - "description": "Something went wrong", - "title": "Oops! Something went wrong." - } - }, - "error": { - "general": "Something went wrong." - }, - "input": { - "password": { - "confirm": "Confirm password", - "error": { - "mismatch": "Passwords don’t match", - "weak": "This password is too weak" - }, - "new": "New password", - "placeholder": "Password", - "strength": { - "medium": "Medium", - "strong": "Strong", - "weak": "Weak" - } - }, - "search": "Search" - }, - "longText": { - "button": { - "less": "Read less", - "more": "Read more" - } - }, - "navigation": { - "settings": "Settings", - "systemSettings": "Settings" - }, - "text": { - "error": "Error", - "loading": "Loading", - "notAvailable": "N/A", - "unknown": "Unknown" - } - }, - "currency": { - "aud": "Australian Dollar", - "brl": "Brazilian Real", - "cad": "Canadian Dollar", - "cny": "Chinese Yuan", - "eur": "Euro", - "gbp": "British Pound", - "hkd": "Hong Kong Dollar", - "idr": "Indonesian Rupiah", - "inr": "Indian Rupee", - "jpy": "Japanese Yen", - "ngn": "Nigerian Naira", - "pkr": "Pakistani Rupee", - "rub": "Russian Ruble", - "sgd": "Singapore Dollar", - "thb": "Thai Baht", - "try": "Turkish Lira", - "uah": "Ukrainian Hryvnia", - "usd": "United States Dollar", - "vnd": "Vietnamese Dong" - }, - "dapp": { - "request": { - "approve": { - "label": "Wallet" - }, - "error": { - "none": "No approvals pending" - }, - "signature": { - "education": { - "description": "A signature is required to prove that you own the wallet without exposing your private keys", - "title": "What’s a signature request?" - } - }, - "warning": { - "notActive": { - "message": "Make sure it’s the right one", - "title": "This is not your active wallet" - } - } - } - }, - "errors": { - "crash": { - "message": "Something crashed.", - "restart": "Restart app", - "title": "Uh oh!" - } - }, - "explore": { - "search": { - "action": { - "clear": "Clear all", - "viewEtherscan": "View on {{blockExplorerName}}" - }, - "empty": { - "full": "No results found for <highlight>\"{{searchQuery}}\"</highlight>" - }, - "error": "Couldn’t load search results", - "label": { - "ownedBy": "Owned by {{ownerAddress}}" - }, - "placeholder": "Search tokens and wallets", - "section": { - "nft": "NFT Collections", - "popularNFT": "Popular NFT collections", - "popularTokens": "Popular tokens", - "recent": "Recent searches", - "suggestedWallets": "Suggested wallets", - "tokens": "Tokens", - "wallets": "Wallets" - } - }, - "tokens": { - "error": "Couldn’t load tokens", - "favorite": { - "action": { - "add": "Favorite token", - "edit": "Edit favorites", - "remove": "Remove favorite" - }, - "title": { - "default": "Favorite tokens", - "edit": "Edit favorite tokens" - } - }, - "metadata": { - "marketCap": "{{number}} MCap", - "totalValueLocked": "{{number}} TVL", - "volume": "{{number}} Vol" - }, - "sort": { - "label": { - "marketCap": "Market cap", - "priceDecrease": "Price decrease", - "priceIncrease": "Price increase", - "totalValueLocked": "TVL", - "volume": "Volume" - }, - "option": { - "marketCap": "Market cap", - "priceDecrease": "Price decrease (24H)", - "priceIncrease": "Price increase (24H)", - "totalValueLocked": "Uniswap TVL", - "volume": "Uniswap volume (24H)" - } - }, - "top": { - "title": "Top tokens" - } - }, - "wallets": { - "favorite": { - "action": { - "add": "Favorite wallet", - "edit": "Edit favorites", - "remove": "Remove favorite" - }, - "title": { - "default": "Favorite wallets", - "edit": "Edit favorite wallets" - } - } - } - }, - "extension": { - "connection": { - "popup": "Your wallet isn’t connected to this site.", - "popupWithButton": "Your wallet isn’t connected to this site. Look for a “Connect Wallet” or “Log in” button." - }, - "lock": { - "button": { - "forgot": "Forgot password?", - "help": "Get help", - "recoveryPhrase": "Enter recovery phrase", - "submit": "Unlock" - }, - "password": { - "error": "Wrong password. Try again", - "reset": { - "description": { - "default": "To reset your password, enter your wallet’s recovery phrase. Uniswap cannot help recover your password.", - "inProgress": "Follow the instructions on the browser web page to reset your password" - }, - "title": "Forgot password?" - } - }, - "subtitle": "Enter your password to unlock", - "title": "Welcome back" - }, - "settings": { - "password": { - "enter": { - "title": "Enter your current password" - }, - "error": { - "wrong": "Wrong password" - }, - "placeholder": "Current password" - } - } - }, - "fiatOnRamp": { - "button": { - "chooseToken": "Choose a token", - "continueCheckout": "Continue to checkout" - }, - "checkout": { - "button": "Checkout", - "title": "Checkout with" - }, - "connection": { - "message": "Connecting you to {{serviceProvider}}", - "quote": "Buying {{amount}} worth of {{currencySymbol}}" - }, - "error": { - "default": "Something went wrong.", - "load": "Couldn’t load tokens to buy", - "max": "Maximum {{amount}}", - "min": "Minimum {{amount}}", - "unsupported": "Not supported in region", - "usd": "Only available to purchase in USD" - }, - "quote": { - "amount": "Receive {{tokenAmount}}", - "amountAfterFees": "{{tokenAmount}} after fees", - "type": { - "best": "Best overall", - "other": "Other options", - "recent": "Recently used" - } - }, - "region": { - "placeholder": "Search by country or region", - "title": "Select your region" - }, - "summary": { - "total": "{{cryptoAmount}} for {{fiatAmount}}" - } - }, - "forceUpgrade": { - "action": { - "confirm": "Update app", - "recoveryPhrase": "View recovery phrase" - }, - "description": "The version of Uniswap Wallet you’re using is out of date and is missing critical upgrades. If you don’t update the app or you don’t have your recovery phrase written down, you won’t be able to access your assets.", - "label": { - "recoveryPhrase": "Recovery phrase" - }, - "title": "Update the app to continue" - }, - "home": { - "activity": { - "empty": { - "button": "Receive tokens or NFTs", - "description": { - "default": "When you approve, trade, or transfer tokens or NFTs, your transactions will appear here.", - "external": "When this wallet makes transactions, they’ll appear here." - }, - "title": "No activity yet" - }, - "error": { - "load": "Couldn’t load activity" - }, - "title": "Activity" - }, - "banner": { - "offline": "You are in offline mode" - }, - "extension": { - "error": "Error loading accounts", - "pin": "Pin Uniswap Wallet to your browser toolbar by clicking on the <puzzleIcon />" - }, - "feed": { - "empty": { - "description": "When your favorited wallets makes transactions, they’ll appear here.", - "title": "No activity yet" - }, - "error": "Couldn’t load activity", - "title": "Feed" - }, - "label": { - "buy": "Buy", - "receive": "Receive", - "scan": "Scan", - "send": "Send", - "swap": "Swap" - }, - "nfts": { - "title": "NFTs" - }, - "tokens": { - "empty": { - "action": { - "buy": { - "description": "You’ll need ETH to get started. Buy with a card or bank.", - "title": "Buy crypto" - }, - "import": { - "description": "Enter this wallet’s recovery phrase to begin swapping and sending.", - "title": "Import wallet" - }, - "receive": { - "description": "Transfer tokens from another wallet or crypto exchange.", - "title": "Receive funds" - } - }, - "description": "When this wallet buys or receives tokens, they’ll appear here.", - "title": "No tokens yet" - }, - "error": { - "fetch": "Failed to fetch token balances", - "load": "Couldn’t load token balances" - }, - "title": "Tokens" - }, - "upsell": { - "receive": { - "cta": "Link an account", - "description": "Fund your wallet by transferring crypto from another wallet or account", - "title": "Receive crypto" - } - }, - "warning": { - "viewOnly": "This is a view-only wallet" - } - }, - "language": { - "chineseSimplified": "Chinese, Simplified", - "chineseTraditional": "Chinese, Traditional", - "dutch": "Dutch", - "english": "English", - "french": "French", - "hindi": "Hindi", - "indonesian": "Indonesian", - "japanese": "Japanese", - "malay": "Malay", - "portuguese": "Portuguese", - "russian": "Russian", - "spanishLatam": "Spanish (Latin America)", - "spanishSpain": "Spanish (Spain)", - "spanishUs": "Spanish (US)", - "thai": "Thai", - "turkish": "Turkish", - "ukrainian": "Ukrainian", - "urdu": "Urdu", - "vietnamese": "Vietnamese" - }, - "notification": { - "assetVisibility": { - "hidden": "{{assetName}} hidden", - "unhidden": "{{assetName}} unhidden" - }, - "copied": { - "address": "Address copied", - "contractAddress": "Contract address copied", - "failed": "Failed to copy to clipboard", - "image": "Image copied", - "transactionId": "Transaction ID copied" - }, - "countryChange": "Switched to {{countryName}}", - "restore": { - "success": "Wallet restored!" - }, - "swap": { - "network": "Swapping on {{network}}", - "pending": { - "swap": "Swap pending", - "unwrap": "Unwrap pending", - "wrap": "Wrap pending" - } - }, - "transaction": { - "approve": { - "canceled": "Canceled {{currencySymbol}} approve.", - "fail": "Failed to approve {{currencySymbol}} for use with {{address}}.", - "success": "Approved {{currencySymbol}} for use with {{address}}." - }, - "swap": { - "canceled": "Canceled {{inputCurrencySymbol}}-{{outputCurrencySymbol}} swap.", - "fail": "Failed to swap {{inputCurrencyAmountWithSymbol}} for {{outputCurrencyAmountWithSymbol}}.", - "success": "Swapped {{inputCurrencyAmountWithSymbol}} for {{outputCurrencyAmountWithSymbol}}." - }, - "transfer": { - "canceled": "Canceled {{tokenNameOrAddress}} send.", - "fail": "Failed to send {{tokenNameOrAddress}} to {{walletNameOrAddress}}.", - "received": "Received {{tokenNameOrAddress}} from {{walletNameOrAddress}}.", - "success": "Sent {{tokenNameOrAddress}} to {{walletNameOrAddress}}." - }, - "unknown": { - "fail": { - "full": "Failed to transact with {{addressOrEnsName}}", - "short": "Failed to transact" - }, - "success": { - "full": "Transacted with {{addressOrEnsName}}", - "short": "Transacted" - } - }, - "unwrap": { - "canceled": "Canceled {{inputCurrencySymbol}} unwrap.", - "fail": "Failed to unwrap {{inputCurrencyAmountWithSymbol}}.", - "success": "Unwrapped {{inputCurrencyAmountWithSymbol}} and received {{outputCurrencyAmountWithSymbol}}." - }, - "wrap": { - "canceled": "Canceled {{inputCurrencySymbol}} wrap.", - "fail": "Failed to wrap {{inputCurrencyAmountWithSymbol}}.", - "success": "Wrapped {{inputCurrencyAmountWithSymbol}} and received {{outputCurrencyAmountWithSymbol}}." - } - }, - "transfer": { - "pending": "{{currencySymbol}} transfer pending" - }, - "walletConnect": { - "confirmed": "Transaction confirmed with {{dappName}}", - "connected": "Connected", - "disconnected": "Disconnected", - "failed": "Transaction failed with {{dappName}}", - "networkChanged": { - "full": "Switched to {{networkName}}", - "short": "Switched networks" - } - } - }, - "notifications": { - "scantastic": { - "subtitle": "Continue on Uniswap Extension", - "title": "Success" - } - }, - "onboarding": { - "backup": { - "manual": { - "placeholder": "Secret word", - "progress": "{{completedStepsCount}}/{{totalStepsCount}} completed", - "subtitle_one": "What’s the <highlight/>{{count}}st</highlight/> word in your recovery phrase?", - "subtitle_two": "What’s the <highlight/>{{count}}nd</highlight/> word in your recovery phrase?", - "subtitle_few": "What’s the <highlight/>{{count}}rd</highlight/> word in your recovery phrase?", - "subtitle_other": "What’s the <highlight/>{{count}}th</highlight/> word in your recovery phrase?", - "title": "Let’s make sure you’ve recorded it correctly" - }, - "option": { - "cloud": { - "description": "Encrypt your recovery phrase with a secure password", - "title": "{{cloudProviderName}} backup" - }, - "manual": { - "description": "Write your recovery phrase down and store it in a safe location", - "title": "Manual backup" - } - }, - "subtitle": "Backups let you restore your wallet if you delete the app or lose your device", - "title": { - "existing": "Back up your wallet", - "new": "Choose a backup method" - }, - "view": { - "disclaimer": "I understand that if I lose my recovery phrase, Uniswap Labs cannot help me restore it", - "subtitle": { - "write": "Read the following carefully before continuing" - }, - "title": "Save your recovery phrase", - "warning": { - "message1": "This recovery phrase gives you full access to your wallet and funds", - "message2": "Write it down and keep it in a safe place", - "message3": "View this in private and <u>do not share it with anyone</u>" - } - } - }, - "cloud": { - "confirm": { - "description": "You’ll need to enter this password to recover your account. It’s not stored anywhere, so it can’t be recovered by anyone else.", - "title": "Confirm your backup password" - }, - "createPassword": { - "description": "You’ll need to enter this password to recover your wallet.", - "title": "Create your backup password" - } - }, - "complete": { - "card": { - "buy": { - "description": "Purchase with a card, or transfer from an exchange", - "title": "Buy or transfer crypto" - }, - "explore": { - "description": "Search and browse trending tokens and NFTs", - "title": "Explore tokens & NFTs" - }, - "swap": { - "description": "Purchase with a card, or transfer from an exchange", - "title": "Start swapping" - } - }, - "description": "Your wallet is ready! Start by funding your wallet by buying or transferring crypto to your wallet.", - "footer": "Learn how to use the Uniswap Wallet", - "pin": "<text>Pin the extension to your browser window</text><container><text>by clicking on the </text><puzzleIcon /><text> icon, and then the pin</text></container>" - }, - "editName": { - "button": { - "create": "Create wallet" - }, - "label": "Nickname", - "subtitle": "Give your wallet a nickname", - "title": "This nickname is only visible to you", - "walletAddress": "Your public address will be <highlight>{{walletAddress}}</highlight>" - }, - "extension": { - "connectMobile": { - "button": "Import from your phone", - "title": "Have the Uniswap mobile app?" - }, - "password": { - "subtitle": "You’ll need this to unlock your wallet and access your recovery phrase", - "title": { - "default": "Create a password", - "reset": "Reset your password" - } - } - }, - "import": { - "error": { - "invalidWords_one": "1 word is invalid or misspelled", - "invalidWords_other": "{{count}} words are invalid or misspelled" - }, - "method": { - "import": { - "message": "Enter your recovery phrase from another crypto wallet", - "title": "Import a wallet" - }, - "restore": { - "message": { - "android": "Add wallets you’ve backed up to your Google Drive account", - "ios": "Add wallets you’ve backed up to your iCloud account" - }, - "title": "Restore a wallet" - } - }, - "title": "Choose how you want to add your wallet" - }, - "importMnemonic": { - "button": { - "default": "My recovery phrase is 12 words", - "longPhrase": "My recovery phrase is longer than 12 words" - }, - "error": { - "invalidPhrase": "The phrase you entered is invalid" - }, - "subtitle": "Type or paste your 12-word recovery phrase", - "title": "Enter your recovery phrase" - }, - "intro": { - "alreadyComplete": { - "subtitle": "To create more wallets, open the account switcher inside the extension popup, or reinstall the extension to start over", - "title": "You’ve already completed onboarding" - }, - "button": { - "alreadyHave": "I already have a wallet" - }, - "title": "Welcome to \nUniswap Wallet" - }, - "landing": { - "button": { - "add": "Add an existing wallet", - "create": "Create a new wallet" - } - }, - "notification": { - "permission": { - "message": "To receive notifications, turn on notifications for Uniswap Wallet in your device’s settings.", - "title": "Notifications permission" - }, - "subtitle": "Get notified when your transfers, swaps, and approvals complete.", - "title": "Turn on push notifications" - }, - "recoveryPhrase": { - "confirm": { - "subtitle": { - "combined": "Confirm your recovery phrase. Select the missing words in order.", - "default": "Select the missing words in order." - }, - "title": "Confirm your recovery phrase" - }, - "view": { - "subtitle": "You can check this in settings at any time.", - "title": "Write down your recovery phrase in order" - }, - "warning": { - "final": { - "button": "I’m ready", - "message": "Your recovery phrase is what grants you (and anyone who has it) access to your funds. Be sure to keep it to yourself.", - "title": "Do this step in a private place" - }, - "screenshot": { - "message": "Anyone who gains access to your photos can access your wallet. We recommend that you write down your words instead.", - "title": "Screenshots aren’t secure" - } - } - }, - "resetPassword": { - "complete": { - "safety": "Learn more about wallet safety", - "subtitle": "Use your new password to unlock your wallet.", - "title": "Password reset" - } - }, - "scan": { - "button": "Scan with Uniswap app", - "error": "Sorry, we are unable to load the QR code right now. Please try another onboarding method.", - "otp": { - "error": "The code you submitted is incorrect, or there was an error submitting. Please try again.", - "failed": "Failed attempts: {{count}}", - "subtitle": "Check your Uniswap mobile app for the 6-character code", - "title": "Enter one-time code" - }, - "subtitle": "Scan the QR code with the Uniswap app to import your wallet", - "title": "Sync from your phone" - }, - "security": { - "alert": { - "biometrics": { - "message": { - "android": "To use biometrics, set up it first in settings", - "ios": "To use {{biometricsMethod}}, allow access in system settings" - }, - "title": { - "android": "Biometrics is disabled", - "ios": "{{biometricsMethod}} is disabled" - } - } - }, - "button": { - "confirm": { - "android": "Enable biometrics", - "ios": "Enable {{biometricsMethod}}" - }, - "setup": "Set up" - }, - "subtitle": { - "android": "Add an extra layer of security by requiring biometrics to send transactions.", - "ios": "Add an extra layer of security by requiring {{biometricsMethod}} to send transactions." - }, - "title": "Protect your wallet" - }, - "selectWallets": { - "error": "Couldn’t load addresses", - "title": { - "default": "Choose wallets to import", - "error": "Error importing wallets" - } - }, - "termsOfService": "By continuing, I agree to the<highlightTerms> Terms of Service </highlightTerms>and consent to the<highlightPrivacy> Privacy Policy</highlightPrivacy>.", - "tooltip": { - "recoveryPhrase": { - "trigger": "What’s a recovery phrase?" - } - }, - "wallet": { - "continue": "Let’s keep it safe", - "defaultName": "Wallet {{number}}", - "description": { - "existing": "Check out your tokens and NFTs, follow crypto wallets, and stay up to date on the go.", - "full": "This is your personal space for tokens, NFTs, and all of your trades. Finish setting it up to keep your funds safe.", - "new": "Your personal space for tokens, NFTs, and all your trades." - }, - "title": "Welcome to your new wallet" - } - }, - "qrScanner": { - "button": { - "connections_one": "1 app connected", - "connections_other": "{{count}} apps connected" - }, - "error": { - "camera": { - "message": "To scan a code, allow Camera access in system settings", - "title": "Camera is disabled" - }, - "none": "No QR code found" - }, - "recipient": { - "action": { - "scan": "Scan a QR code", - "show": "Show my QR code" - }, - "error": { - "message": "Make sure that you’re scanning a valid Ethereum address QR code before trying again.", - "title": "Invalid QR Code" - }, - "input": { - "placeholder": "Search ENS or address" - }, - "label": { - "send": "Send" - }, - "results": { - "empty": "No results found", - "error": "The address you typed either does not exist or is spelled incorrectly." - } - }, - "request": { - "message": { - "unavailable": "No message found." - }, - "method": { - "default": "Request from {{dappNameOrUrl}}", - "signature": "Signature request from {{dappNameOrUrl}}", - "transaction": "Transaction request from {{dappNameOrUrl}}" - }, - "withAmount": "Allow {{dappName}} to use up to<highlight> {{amount}} </highlight>{{currencySymbol}}?", - "withoutAmount": "Allow {{dappName}} to use your {{currencySymbol}}?" - }, - "status": { - "connecting": "Connecting...", - "loading": "Loading..." - }, - "title": "Scan a QR code", - "wallet": { - "networks": { - "description": "Uniswap Wallet supports tokens on Ethereum, Polygon, Arbitrum, Optimism, Base, and BNB Chain. Right now, we only support NFTs on Ethereum.", - "title": "Supported Networks" - }, - "title": "You can send tokens on all of our supported networks to this address." - } - }, - "scantastic": { - "code": { - "expired": "Expired", - "subtitle": "Enter this code in the Uniswap Extension. Your recovery phrase will be safely encrypted and transferred.", - "timeRemaining": { - "shorthand": { - "hours": "New code in {{hours}}h {{minutes}}m {{seconds}}s", - "minutes": "New code in {{minutes}}m {{seconds}}s", - "seconds": "New code in {{seconds}}s" - } - }, - "title": "Uniswap one-time code" - }, - "confirmation": { - "button": { - "continue": "Yes, continue" - }, - "label": { - "browser": "Browser", - "device": "Device" - }, - "subtitle": "Only continue if you are syncing with the Uniswap Extension on a trusted device.", - "title": "Is this your device?" - }, - "error": { - "encryption": "Failed to prepare seed phrase.", - "noCode": "No OTP received. Please try again.", - "timeout": { - "message": "Scan the QR code on the Uniswap Extension again to continue syncing your wallet.", - "title": "Your connection timed out" - } - } - }, - "send": { - "button": { - "review": "Review transfer", - "send": "Send" - }, - "recipient": { - "previous_one": "{{count}} previous transfer", - "previous_other": "{{count}} previous transfers", - "section": { - "favorite": "Favorite wallets", - "recent": "Recent", - "search": "Search results", - "yours": "Your wallets" - } - }, - "review": { - "summary": { - "sending": "Sending", - "to": "To" - } - }, - "search": { - "empty": { - "subtitle": "The address you typed either does not exist or is spelled incorrectly.", - "title": "No results found" - }, - "placeholder": "Search ENS or address" - }, - "status": { - "fail": { - "description": "Keep in mind that the network cost is still charged for failed transfers." - }, - "failed": { - "title": "Send failed" - }, - "inProgress": { - "description": "We’ll notify you once your transaction is complete.", - "title": "Sending" - }, - "success": { - "description": "You sent {{currencyAmount}}{{tokenName}}{{fiatValue}} to {{recipient}}.", - "title": "Send successful!" - } - }, - "title": "Send", - "warning": { - "blocked": { - "default": "This wallet is blocked", - "modal": { - "message": "This address is blocked on Uniswap Wallet because it is associated with one or more blocked activities. If you believe this is an error, please email compliance@uniswap.org.", - "title": "Blocked address" - }, - "recipient": "Recipient wallet is blocked" - }, - "insufficientFunds": { - "message": "Your {{currencySymbol}} balance has decreased since you entered the amount you’d like to send", - "title": "Not enough {{currencySymbol}}." - }, - "newAddress": { - "message": "You haven’t transacted with this address before. Please confirm that the address is correct before continuing.", - "title": "New address" - }, - "restore": "Restore your wallet to send", - "smartContract": { - "message": "You’re about to send tokens to a special type of address—a smart contract. Double-check it’s the address you intended to send to. If it’s wrong, your tokens could be lost forever.", - "title": "Is this a wallet address?" - }, - "viewOnly": { - "message": "You need to import this wallet via recovery phrase to send assets.", - "title": "This wallet is view-only" - } - } - }, - "setting": { - "recoveryPhrase": { - "account": { - "show": "Show recovery phrase" - }, - "action": { - "hide": "Hide recovery phrase" - }, - "remove": { - "button": "Remove recovery phrase", - "confirm": { - "subtitle": "I understand that Uniswap Labs can’t help me recover my wallet if I failed to do so", - "title": "I saved my recovery phrase" - }, - "initial": { - "subtitle": "Make sure you’ve saved your recovery phrase. You will lose access to your funds otherwise", - "title": "Before you continue" - }, - "password": { - "error": "Wrong password. Try again", - "input": "Enter password" - }, - "subtitle": "Enter your password to continue", - "title": "You’re removing your recovery phrase" - }, - "view": { - "error": "Wrong password, try again", - "warning": { - "message1": "Anyone who knows your recovery phrase can access your wallet and funds", - "message2": "View this in private", - "message3": "Do not share this with anyone", - "message4": "Never enter it to any websites or apps", - "title": "Before you continue" - } - }, - "warning": { - "screenshot": { - "message": "Anyone who gains access to your photos can access your wallet. We recommend that you write down your words instead.", - "title": "Screenshots aren’t secure" - }, - "view": { - "message": "Anyone who knows your recovery phrase can access your wallet and funds.", - "title": "View this in a private place" - } - } - } - }, - "settings": { - "action": { - "feedback": "Send feedback", - "help": "Get help", - "lock": "Lock wallet", - "privacy": "Privacy policy", - "terms": "Terms of service" - }, - "footer": "Made with love, \nUniswap Team 🦄", - "screen": { - "appearance": { - "title": "Appearance" - } - }, - "section": { - "about": "About", - "preferences": "Preferences", - "security": "Security", - "support": "Support", - "wallet": { - "action": { - "hide": "Hide wallets", - "showAll_one": "Show one wallet", - "showAll_other": "Show all {{count}} wallets" - }, - "button": { - "viewAll": "View all", - "viewLess": "View less" - }, - "title": "Wallet settings" - } - }, - "setting": { - "appearance": { - "option": { - "dark": { - "subtitle": "Always use dark mode", - "title": "Dark mode" - }, - "device": { - "subtitle": "Default to your device’s appearance", - "title": "Device settings" - }, - "light": { - "subtitle": "Always use light mode", - "title": "Light mode" - } - }, - "title": "Appearance" - }, - "backup": { - "create": { - "description": "Setting a password will encrypt your recovery phrase backup, adding an extra level of protection if your {{cloudProviderName}} account is ever compromised.", - "title": "Back up to {{cloudProviderName}}" - }, - "delete": { - "confirm": { - "message": "Because these wallets share a recovery phrase, it will also delete the backups for these wallets below", - "title": "Are you sure?" - }, - "warning": "If you delete your {{cloudProviderName}} backup, you’ll only be able to recover your wallet with a manual backup of your recovery phrase. Uniswap Labs can’t recover your assets if you lose your recovery phrase." - }, - "error": { - "message": { - "full": "Unable to backup recovery phrase to {{cloudProviderName}}. Please ensure you have {{cloudProviderName}} enabled with available storage space and try again.", - "short": "Unable to delete backup" - }, - "title": "{{cloudProviderName}} error" - }, - "modal": { - "description": "You haven’t backed up your recovery phrase to {{cloudProviderName}} yet. By doing so, you can recover your wallet just by being logged into {{cloudProviderName}} on any device.", - "title": "Back up recovery phrase to {{cloudProviderName}}?" - }, - "password": { - "disclaimer": "Uniswap Labs does not store your password and can’t recover it, so it’s crucial you remember it.", - "error": { - "mismatch": "Passwords do not match", - "weak": "Weak password" - }, - "medium": "This is a medium password", - "placeholder": { - "confirm": "Confirm password", - "create": "Create password" - }, - "strong": "This is a strong password", - "weak": "This is a weak password" - }, - "recoveryPhrase": { - "label": "Recovery phrase" - }, - "selected": "{{cloudProviderName}} backup", - "status": { - "action": { - "delete": "Delete backup" - }, - "complete": "Backed up to {{cloudProviderName}}", - "description": "By having your recovery phrase backed up to {{cloudProviderName}}, you can recover your wallet just by being logged into your {{cloudProviderName}} account on any device.", - "inProgress": "Backing up to {{cloudProviderName}}...", - "recoveryPhrase": { - "backed": "Backed up" - }, - "title": "{{cloudProviderName}} backup" - } - }, - "biometrics": { - "appAccess": { - "subtitle": { - "android": "Require biometrics to open app", - "ios": "Require {{biometricsMethod}} to open app" - }, - "title": "App access" - }, - "auth": "Please authenticate", - "off": { - "message": { - "android": "Biometrics is currently turned off for Uniswap Wallet—you can turn it on in your system settings.", - "ios": "{{biometricsMethod}} is currently turned off for Uniswap Wallet—you can turn it on in your system settings." - }, - "title": { - "android": "Biometrics is turned off", - "ios": "{{biometricsMethod}} is turned off" - } - }, - "title": "Biometrics", - "transactions": { - "subtitle": { - "android": "Require biometrics to transact", - "ios": "Require {{biometricsMethod}} to transact" - }, - "title": "Transactions" - }, - "unavailable": { - "message": { - "android": "Biometrics is not setup on your device. To use biometrics, set it up first in Settings.", - "ios": "{{biometricsMethod}} is not setup on your device. To use {{biometricsMethod}}, set it up first in Settings." - }, - "title": { - "android": "Biometrics is not setup", - "ios": "{{biometricsMethod}} is not setup" - } - }, - "warning": { - "message": { - "android": "If you don’t turn on {{biometricsMethod}}, anyone who gains access to your device can open Uniswap Wallet and make transactions.", - "ios": "If you don’t turn on biometrics, anyone who gains access to your device can open Uniswap Wallet and make transactions." - }, - "title": "Are you sure?" - } - }, - "currency": { - "title": "Local currency" - }, - "helpCenter": { - "title": "Help center" - }, - "language": { - "button": { - "navigate": "Go to settings" - }, - "description": "Uniswap defaults to your device‘s language settings. To change your preferred language, go to “Uniswap” in your device settings and tap on “Language”", - "title": "Language" - }, - "password": { - "title": "Change password" - }, - "privacy": { - "analytics": { - "description": "We use anonymous usage data to enhance your experience across Uniswap Labs products. When disabled, we only track errors and essential usage.", - "title": "Allow analytics" - }, - "title": "Privacy" - }, - "recoveryPhrase": { - "remove": "Remove recovery phrase", - "title": "Recovery phrase", - "view": "View recovery phrase" - }, - "smallBalances": { - "title": "Hide small balances" - }, - "unknownTokens": { - "title": "Hide unknown tokens" - }, - "wallet": { - "action": { - "editLabel": "Edit label", - "editProfile": "Edit profile", - "remove": "Remove wallet" - }, - "connections": { - "title": "Manage connections" - }, - "editLabel": { - "description": "Labels are not public. They are stored locally and only visible to you.", - "disclaimer": "This nickname is only visible to you.", - "save": "Save changes", - "title": "Edit nickname" - }, - "label": "Nickname", - "notifications": { - "title": "Notifications" - }, - "preferences": { - "title": "Wallet preferences" - } - } - }, - "title": "Settings", - "version": "Version {{appVersion}}" - }, - "swap": { - "button": { - "max": "Max", - "swap": "Swap", - "unwrap": "Unwrap", - "view": "View transaction", - "wrap": "Wrap" - }, - "details": { - "action": { - "less": "Show less", - "more": "Show more" - }, - "feeOnTransfer": "{{tokenSymbol}} fee", - "newQuote": { - "input": "New input", - "output": "New output" - }, - "rate": "Rate", - "slippage": "Max slippage", - "uniswapFee": "Fee" - }, - "form": { - "balance": "Balance", - "header": "Swap", - "slippage": "{{slippageTolerancePercent}} slippage", - "warning": { - "restore": "Restore your wallet to swap" - } - }, - "header": { - "viewOnly": "View-only" - }, - "hold": { - "swap": "Hold to swap", - "tip": "Tip: Hold to instant swap", - "unwrap": "Hold to unwrap", - "wrap": "Hold to wrap" - }, - "request": { - "details": { - "header": "You’re swapping" - }, - "title": { - "full": "Swap {{inputCurrencySymbol}} → {{outputCurrencySymbol}}", - "short": "Swap Tokens" - } - }, - "review": { - "summary": "You’re swapping" - }, - "settings": { - "protection": { - "description": "With swap protection on, your Ethereum transactions will be protected from sandwich attacks, with reduced chances of failure.", - "subtitle": { - "supported": "{{chainName}} Network", - "unavailable": "Not available on {{chainName}}" - }, - "title": "Swap Protection" - }, - "slippage": { - "control": { - "auto": "Auto", - "title": "Max slippage" - }, - "description": "Your transaction will revert if the price changes more than the slippage percentage.", - "input": { - "message": "If the price slips any further, your transaction will revert. Below is the minimum amount you are guaranteed to receive.", - "receive": { - "formatted": "<text>Receive at least </text><highlight>{{amount}} {{tokenSymbol}}</highlight>", - "unformatted": "Receive at least {{amount}} {{tokenSymbol}}" - } - }, - "output": { - "message": "If the price slips any further, your transaction will revert. Below is the maximum amount you would need to spend.", - "spend": { - "formatted": "<text>Spend at most </text><highlight>{{amount}} {{tokenSymbol}}</highlight>", - "unformatted": "Spend at most {{amount}} {{tokenSymbol}}" - } - }, - "warning": { - "max": "Enter a value less than {{maxSlippageTolerance}}", - "message": "Slippage may be higher than necessary", - "min": "Enter a value larger than 0" - } - }, - "title": "Swap Settings" - }, - "slippage": { - "settings": { - "title": "Slippage Settings" - } - }, - "warning": { - "expectedFailure": "This transaction is expected to fail", - "feeOnTransfer": { - "message": "Some tokens take a fee when they are bought or sold, which is set by the token issuer. Uniswap does not receive any share of these fees.", - "title": "Why is there an additional fee?" - }, - "insufficientBalance": { - "button": "Not enough {{currencySymbol}}", - "title": "You don’t have enough {{currencySymbol}}" - }, - "insufficientGas": { - "button": "Not enough {{currencySymbol}}", - "cta": { - "button": "Buy {{currencySymbol}}", - "message": "You need more <highlight>{{currencySymbol}}</highlight> to cover the network cost for this transaction." - }, - "title": "You don’t have enough {{currencySymbol}} to cover the network cost" - }, - "lowLiquidity": { - "message": "There isn’t currently enough liquidity available between these tokens to perform a swap. Please try again later or select another token.", - "title": "Not enough liquidity" - }, - "networkFee": { - "message": "This is the cost to process your transaction on the blockchain. Uniswap does not receive any share of these fees." - }, - "offline": { - "message": "You may have lost internet connection or the network may be down. Please check your internet connection and try again.", - "title": "You’re offline" - }, - "priceImpact": { - "message": "Due to the amount of {{outputCurrencySymbol}} liquidity currently available, the more {{inputCurrencySymbol}} you try to swap, the less {{outputCurrencySymbol}} you will receive.", - "title": "High price impact ({{priceImpactValue}})" - }, - "rateLimit": { - "message": "Please try again in a few minutes.", - "title": "Rate limit exceeded" - }, - "router": { - "message": "You may have lost connection or the network may be down. If the problem persists, please try again later.", - "title": "This trade cannot be completed right now" - }, - "uniswapFee": { - "message": { - "default": "Fees are applied on a few select tokens to ensure the best experience with Uniswap. There is no fee associated with this swap.", - "included": "Fees are applied on a few select tokens to ensure the best experience with Uniswap, and have already been factored into this quote." - }, - "title": "Swap fee" - }, - "viewOnly": { - "message": "You need to import this wallet via recovery phrase to swap tokens." - } - } - }, - "token": { - "balances": { - "main": "Your balance", - "other": "Balances on other networks", - "viewOnly": "{{ownerAddress}}’s balance" - }, - "error": { - "unknown": "Unknown token" - }, - "links": { - "contract": "Contract", - "title": "Links", - "twitter": "Twitter", - "website": "Website" - }, - "priceExplorer": { - "error": { - "description": "Something went wrong.", - "title": "Couldn’t load price chart" - }, - "timeRangeLabel": { - "day": "1D", - "hour": "1H", - "month": "1M", - "week": "1W", - "year": "1Y" - } - }, - "safetyLevel": { - "blocked": { - "header": "Not available", - "message": "You can’t trade this token using the Uniswap Wallet." - }, - "medium": { - "header": "Caution", - "message": "This token isn’t traded on leading U.S. centralized exchanges. Always conduct your own research before trading." - }, - "strong": { - "header": "Warning", - "message": "This token isn’t traded on leading U.S. centralized exchanges or frequently swapped on Uniswap. Always conduct your own research before trading." - } - }, - "selector": { - "search": { - "error": "Couldn’t load search results" - } - }, - "stats": { - "fullyDilutedValuation": "Fully Diluted Valuation", - "marketCap": "Market Cap", - "priceHighYear": "52W High", - "priceLowYear": "52W Low", - "section": { - "about": "About {{token}}" - }, - "title": "Stats", - "translation": { - "original": "Show original", - "translate": "Translate to {{language}}" - }, - "volume": "24h Volume" - } - }, - "tokens": { - "action": { - "hide": "Hide Token", - "unhide": "Unhide Token" - }, - "hidden": { - "label": "Hidden ({{numHidden}})" - }, - "nfts": { - "collection": { - "error": { - "load": { - "title": "Couldn’t load NFT collection" - } - }, - "label": { - "items": "Items", - "owners": "Owners", - "priceFloor": "Floor", - "swapVolume": "Volume" - } - }, - "details": { - "error": { - "load": { - "title": "Couldn’t load NFT details" - } - }, - "owner": "Owned by", - "price": "Current price", - "recentPrice": "Last sale price", - "traits": "Traits" - }, - "empty": { - "description": "No NFTs found" - }, - "error": { - "unavailable": "Content not available" - }, - "hidden": { - "action": { - "hide": "Hide NFT", - "unhide": "Unhide NFT" - }, - "label": "Hidden ({{numHidden}})" - }, - "link": { - "collection": "Collection website" - }, - "list": { - "error": { - "load": { - "title": "Couldn’t load NFTs" - } - }, - "none": { - "button": "Receive NFTs", - "description": { - "default": "Transfer NFTs from another wallet to get started.", - "external": "When this wallet buys or receives NFTs, they’ll appear here." - }, - "title": "No NFTs yet" - } - } - }, - "selector": { - "button": { - "choose": "Choose token", - "clear": "Clear all" - }, - "empty": { - "buy": { - "message": "Buy crypto with a card or bank to send tokens.", - "title": "Buy crypto" - }, - "receive": { - "message": "Transfer tokens from a centralized exchange or another wallet to send tokens.", - "title": "Receive tokens" - }, - "title": "No tokens yet" - }, - "error": { - "load": "Couldn’t load tokens" - }, - "search": { - "empty": "No results found for <highlight>{{searchText}}</highlight>", - "placeholder": "Search tokens" - }, - "section": { - "favorite": "Favorites", - "popular": "Popular tokens", - "recent": "Recent searches", - "search": "Search results", - "suggested": "Suggested", - "yours": "Your tokens" - } - } - }, - "transaction": { - "action": { - "cancel": { - "button": "Cancel transaction", - "description": "If you cancel this transaction before it’s processed by the network, you’ll pay a new network cost instead of the original one.", - "title": "Cancel this transaction?" - }, - "copy": "Copy transaction ID", - "copyMoonPay": "Copy MoonPay transaction ID", - "view": "View {{tokenSymbol}}", - "viewEtherscan": "View on {{blockExplorerName}}", - "viewMoonPay": "View on MoonPay" - }, - "amount": { - "unlimited": "Unlimited" - }, - "currency": { - "unknown": "unknown token" - }, - "date": "Submitted on {{date}}", - "network": { - "all": "All networks" - }, - "networkCost": { - "label": "Network cost" - }, - "notification": { - "error": { - "cancel": "Unable to cancel transaction", - "replace": "Unable to replace transaction" - } - }, - "priceImpact": { - "label": "Price impact" - }, - "status": { - "approve": { - "canceled": "Canceled approve", - "canceling": "Canceling approve", - "failed": "Failed to approve", - "pending": "Approving", - "success": "Approved", - "successDapp": "Approved on {{externalDappName}}" - }, - "buy": { - "canceled": "Canceled buy", - "canceling": "Canceling buy", - "failed": "Failed to buy", - "pending": "Buying", - "success": "Bought", - "successDapp": "Bought on {{externalDappName}}" - }, - "confirm": { - "canceled": "Canceled confirm", - "canceling": "Canceling confirm", - "failed": "Failed to confirm", - "pending": "Transaction in progress", - "success": "Transaction confirmed", - "successDapp": "Transaction confirmed on {{externalDappName}}" - }, - "mint": { - "canceled": "Canceled mint", - "canceling": "Canceling mint", - "failed": "Failed to mint", - "pending": "Minting", - "success": "Minted", - "successDapp": "Minted on {{externalDappName}}" - }, - "purchase": { - "canceled": "Canceled purchase", - "canceling": "Canceling purchase", - "failed": "Failed to purchase", - "pending": "Purchasing", - "success": "Purchased", - "successDapp": "Purchased on {{externalDappName}}" - }, - "receive": { - "canceled": "Canceled receive", - "canceling": "Canceling receive", - "failed": "Failed to receive", - "pending": "Receiving", - "success": "Received", - "successDapp": "Received on {{externalDappName}}" - }, - "revoke": { - "canceled": "Canceled revoke", - "canceling": "Canceling revoke", - "failed": "Failed to revoke", - "pending": "Revoking", - "success": "Revoked", - "successDapp": "Revoked on {{externalDappName}}" - }, - "sell": { - "canceled": "Canceled sell", - "canceling": "Canceling sell", - "failed": "Failed to sell", - "pending": "Selling", - "success": "Sold", - "successDapp": "Sold on {{externalDappName}}" - }, - "send": { - "canceled": "Canceled send", - "canceling": "Canceling send", - "failed": "Failed to send", - "pending": "Sending", - "success": "Sent", - "successDapp": "Sent on {{externalDappName}}" - }, - "swap": { - "canceled": "Canceled swap", - "canceling": "Canceling swap", - "failed": "Failed to swap", - "pending": "Swapping", - "success": "Swapped", - "successDapp": "Swapped on {{externalDappName}}" - }, - "unwrap": { - "canceled": "Canceled unwrap", - "canceling": "Canceling unwrap", - "failed": "Failed to unwrap", - "pending": "Unwrapping", - "success": "Unwrapped", - "successDapp": "Unwrapped on {{externalDappName}}" - }, - "wrap": { - "canceled": "Canceled wrap", - "canceling": "Canceling wrap", - "failed": "Failed to wrap", - "pending": "Wrapping", - "success": "Wrapped", - "successDapp": "Wrapped on {{externalDappName}}" - } - }, - "summary": { - "received": "{{tokenAmountWithSymbol}} to {{recipientAddress}}", - "sent": "{{tokenAmountWithSymbol}} from {{senderAddress}}" - }, - "watcher": { - "error": { - "cancel": "Unable to cancel transaction", - "status": "Error while checking transaction status" - } - } - }, - "unicons": { - "banner": { - "button": "Got it", - "subtitle": "We gave your wallet’s unique Unicon a makeover. Check out the rest of your accounts to see your upgraded icons.", - "title": "Your Unicon got a new look" - } - }, - "unitags": { - "banner": { - "button": { - "claim": "Claim now" - }, - "subtitle": "Build a personalized web3 profile and easily share your address with friends.", - "title": { - "compact": "<highlight>Claim your {{unitagDomain}} username</highlight> and build out your customizable profile.", - "full": "Claim your {{unitagDomain}} username" - } - }, - "choosePhoto": { - "option": { - "cameraRoll": "Choose from camera roll", - "nft": "Choose an NFT", - "remove": "Remove profile picture" - } - }, - "claim": { - "confirmation": { - "customize": "Customize profile", - "description": "{{unitagAddress}} is ready to send and receive crypto. Continue to build out your wallet by customizing your web3 profile.", - "success": { - "long": "You got it!", - "short": "got it!" - } - }, - "error": { - "addressLimit": "You already have made the maximum number of changes to your username for this address", - "appCheck": "Could not claim username. Please try again tomorrow.", - "avatar": "Could not set avatar. Try again later.", - "default": "Could not claim username. Try again later.", - "deviceLimit": "You have hit the maximum number of usernames that can be active for this device", - "ens": "To claim this username you must own the {{username}}.eth ENS", - "ensMismatch": "This username is not currently available.", - "general": "Unable to claim username", - "unavailable": "This username is not available", - "unknown": "Unknown error" - }, - "username": { - "default": "yourname" - } - }, - "delete": { - "confirm": { - "subtitle": "You’re about to delete your username and customizable profile details. You will not be able to reclaim it.", - "title": "Are you sure?" - } - }, - "editProfile": { - "placeholder": "username" - }, - "editUsername": { - "button": { - "confirm": "Save changes" - }, - "confirm": { - "subtitle": "You’re about to change your username. Once you change it, you can never claim it again.", - "title": "Are you sure?" - }, - "title": "Edit username", - "warning": { - "default": "Once you change your username, you can never claim it again. You can only change it 2 times.", - "max": "You’ve reached the maximum number of 2 usernames changes." - } - }, - "intro": { - "features": { - "ens": "Powered by ENS subdomains", - "free": "Free to claim", - "profile": "Customizable profiles" - }, - "subtitle": "Say goodbye to 0x addresses. Usernames are readable names that make it easier to send and receive crypto.", - "title": "Introducing usernames" - }, - "notification": { - "delete": { - "error": "Could not delete username. Try again later.", - "title": "Username deleted" - }, - "profile": { - "error": "Could not update profile. Try again later.", - "title": "Profile updated" - }, - "username": { - "error": "Could not change username. Try again later.", - "title": "Username changed" - } - }, - "onboarding": { - "claim": { - "subtitle": "This is your unique name that anyone can send crypto to.", - "title": { - "choose": "Choose your username", - "claim": "Claim your username" - } - }, - "claimPeriod": { - "description": "For a limited time, the username {{username}} is reserved. Import the wallet that owns {{username}}.eth ENS to claim this username or try again after the claim period.", - "link": "Learn more about our <highlight>claim period</highlight>.", - "title": "ENS claim period" - }, - "info": { - "description": "Usernames transform complex 0x addresses into readable names. By claiming a {{unitagDomain}} username, you can easily send and receive crypto and build out a public web3 profile.", - "title": "A simplified address" - }, - "profile": { - "subtitle": "Upload your own or stick with your unique Unicon. You can always change this later.", - "title": "Choose a profile photo" - } - }, - "profile": { - "action": { - "delete": "Delete username", - "edit": "Edit username" - }, - "bio": { - "label": "Bio", - "placeholder": "Type a bio for your profile" - }, - "links": { - "twitter": "Twitter" - } - }, - "username": { - "error": { - "chars": "Usernames can only contain letters and numbers", - "max": "Usernames cannot be more than {{number}} characters", - "min": "Usernames must be at least {{number}} characters", - "uppercase": "Usernames can only contain lowercase letters and numbers" - } - } - }, - "walletConnect": { - "dapps": { - "connection": "<highlight>Connected to </highlight>{{dappNameOrUrl}}", - "empty": { - "description": "Connect to an app by scanning a code via WalletConnect" - }, - "manage": { - "empty": { - "title": "No apps connected" - }, - "title": "Manage connections" - } - }, - "error": { - "connection": { - "message": "Uniswap Wallet currently supports {{chainNames}}. Please only use \"{{dappName}}\" on these chains", - "title": "Connection Error" - }, - "general": { - "message": "There was an issue with WalletConnect. Please try again", - "title": "WalletConnect Error" - }, - "scantastic": { - "message": "There was an issue with your QR code. Please try again", - "title": "Invalid QR Code" - }, - "unsupported": { - "message": "Make sure that you’re scanning a valid WalletConnect or Ethereum address QR code before trying again.", - "title": "Invalid QR Code" - }, - "unsupportedV1": { - "message": "WalletConnect v1 is no longer supported. The application you’re trying to connect to needs to upgrade to WalletConnect v2.", - "title": "Invalid QR Code" - }, - "uwu": { - "scan": "There was an issue scanning this QR code.", - "title": "UwU Link error", - "unsupported": "This QR code is not supported." - } - }, - "pending": { - "button": { - "connect": "Connect" - }, - "switchAccount": "Switch Account", - "switchNetwork": "Switch Network", - "title": "{{dappName}} wants to connect to your wallet" - }, - "permissions": { - "networks": "Networks", - "option": { - "transferAssets": "Transfer your assets without consent", - "viewTokenBalances": "View your token balances", - "viewWalletAddress": "View your wallet address" - }, - "title": "App permissions" - }, - "request": { - "button": { - "sign": "Sign" - }, - "details": { - "label": { - "function": "Function: ", - "recipient": "To: ", - "sending": "Sending: " - } - }, - "error": { - "insufficientFunds": "You don’t have enough {{currencySymbol}} to complete this transaction.", - "network": "Internet or network connection error" - }, - "label": { - "network": "Network" - }, - "warning": { - "general": { - "message": "Be careful: this message may transfer assets", - "transaction": "Be careful: this transaction may transfer assets" - }, - "message": "In order to sign messages or transactions, you’ll need to import the wallet’s recovery phrase.", - "title": "This wallet is in view only mode" - } - } - } - } -} - -export default Resources; diff --git a/packages/wallet/src/state/index.ts b/packages/wallet/src/state/index.ts index ccd9c989cd4..51aada16b13 100644 --- a/packages/wallet/src/state/index.ts +++ b/packages/wallet/src/state/index.ts @@ -1,9 +1,7 @@ import type { Middleware, PreloadedState, Reducer, StoreEnhancer } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit' -import { TypedUseSelectorHook, useSelector } from 'react-redux' import { PersistState } from 'redux-persist' import createSagaMiddleware, { Saga } from 'redux-saga' -import { SagaGenerator, select } from 'typed-redux-saga' import { walletContextValue } from 'wallet/src/features/wallet/context' import { sharedRootReducer } from 'wallet/src/state/reducer' import { rootSaga } from 'wallet/src/state/saga' @@ -76,12 +74,3 @@ export function createStore({ export type RootState = ReturnType<typeof sharedRootReducer> & { saga: Record<string, SagaState> } & { _persist?: PersistState } -export type AppSelector<T> = (state: RootState) => T - -export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector - -// Use in sagas for better typing when selecting from redux state -export function* appSelect<T>(fn: (state: RootState) => T): SagaGenerator<T> { - const state = yield* select(fn) - return state -} diff --git a/packages/wallet/src/state/sharedMigrations.ts b/packages/wallet/src/state/sharedMigrations.ts index ba98dd43aea..1f0ec63ff03 100644 --- a/packages/wallet/src/state/sharedMigrations.ts +++ b/packages/wallet/src/state/sharedMigrations.ts @@ -138,3 +138,15 @@ export function activatePendingAccounts(state: any): any { }, } } + +export function deleteBetaOnboardingState(state: any): any { + const newState = { ...state } + delete newState?.behaviorHistory?.extensionBetaFeedbackState + return newState +} + +export function deleteExtensionOnboardingState(state: any): any { + const newState = { ...state } + delete newState?.behaviorHistory?.extensionOnboardingState + return newState +} diff --git a/packages/wallet/src/test/fixtures/wallet/transactions/fixtures.ts b/packages/wallet/src/test/fixtures/wallet/transactions/fixtures.ts index a145fb766dc..06e8041a191 100644 --- a/packages/wallet/src/test/fixtures/wallet/transactions/fixtures.ts +++ b/packages/wallet/src/test/fixtures/wallet/transactions/fixtures.ts @@ -1,10 +1,10 @@ import { TransactionRequest } from '@ethersproject/providers' import { TradeType } from '@uniswap/sdk-core' +import { Routing } from 'uniswap/src/data/tradingApi/__generated__/index' import { AssetType } from 'uniswap/src/entities/assets' import { faker } from 'uniswap/src/test/shared' import { createFixture } from 'uniswap/src/test/utils' import { WALLET_SUPPORTED_CHAIN_IDS } from 'uniswap/src/types/chains' -import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' import { finalizeTransaction } from 'wallet/src/features/transactions/slice' import { ApproveTransactionInfo, diff --git a/packages/wallet/src/test/render.tsx b/packages/wallet/src/test/render.tsx index 4eca5747905..84ebb1a5d1a 100644 --- a/packages/wallet/src/test/render.tsx +++ b/packages/wallet/src/test/render.tsx @@ -31,6 +31,7 @@ const mockNavigationFunctions: WalletNavigationContextState = { navigateToAccountActivityList: jest.fn(), navigateToAccountTokenList: jest.fn(), navigateToBuyOrReceiveWithEmptyWallet: jest.fn(), + navigateToFiatOnRamp: jest.fn(), navigateToNftDetails: jest.fn(), navigateToNftCollection: jest.fn(), navigateToSwapFlow: jest.fn(), diff --git a/packages/wallet/src/utils/balance.ts b/packages/wallet/src/utils/balance.ts deleted file mode 100644 index c40796a5ab1..00000000000 --- a/packages/wallet/src/utils/balance.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import JSBI from 'jsbi' -import { UniverseChainId } from 'uniswap/src/types/chains' -import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' - -const NATIVE_CURRENCY_DECIMALS = 18 - -// TODO(MOB-181): calculate this in a more scientific way -export const MIN_ETH_FOR_GAS: JSBI = JSBI.multiply( - JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(NATIVE_CURRENCY_DECIMALS - 3)), - JSBI.BigInt(15), -) // .015 ETH - -export const MIN_POLYGON_FOR_GAS: JSBI = JSBI.multiply( - JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(NATIVE_CURRENCY_DECIMALS - 2)), - JSBI.BigInt(6), -) // .06 MATIC - -export const MIN_ARBITRUM_FOR_GAS: JSBI = JSBI.multiply( - JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(NATIVE_CURRENCY_DECIMALS - 4)), - JSBI.BigInt(8), -) // .0008 ETH - -export const MIN_OPTIMISM_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -export const MIN_BASE_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -export const MIN_BNB_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -export const MIN_BLAST_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -export const MIN_AVALANCHE_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS // TODO: find better estimates for Avalanche, Celo & Zora - -export const MIN_CELO_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -export const MIN_ZORA_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -export const MIN_ZKSYNC_FOR_GAS: JSBI = MIN_ARBITRUM_FOR_GAS - -/** - * Given some token amount, return the max that can be spent of it - * https://github.com/Uniswap/interface/blob/main/src/utils/maxAmountSpend.ts - * @param currencyAmount to return max of - */ -export function maxAmountSpend(currencyAmount: Maybe<CurrencyAmount<Currency>>): Maybe<CurrencyAmount<Currency>> { - if (!currencyAmount) { - return undefined - } - if (!currencyAmount.currency.isNative) { - return currencyAmount - } - - let minAmount - switch (currencyAmount.currency.chainId) { - case UniverseChainId.Mainnet: - minAmount = MIN_ETH_FOR_GAS - break - case UniverseChainId.Polygon: - minAmount = MIN_POLYGON_FOR_GAS - break - case UniverseChainId.ArbitrumOne: - minAmount = MIN_ARBITRUM_FOR_GAS - break - case UniverseChainId.Optimism: - minAmount = MIN_OPTIMISM_FOR_GAS - break - case UniverseChainId.Base: - minAmount = MIN_BASE_FOR_GAS - break - case UniverseChainId.Bnb: - minAmount = MIN_BNB_FOR_GAS - break - case UniverseChainId.Blast: - minAmount = MIN_BLAST_FOR_GAS - break - case UniverseChainId.Avalanche: - minAmount = MIN_AVALANCHE_FOR_GAS - break - case UniverseChainId.Celo: - minAmount = MIN_CELO_FOR_GAS - break - case UniverseChainId.Zora: - minAmount = MIN_ZORA_FOR_GAS - break - case UniverseChainId.Zksync: - minAmount = MIN_ZKSYNC_FOR_GAS - break - default: - return undefined - } - - // If amount is negative then set it to 0 - const amount = JSBI.greaterThan(currencyAmount.quotient, minAmount) - ? JSBI.subtract(currencyAmount.quotient, minAmount).toString() - : '0' - - return getCurrencyAmount({ - value: amount, - valueType: ValueType.Raw, - currency: currencyAmount.currency, - }) -} diff --git a/packages/wallet/src/utils/linking.ts b/packages/wallet/src/utils/linking.ts index 9c64b953419..8f752f0349f 100644 --- a/packages/wallet/src/utils/linking.ts +++ b/packages/wallet/src/utils/linking.ts @@ -6,7 +6,7 @@ import { toUniswapWebAppLink } from 'uniswap/src/features/chains/utils' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { currencyIdToChain, currencyIdToGraphQLAddress } from 'uniswap/src/utils/currencyId' import { openUri } from 'uniswap/src/utils/linking' -import { FiatPurchaseTransactionInfo, ServiceProviderInfo } from 'wallet/src/features/transactions/types' +import { ServiceProviderInfo } from 'wallet/src/features/transactions/types' export const UNISWAP_APP_NATIVE_TOKEN = 'NATIVE' @@ -26,10 +26,6 @@ export async function openUniswapHelpLink(): Promise<void> { return openUri(uniswapUrls.helpRequestUrl) } -export async function openMoonpayTransactionLink(info: FiatPurchaseTransactionInfo): Promise<void> { - return openUri(info.explorerUrl ?? 'https://support.moonpay.com/hc/en-gb') -} - const SERVICE_PROVIDER_SUPPORT_URLS: Record<string, string> = { MOONPAY: 'https://www.moonpay.com/contact-us', ROBINHOOD: 'https://robinhood.com/support/articles/how-to-contact-support/', diff --git a/packages/wallet/src/utils/sentry.ts b/packages/wallet/src/utils/sentry.ts index e4473e0b56c..ce30932bd33 100644 --- a/packages/wallet/src/utils/sentry.ts +++ b/packages/wallet/src/utils/sentry.ts @@ -1,6 +1,6 @@ import { ApolloError } from '@apollo/client' import { ErrorEvent, EventHint } from '@sentry/types' -import { MissingI18nInterpolationError } from 'uniswap/src/i18n/i18n' +import { MissingI18nInterpolationError } from 'uniswap/src/i18n/shared' const APOLLO_HTTP_ERROR_REGEX = /Received status code ([0-9]+)/ diff --git a/packages/wallet/src/utils/useNoYoloParser.ts b/packages/wallet/src/utils/useNoYoloParser.ts index dbd7ad302e7..cbab19fa28a 100644 --- a/packages/wallet/src/utils/useNoYoloParser.ts +++ b/packages/wallet/src/utils/useNoYoloParser.ts @@ -1,21 +1,32 @@ import { JsonRpcProvider } from '@ethersproject/providers' -import { ExplorerAbiFetcher, Parser, ProxyAbiFetcher } from 'no-yolo-signatures' -import { useMemo } from 'react' +import { ExplorerAbiFetcher, Parser, ProxyAbiFetcher, Transaction, TransactionDescription } from 'no-yolo-signatures' +import { useEffect, useMemo, useState } from 'react' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { RPCType, WalletChainId } from 'uniswap/src/types/chains' +import { EthTransaction } from 'uniswap/src/types/walletConnect' +import { logger } from 'utilities/src/logger/logger' + +export function useNoYoloParser( + transaction: EthTransaction, + chainId?: WalletChainId, +): { parsedTransactionData: TransactionDescription | undefined; isLoading: boolean } { + const [isLoading, setIsLoading] = useState(true) + const [parsedTransactionData, setParsedTransactionData] = useState<TransactionDescription | undefined>(undefined) + const { from, to, value, data } = transaction -export function useNoYoloParser(chainId: WalletChainId): Parser { const parser = useMemo(() => { + if (!chainId) { + return new Parser({ abiFetchers: [] }) + } + const rpcUrls = UNIVERSE_CHAIN_INFO[chainId].rpcUrls const apiURL = UNIVERSE_CHAIN_INFO[chainId].explorer.apiURL || '' const explorerAbiFetcher = new ExplorerAbiFetcher(apiURL) + // TODO: revisit this once quicknode RPCs are added and then prioritize rpcUrls?.appOnly?.http[0] const rpcUrl = - rpcUrls?.appOnly?.http[0] || - rpcUrls?.default?.http[0] || - rpcUrls?.[RPCType.Public]?.http[0] || - rpcUrls?.[RPCType.PublicAlt]?.http[0] + rpcUrls?.default?.http[0] || rpcUrls?.[RPCType.Public]?.http[0] || rpcUrls?.[RPCType.PublicAlt]?.http[0] const provider = new JsonRpcProvider(rpcUrl) const proxyAbiFetcher = new ProxyAbiFetcher(provider, [explorerAbiFetcher]) @@ -23,5 +34,31 @@ export function useNoYoloParser(chainId: WalletChainId): Parser { return new Parser({ abiFetchers: [proxyAbiFetcher, explorerAbiFetcher] }) }, [chainId]) - return parser + useEffect(() => { + const parseResult = async (): Promise<TransactionDescription | undefined> => { + // no-yolo-parser library expects these fields to be defined + if (!from || !to || !data) { + return + } + return parser.parseAsResult(transaction as Transaction).then((result) => { + if (!result.transactionDescription.ok) { + throw result.transactionDescription.error + } + + return result.transactionDescription.result + }) + } + + parseResult() + .then(setParsedTransactionData) + .catch((error) => { + setParsedTransactionData(undefined) + logger.warn('RequestMessage', 'DecodedDataDetails', 'Could not parse data', error) + }) + .finally(() => { + setIsLoading(false) + }) + }, [data, from, parser, to, transaction, value]) + + return { parsedTransactionData, isLoading } } diff --git a/packages/wallet/src/utils/useTransactionCurrencies.ts b/packages/wallet/src/utils/useTransactionCurrencies.ts new file mode 100644 index 00000000000..ece3154e7e9 --- /dev/null +++ b/packages/wallet/src/utils/useTransactionCurrencies.ts @@ -0,0 +1,44 @@ +import { Result } from 'ethers/lib/utils' +import { TransactionDescription } from 'no-yolo-signatures' +import { useTokenProjects } from 'uniswap/src/features/dataApi/tokenProjects' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { WalletChainId } from 'uniswap/src/types/chains' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { isAddress } from 'utilities/src/addresses' + +export function useTransactionCurrencies(args: { + chainId?: WalletChainId + to?: string + parsedTransactionData?: TransactionDescription +}): CurrencyInfo[] { + const { chainId, to, parsedTransactionData } = args + const addresses = parseAddressesFromArgData(parsedTransactionData?.args) + + const addressesFound = [...(to ? [to] : []), ...addresses] + const currencyIdsInvolved = chainId ? addressesFound.map((address) => buildCurrencyId(chainId, address)) : [] + const currenciesInvolved = useTokenProjects(currencyIdsInvolved) + const chainCurrencies = currenciesInvolved?.data?.filter( + (c) => c.currency.chainId === chainId && !c.currency.isNative, + ) + + return chainCurrencies || [] +} + +// recursively parse smart contract arguments and finds all addresses involved in a transaction +function parseAddressesFromArgData(args?: Result): string[] { + const addresses: string[] = [] + + args?.forEach((arg) => { + if (Array.isArray(arg)) { + parseAddressesFromArgData(arg) + } + + if (typeof arg === 'string' && isAddress(arg)) { + if (!addresses.includes(arg)) { + addresses.push(arg) + } + } + }) + + return addresses +} diff --git a/scripts/check-circular-imports.sh b/scripts/check-circular-imports.sh index 92234e64d7e..d4cdfce228d 100755 --- a/scripts/check-circular-imports.sh +++ b/scripts/check-circular-imports.sh @@ -17,7 +17,7 @@ if [[ "$expected" == "0" ]]; then echo "$out" exit 1 fi -elif [[ "$out" == *"Found $expected circular dependencies"* ]]; then +elif [[ "$out" == *"Found $expected circular dependencies"* || "$out" == *"Found $expected circular dependency"* ]]; then echo "Passed!" echo "" echo "$out" diff --git a/apps/web/scripts/crowdin.sh b/scripts/crowdin-web.sh similarity index 63% rename from apps/web/scripts/crowdin.sh rename to scripts/crowdin-web.sh index 3dbb7e0c359..7cfe95a1c81 100755 --- a/apps/web/scripts/crowdin.sh +++ b/scripts/crowdin-web.sh @@ -1,5 +1,7 @@ #!/bin/bash +file="./packages/uniswap/src/i18n/locales/web-translations/es-ES.json" + if [ -n "$ONLY_IF_MISSING" ]; then if [ -e "$file" ]; then echo "Translation exist already, skipping download" @@ -7,14 +9,6 @@ if [ -n "$ONLY_IF_MISSING" ]; then fi fi -if [ ! -e "$file" ]; then - echo "File does not exist." - # Do something here, for example: - # touch "$file" # Create the file -else - echo "File exists." -fi - # install in CI if ! which crowdin >/dev/null 2>&1; then echo "Installing" @@ -23,8 +17,8 @@ fi if [ -n "$CROWDIN_WEB_ACCESS_TOKEN" ]; then echo "Running crowdin $@ for project ID: $CROWDIN_WEB_PROJECT_ID" - npx crowdin "$@" + npx crowdin "$@" -c crowdin-web.yml else echo "Running crowdin using dotenv" - npx dotenv -e ../../.env.defaults.local -- npx crowdin "$@" + npx dotenv -e .env.defaults.local -- npx crowdin "$@" -c crowdin-web.yml fi diff --git a/scripts/ensure-i18n-alphabetized.js b/scripts/ensure-i18n-alphabetized.js index 9e5f4866174..563d96701dd 100644 --- a/scripts/ensure-i18n-alphabetized.js +++ b/scripts/ensure-i18n-alphabetized.js @@ -1,12 +1,10 @@ const fs = require('fs/promises') async function check(file) { - const json = JSON.parse( - await fs.readFile(file) - ) + const json = JSON.parse(await fs.readFile(file)) const keys = Object.keys(json) const sortedKeys = [...keys].sort() - + // check alphabetized for (const [index, key] in sortedKeys.entries()) { if (keys[index] !== key) { @@ -16,51 +14,7 @@ async function check(file) { } } - // check no duplicate values - const entries = Object.entries(json) - const seen = new Map() - const duplicates = {} - for (const [key, value] of entries) { - const existing = seen.get(value) - if (existing) { - const isAdjective = (str) => str.includes('.adjective') - - // if both are tagged as adjectives, or both are not tagged as adjectives, treat as duplicate - if (isAdjective(existing) === isAdjective(key)) { - duplicates[value] = [key, existing] - } - } - seen.set(value, key) - } - - if (Object.keys(duplicates).length){ - console.error(`Found duplicate values, please de-dupe!\n`, JSON.stringify(duplicates, null, 2)) - process.exit(1) - } - console.log(` ✅ Translations keys are sorted alphabetically and properly de-duped`) } -check('apps/web/src/i18n/locales/source/en-US.json') - -const arrayDifference = (arr1, arr2) => { - const counts = {} - - // Count occurrences of each element in arr2 - for (const val of arr2) { - if (counts[val]) { - counts[val]++ - } else { - counts[val] = 1 - } - } - - // Filter arr1 based on the counts - return arr1.filter(val => { - if (counts[val]) { - counts[val]-- - return false - } - return true - }) -}; +check('packages/uniswap/src/i18n/locales/web-source/en-US.json') diff --git a/turbo.json b/turbo.json index 6f1bf6bc611..d9f03d6cfcd 100644 --- a/turbo.json +++ b/turbo.json @@ -6,7 +6,7 @@ "package.json" ] }, - "wallet#tradingapi:generate": { + "uniswap#tradingapi:generate": { "inputs": [ "src/data/tradingApi/api.json" ], @@ -32,6 +32,7 @@ "contracts", "i18n:generate", "graphql:generate", + "tradingapi:generate", "^prepare" ] }, @@ -40,7 +41,6 @@ "package.json" ], "dependsOn": [ - "tradingapi:generate", "^prepare" ] }, diff --git a/yarn.lock b/yarn.lock index 9f32bfdaf93..6af0309c24c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -311,6 +311,43 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/crc32@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32@npm:5.2.0" + dependencies: + "@aws-crypto/util": ^5.2.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^2.6.2 + checksum: 1ddf7ec3fccf106205ff2476d90ae1d6625eabd47752f689c761b71e41fe451962b7a1c9ed25fe54e17dd747a62fbf4de06030fe56fe625f95285f6f70b96c57 + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": ^5.2.0 + "@aws-crypto/supports-web-crypto": ^5.2.0 + "@aws-crypto/util": ^5.2.0 + "@aws-sdk/types": ^3.222.0 + "@aws-sdk/util-locate-window": ^3.0.0 + "@smithy/util-utf8": ^2.0.0 + tslib: ^2.6.2 + checksum: 773f12f2026d82a6bb4a23a8f491894a6d32525bd9b8bfbc12896526cf11882a7607a671c478c45f9cd7d6ba1caaed48a62b67c6f725244bd83a1275108f46c7 + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": ^5.2.0 + "@aws-sdk/types": ^3.222.0 + tslib: ^2.6.2 + checksum: 007fbe0436d714d0d0d282e2b61c90e45adcb9ad75eac9ac7ba03d32b56624afd09b2a9ceb4d659661cf17c51d74d1900ab6b00eacafc002da1101664955ca53 + languageName: node + linkType: hard + "@aws-crypto/sha256-js@npm:^1.2.0": version: 1.2.2 resolution: "@aws-crypto/sha256-js@npm:1.2.2" @@ -322,6 +359,15 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: ^2.6.2 + checksum: 6ffc21de48b2b2c3e918193101d7e8fe949d47b37688892e1c39eaedaa938be80c0f404fe1c874c30cce16781026777a53bf47d5d90143ca91d0feb7c4a6f830 + languageName: node + linkType: hard + "@aws-crypto/util@npm:^1.2.2": version: 1.2.2 resolution: "@aws-crypto/util@npm:1.2.2" @@ -333,13 +379,676 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/types@npm:^3.1.0, @aws-sdk/types@npm:^3.25.0": - version: 3.511.0 - resolution: "@aws-sdk/types@npm:3.511.0" +"@aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": ^3.222.0 + "@smithy/util-utf8": ^2.0.0 + tslib: ^2.6.2 + checksum: f0f81d9d2771c59946cfec48b86cb23d39f78a966c4a1f89d4753abdc3cb38de06f907d1e6450059b121d48ac65d612ab88bdb70014553a077fc3dabddfbf8d6 + languageName: node + linkType: hard + +"@aws-sdk/client-cloudwatch-logs@npm:^3.537.0": + version: 3.616.0 + resolution: "@aws-sdk/client-cloudwatch-logs@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/client-sso-oidc": 3.616.0 + "@aws-sdk/client-sts": 3.616.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/eventstream-serde-browser": ^3.0.4 + "@smithy/eventstream-serde-config-resolver": ^3.0.3 + "@smithy/eventstream-serde-node": ^3.0.4 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + uuid: ^9.0.1 + checksum: 6aa13c23e747f40be0a80ec6e7bede214c8a2c67b326b16fc4b105519135026dd381dfc7cf3cb875940961454d57d82131f879f074c5f649e70a0dbe567b6b68 + languageName: node + linkType: hard + +"@aws-sdk/client-cognito-identity@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/client-cognito-identity@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/client-sso-oidc": 3.616.0 + "@aws-sdk/client-sts": 3.616.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: 8ff406d4ac7a47315e3261e90358fb9ebaacc9f3f327c81e984c11a09f77f83a089f0df5534fd6721c8df587f9af1a71baf96640407665571738a0b149d20d23 + languageName: node + linkType: hard + +"@aws-sdk/client-iam@npm:^3.535.0": + version: 3.616.0 + resolution: "@aws-sdk/client-iam@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/client-sso-oidc": 3.616.0 + "@aws-sdk/client-sts": 3.616.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + "@smithy/util-waiter": ^3.1.2 + tslib: ^2.6.2 + checksum: def0ab7a921b8423bb935629bf53ed9f77791c36964c4d955056bfd6b005f12c96a40aee30a693602fbc2cb97945f61131af712e6de4acea0245aebf7cdeb300 + languageName: node + linkType: hard + +"@aws-sdk/client-lambda@npm:^3.536.0": + version: 3.616.0 + resolution: "@aws-sdk/client-lambda@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/client-sso-oidc": 3.616.0 + "@aws-sdk/client-sts": 3.616.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/eventstream-serde-browser": ^3.0.4 + "@smithy/eventstream-serde-config-resolver": ^3.0.3 + "@smithy/eventstream-serde-node": ^3.0.4 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-stream": ^3.1.0 + "@smithy/util-utf8": ^3.0.0 + "@smithy/util-waiter": ^3.1.2 + tslib: ^2.6.2 + checksum: 5e8678565008b571ee7531c4ae2e7f40e712aea1e14670b4e581b72dfd52a2bbfd9be9479bd37afda60feb9ac5dd9d19d2c8cffb2daed90fe83d0616ec9eddb0 + languageName: node + linkType: hard + +"@aws-sdk/client-sfn@npm:^3.535.0": + version: 3.616.0 + resolution: "@aws-sdk/client-sfn@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/client-sso-oidc": 3.616.0 + "@aws-sdk/client-sts": 3.616.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + uuid: ^9.0.1 + checksum: 37a9adb696bc22660ee75d62199ef512b0d4bc31bb877e98014c174bf2c79385636c71392e0e36d2b0325557a4fa48a30a844e37c4d87df932b7933cf0def470 + languageName: node + linkType: hard + +"@aws-sdk/client-sso-oidc@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/client-sso-oidc@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/client-sts": ^3.616.0 + checksum: cc6fab0e7369b0dbb7d03dbfcdc4e1dedd9bf395ed468c0c22b0c141ab35fc286d27f54a075584395dd5ca8a134682e9aa119e95b52694fb061aa8c389d6fc42 + languageName: node + linkType: hard + +"@aws-sdk/client-sso@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/client-sso@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: 0a0d5560a84b381caad36264cc3760a0aa2c1dfa980429dc55c582447e7e7242f0947963815f7b91c0b92fc4b3b95507fd37cf9eb155b5409a6f9303f6efaed7 + languageName: node + linkType: hard + +"@aws-sdk/client-sts@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/client-sts@npm:3.616.0" + dependencies: + "@aws-crypto/sha256-browser": 5.2.0 + "@aws-crypto/sha256-js": 5.2.0 + "@aws-sdk/client-sso-oidc": 3.616.0 + "@aws-sdk/core": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/middleware-host-header": 3.616.0 + "@aws-sdk/middleware-logger": 3.609.0 + "@aws-sdk/middleware-recursion-detection": 3.616.0 + "@aws-sdk/middleware-user-agent": 3.616.0 + "@aws-sdk/region-config-resolver": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@aws-sdk/util-user-agent-browser": 3.609.0 + "@aws-sdk/util-user-agent-node": 3.614.0 + "@smithy/config-resolver": ^3.0.5 + "@smithy/core": ^2.2.7 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/hash-node": ^3.0.3 + "@smithy/invalid-dependency": ^3.0.3 + "@smithy/middleware-content-length": ^3.0.4 + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.10 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-body-length-browser": ^3.0.0 + "@smithy/util-body-length-node": ^3.0.0 + "@smithy/util-defaults-mode-browser": ^3.0.10 + "@smithy/util-defaults-mode-node": ^3.0.10 + "@smithy/util-endpoints": ^2.0.5 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: bad2619661085259ccfa2cd95f721ed1dc479c6871a8927648d182c6fbe3d25989a904d6b9430eac762c1d2a25d370a89879b9d1f2375f2cbfa869949288643f + languageName: node + linkType: hard + +"@aws-sdk/core@npm:3.616.0, @aws-sdk/core@npm:^3.535.0": + version: 3.616.0 + resolution: "@aws-sdk/core@npm:3.616.0" + dependencies: + "@smithy/core": ^2.2.7 + "@smithy/protocol-http": ^4.0.4 + "@smithy/signature-v4": ^4.0.0 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + fast-xml-parser: 4.2.5 + tslib: ^2.6.2 + checksum: b19c43578beba8e90c1dac2a4842012e0ac2469fb0a8a72801677268447f5de2ad92ebcadc83826a7bfc360ef345c1686f2f76a04fc77f478e1c7512759789a9 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-cognito-identity@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/credential-provider-cognito-identity@npm:3.616.0" + dependencies: + "@aws-sdk/client-cognito-identity": 3.616.0 + "@aws-sdk/types": 3.609.0 + "@smithy/property-provider": ^3.1.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 3aada71f75b633badfaf84c7cb8f4e6899f19d49e99a18cd1d3e591acf3e5aa495293e25ead945e2833630bae07a7bceca885fbf122ce8a4e6b4778251c29c46 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:3.609.0": + version: 3.609.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.609.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/property-provider": ^3.1.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: eda20122740481d04f5110fb9349df339562da1e1d5217e6c47e5f80ed0cce1b3bea01081272487bf04e402fcecc2734a352b0b57ae80b090dd8a0b3547ad185 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.616.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/property-provider": ^3.1.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.8 + "@smithy/types": ^3.3.0 + "@smithy/util-stream": ^3.1.0 + tslib: ^2.6.2 + checksum: a1afc3d78bc2496b57583a0d4e2ce080ba6f365c5b84aba39b070e51daee677256b32b8dcd93278c3c82a9c1288b2691c8f02624d23e819817fd55fa8377ddb4 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:3.616.0, @aws-sdk/credential-provider-ini@npm:^3.535.0": + version: 3.616.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.616.0" + dependencies: + "@aws-sdk/credential-provider-env": 3.609.0 + "@aws-sdk/credential-provider-http": 3.616.0 + "@aws-sdk/credential-provider-process": 3.614.0 + "@aws-sdk/credential-provider-sso": 3.616.0 + "@aws-sdk/credential-provider-web-identity": 3.609.0 + "@aws-sdk/types": 3.609.0 + "@smithy/credential-provider-imds": ^3.1.4 + "@smithy/property-provider": ^3.1.3 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/client-sts": ^3.616.0 + checksum: 2de4455b8bc58ebed180954d04e4f3de35a390778156a99a5581b7ebbf9adf01df6166f3dc60129a465865f110d30352b740ee92591169a1cb56d11e5ea21d38 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.616.0" dependencies: - "@smithy/types": ^2.9.1 - tslib: ^2.5.0 - checksum: a2082c64b4aabecde26010a48f8535a90c8d3e666e63f82773f8b07dadd7c1ff05d4639c60ddfb7d3533fe4ac55259e34a23995ca868c13e6d7718a1392a6eb8 + "@aws-sdk/credential-provider-env": 3.609.0 + "@aws-sdk/credential-provider-http": 3.616.0 + "@aws-sdk/credential-provider-ini": 3.616.0 + "@aws-sdk/credential-provider-process": 3.614.0 + "@aws-sdk/credential-provider-sso": 3.616.0 + "@aws-sdk/credential-provider-web-identity": 3.609.0 + "@aws-sdk/types": 3.609.0 + "@smithy/credential-provider-imds": ^3.1.4 + "@smithy/property-provider": ^3.1.3 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 9a66c9401eb152711a69010bfe9adc55fedd445d4d9754bd26490bf7b75c6606486dde9495893f893998ba74786ff4703ba94f0bdef92e2aa4c0d5baa605757a + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:3.614.0": + version: 3.614.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.614.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/property-provider": ^3.1.3 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 8bbbbf66911f38818e801187ae8df000e92b4e1c0dbe6d6b9afae81e08fb771302d2dc86c459653a2ed71acc10b9773885ae28d6fbce0031e082e9a6e61c85ee + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.616.0" + dependencies: + "@aws-sdk/client-sso": 3.616.0 + "@aws-sdk/token-providers": 3.614.0 + "@aws-sdk/types": 3.609.0 + "@smithy/property-provider": ^3.1.3 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 773fb35df0bb769964dd1da86e9a498620ba411b664e9ef968ba33d222dbc29849eb95a556f11bb23a3893141815db9be098cba3c99dd0148b34f116f5e1ef56 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:3.609.0": + version: 3.609.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.609.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/property-provider": ^3.1.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/client-sts": ^3.609.0 + checksum: 7a95a6c4792491122677fab6f01a9a46c8aa2f94d95255430bbd3fdcd514ab05ecf92c0ab169c8b30215b6b9181165f8d009774ba5a39cdd633162ef30879e56 + languageName: node + linkType: hard + +"@aws-sdk/credential-providers@npm:^3.535.0": + version: 3.616.0 + resolution: "@aws-sdk/credential-providers@npm:3.616.0" + dependencies: + "@aws-sdk/client-cognito-identity": 3.616.0 + "@aws-sdk/client-sso": 3.616.0 + "@aws-sdk/client-sts": 3.616.0 + "@aws-sdk/credential-provider-cognito-identity": 3.616.0 + "@aws-sdk/credential-provider-env": 3.609.0 + "@aws-sdk/credential-provider-http": 3.616.0 + "@aws-sdk/credential-provider-ini": 3.616.0 + "@aws-sdk/credential-provider-node": 3.616.0 + "@aws-sdk/credential-provider-process": 3.614.0 + "@aws-sdk/credential-provider-sso": 3.616.0 + "@aws-sdk/credential-provider-web-identity": 3.609.0 + "@aws-sdk/types": 3.609.0 + "@smithy/credential-provider-imds": ^3.1.4 + "@smithy/property-provider": ^3.1.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: d3fa4e49e1d1f7bf9d6ad5eea2859164bc633a0ca98eb92b701e18e91623670b1b36155a8ce540d222d745ee534b04468b07d0b2a34a108d141fa399e5e15543 + languageName: node + linkType: hard + +"@aws-sdk/middleware-host-header@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.616.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/protocol-http": ^4.0.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 7936068785a58e35adf96b90d6e72d9defca2d1051992bfd7bf5bbc150d000942ff587151d27d40276942d430817bac9985ab68d926333dfb581983b6236a21c + languageName: node + linkType: hard + +"@aws-sdk/middleware-logger@npm:3.609.0": + version: 3.609.0 + resolution: "@aws-sdk/middleware-logger@npm:3.609.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: b6f67a2e9ba082c8aec9d45905ae45ea5a95896f1beecb0c2d7fecfe17dd8fad99513f43b11ed7fd6ca9ff7764a0fc1ce63af91b1baed92b36f7b4b5390be5c6 + languageName: node + linkType: hard + +"@aws-sdk/middleware-recursion-detection@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.616.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/protocol-http": ^4.0.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 43bd173705125f07e44c0c0feb85af0edba1503fe629d9eacdcc446d45d038fca6148415a9f721d80a80a5dab390585ef122823f30bd8e06d723f523c6fc58c3 + languageName: node + linkType: hard + +"@aws-sdk/middleware-user-agent@npm:3.616.0": + version: 3.616.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.616.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@aws-sdk/util-endpoints": 3.614.0 + "@smithy/protocol-http": ^4.0.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 6525d9061e0993f338c6dbb2c55e3e094aa02801d0814824cd4a0c0d9810e0f82fc7af4f6f2010723b18a856da241c3daded3fd9bc16b991cffef5f3031f0941 + languageName: node + linkType: hard + +"@aws-sdk/region-config-resolver@npm:3.614.0": + version: 3.614.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.614.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/types": ^3.3.0 + "@smithy/util-config-provider": ^3.0.0 + "@smithy/util-middleware": ^3.0.3 + tslib: ^2.6.2 + checksum: dbaca50792c99685845b21dd4a53228613e0458ee517a21db941890ee521d91eff80704f08e9ee71b6f04e70fb86362c4823750bb0b3727240af68d78d8fa4be + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.614.0": + version: 3.614.0 + resolution: "@aws-sdk/token-providers@npm:3.614.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/property-provider": ^3.1.3 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/client-sso-oidc": ^3.614.0 + checksum: 2901b8428afc3b76ff1df9ac29a2698db6bf65d1d2afcd8424b9bf187313d2a3ca747c3b205afeb5c132068b5a5a94d84ce82710f775fa0cbb79499d7fea2d64 + languageName: node + linkType: hard + +"@aws-sdk/types@npm:3.609.0, @aws-sdk/types@npm:^3.1.0, @aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.25.0": + version: 3.609.0 + resolution: "@aws-sdk/types@npm:3.609.0" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 522768d08f104065b0ff6a37eddaa7803186014acee1c0011b3dbd3ef841e47ae694e58f608aeec8a39d22d644d759ade996fe51d18b880617778dc2dbbe1ede + languageName: node + linkType: hard + +"@aws-sdk/util-endpoints@npm:3.614.0": + version: 3.614.0 + resolution: "@aws-sdk/util-endpoints@npm:3.614.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/types": ^3.3.0 + "@smithy/util-endpoints": ^2.0.5 + tslib: ^2.6.2 + checksum: 9d9973ceee59bf30af85c7f4328083daea033a987ec396dcb89eb7649f470ceb19c6b96635e121f3557e726f7ec7453236c956cf43f22128883c277f17d2a13f languageName: node linkType: hard @@ -352,6 +1061,44 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.568.0 + resolution: "@aws-sdk/util-locate-window@npm:3.568.0" + dependencies: + tslib: ^2.6.2 + checksum: 354db5187beee4203c7ec6583556ab14ecde9644c06aaa51fa2528131836d3fc73035a3b080c904e108c49defce20d5562893113b93d819b70497f47989bb578 + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-browser@npm:3.609.0": + version: 3.609.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.609.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/types": ^3.3.0 + bowser: ^2.11.0 + tslib: ^2.6.2 + checksum: 75ba1ae74dd1001f47870766d92b66ac02a0a488efcf42c1a368962a7978a778d99536e880f07f7db1c2ca66cc9b1863fd3342957a22dcf78bf2f4398265a7a5 + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-node@npm:3.614.0": + version: 3.614.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.614.0" + dependencies: + "@aws-sdk/types": 3.609.0 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 1f010080c2301fd836908963a235ef39e597d959e27461d15d4958fa582ab20795022f8cb7429c183c386f558a5c125cb254a0c4e844dbc6422169f4884be34a + languageName: node + linkType: hard + "@aws-sdk/util-utf8-browser@npm:^3.0.0": version: 3.259.0 resolution: "@aws-sdk/util-utf8-browser@npm:3.259.0" @@ -2765,6 +3512,83 @@ __metadata: languageName: node linkType: hard +"@datadog/datadog-ci@npm:2.39.0": + version: 2.39.0 + resolution: "@datadog/datadog-ci@npm:2.39.0" + dependencies: + "@aws-sdk/client-cloudwatch-logs": ^3.537.0 + "@aws-sdk/client-iam": ^3.535.0 + "@aws-sdk/client-lambda": ^3.536.0 + "@aws-sdk/client-sfn": ^3.535.0 + "@aws-sdk/core": ^3.535.0 + "@aws-sdk/credential-provider-ini": ^3.535.0 + "@aws-sdk/credential-providers": ^3.535.0 + "@google-cloud/logging": ^11.0.0 + "@google-cloud/run": ^1.0.2 + "@smithy/property-provider": ^2.0.12 + "@smithy/util-retry": ^2.0.4 + "@types/datadog-metrics": 0.6.1 + "@types/retry": 0.12.0 + ajv: ^8.12.0 + ajv-formats: ^2.1.1 + async-retry: 1.3.1 + axios: ^1.6.8 + chalk: 3.0.0 + clipanion: ^3.2.1 + datadog-metrics: 0.9.3 + deep-extend: 0.6.0 + deep-object-diff: ^1.1.9 + fast-xml-parser: ^4.2.5 + form-data: 4.0.0 + fuzzy: ^0.1.3 + glob: 7.1.4 + google-auth-library: ^8.9.0 + inquirer: ^8.2.5 + inquirer-checkbox-plus-prompt: ^1.4.2 + js-yaml: 3.13.1 + jszip: ^3.10.1 + ora: 5.4.1 + proxy-agent: ^6.4.0 + rimraf: ^3.0.2 + semver: ^7.5.3 + simple-git: 3.16.0 + ssh2: ^1.15.0 + ssh2-streams: 0.4.10 + sshpk: 1.16.1 + terminal-link: 2.1.1 + tiny-async-pool: ^2.1.0 + typanion: ^3.14.0 + uuid: ^9.0.0 + ws: ^7.5.10 + xml2js: 0.5.0 + yamux-js: 0.1.2 + bin: + datadog-ci: dist/cli.js + checksum: 4dfa17fdb6180a65c1227f9dc742f1b3a0da20bd51e00e98dbe706dbd297bb69bc67e8b5de2f5a4ca5d5cc63ade9bab9994601424b000a9270703cc568efdf11 + languageName: node + linkType: hard + +"@datadog/mobile-react-native@npm:2.4.1": + version: 2.4.1 + resolution: "@datadog/mobile-react-native@npm:2.4.1" + peerDependencies: + react: ">=16.13.1" + react-native: ">=0.63.4 <1.0" + checksum: 7c7ce70a09b5f743556d5bc410dc57f11aeb43eb183211898394cd0b37e40f6102c47369eba9c76d844b069b8e225f276887ef9fc4c4346b656322bbc750bb9e + languageName: node + linkType: hard + +"@datadog/mobile-react-navigation@npm:2.4.1": + version: 2.4.1 + resolution: "@datadog/mobile-react-navigation@npm:2.4.1" + peerDependencies: + "@datadog/mobile-react-native": ^2.0.1 + react: ">=16.13.1" + react-native: ">=0.63.4 <1.0" + checksum: 9df2859b1581bd69688f97dc5ebdefd59a18a0c646ea741e0657d1fd7437d181806345980057d58611807fa7d48bf73fdbe7208c9b16268f256fc8ee24b9d2c3 + languageName: node + linkType: hard + "@dependents/detective-less@npm:^3.0.1": version: 3.0.2 resolution: "@dependents/detective-less@npm:3.0.2" @@ -4097,67 +4921,67 @@ __metadata: languageName: node linkType: hard -"@floating-ui/core@npm:^1.6.0": - version: 1.6.0 - resolution: "@floating-ui/core@npm:1.6.0" +"@floating-ui/core@npm:^1.0.0, @floating-ui/core@npm:^1.6.0": + version: 1.6.4 + resolution: "@floating-ui/core@npm:1.6.4" dependencies: - "@floating-ui/utils": ^0.2.1 - checksum: 2e25c53b0c124c5c9577972f8ae21d081f2f7895e6695836a53074463e8c65b47722744d6d2b5a993164936da006a268bcfe87fe68fd24dc235b1cb86bed3127 + "@floating-ui/utils": ^0.2.4 + checksum: 6855472c00ceaa14e0f1cb4bd5de0de01d05cd46bdf12cb19bd6a89fa70bdfba0460a776dc50d28ab40e3bddc291e2211958497528fdd98653ea7260d61e0442 languageName: node linkType: hard -"@floating-ui/dom@npm:^1.6.1": - version: 1.6.1 - resolution: "@floating-ui/dom@npm:1.6.1" +"@floating-ui/dom@npm:^1.0.0": + version: 1.6.7 + resolution: "@floating-ui/dom@npm:1.6.7" dependencies: "@floating-ui/core": ^1.6.0 - "@floating-ui/utils": ^0.2.1 - checksum: 5565e4dee612bab62950913c311d75d3f773bd1d9dc437f7e33b46340f32ec565733c995c6185381adaf64e627df3c79901d0a9d555f58c02509d0764bceb57d + "@floating-ui/utils": ^0.2.4 + checksum: 66605a2948bfe7532408197b4c522fecf04cf11e7839623d0dca0d22362b42d64a5db2f3be865053e9b0d44c89faf1befa9a4ce1b7fa595d1b3dc82f635d079c languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.0.6, @floating-ui/react-dom@npm:^2.0.8": - version: 2.0.8 - resolution: "@floating-ui/react-dom@npm:2.0.8" +"@floating-ui/react-dom@npm:^2.0.6, @floating-ui/react-dom@npm:^2.1.1": + version: 2.1.1 + resolution: "@floating-ui/react-dom@npm:2.1.1" dependencies: - "@floating-ui/dom": ^1.6.1 + "@floating-ui/dom": ^1.0.0 peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 5da7f13a69281e38859a3203a608fe9de1d850b332b355c10c0c2427c7b7209a0374c10f6295b6577c1a70237af8b678340bd4cc0a4b1c66436a94755d81e526 + checksum: 6d1a023e6b0a3f298117223d8cdb0a4767f24469d193181da7002f692b756ccafb1e9756c242fa0c072f8ab8a5710ea7cf5cf2a6e92278d1fcd6f0fc0586c27c languageName: node linkType: hard "@floating-ui/react-native@npm:^0.10.3": - version: 0.10.4 - resolution: "@floating-ui/react-native@npm:0.10.4" + version: 0.10.6 + resolution: "@floating-ui/react-native@npm:0.10.6" dependencies: - "@floating-ui/core": ^1.6.0 + "@floating-ui/core": ^1.0.0 peerDependencies: react: ">=16.8.0" react-native: ">=0.64.0" - checksum: bc13291345380cc16a5be45353c606b949995c38215cc0c41e66ce39db1f85102ac3cb5ba2280a1df05e67d432c0912d3a91fa7481723dbde2525403937da8a6 + checksum: 06c701132c43ed3d0d49729e17c551d21a67a053795cdec576afc645dd7092157404924660fecbdadb58ebbd7b43693a3f99b094458a58d2859705d43423fb73 languageName: node linkType: hard "@floating-ui/react@npm:^0.26.6": - version: 0.26.8 - resolution: "@floating-ui/react@npm:0.26.8" + version: 0.26.20 + resolution: "@floating-ui/react@npm:0.26.20" dependencies: - "@floating-ui/react-dom": ^2.0.8 - "@floating-ui/utils": ^0.2.1 - tabbable: ^6.0.1 + "@floating-ui/react-dom": ^2.1.1 + "@floating-ui/utils": ^0.2.5 + tabbable: ^6.0.0 peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 7ceeb2df46bb82c4703b93c12d80f431230502591dabb8c1214b01c6bfae0c496aad8d0dcb7dab4033c136c4811e731323fb61f2fdc5703e599097017d617fa0 + checksum: efabcb9370a9bd040f81c20695791ab6f4ce3333fdd7d5ea8a3ec5b9e888b2b58a8c8567eff950ab938899e267bbef3e90ea291fa4b597b1a89839f2c929cb2f languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.1": - version: 0.2.1 - resolution: "@floating-ui/utils@npm:0.2.1" - checksum: 9ed4380653c7c217cd6f66ae51f20fdce433730dbc77f95b5abfb5a808f5fdb029c6ae249b4e0490a816f2453aa6e586d9a873cd157fdba4690f65628efc6e06 +"@floating-ui/utils@npm:^0.2.4, @floating-ui/utils@npm:^0.2.5": + version: 0.2.5 + resolution: "@floating-ui/utils@npm:0.2.5" + checksum: 32834fe0fec5ee89187f8defd0b10813d725dab7dc6ed1545ded6655630bac5d438f0c991d019d675585e118846f12391236fc2886a5c73a57576e7de3eca3f9 languageName: node linkType: hard @@ -4308,6 +5132,79 @@ __metadata: languageName: node linkType: hard +"@google-cloud/common@npm:^5.0.0": + version: 5.0.2 + resolution: "@google-cloud/common@npm:5.0.2" + dependencies: + "@google-cloud/projectify": ^4.0.0 + "@google-cloud/promisify": ^4.0.0 + arrify: ^2.0.1 + duplexify: ^4.1.1 + extend: ^3.0.2 + google-auth-library: ^9.0.0 + html-entities: ^2.5.2 + retry-request: ^7.0.0 + teeny-request: ^9.0.0 + checksum: 13c3af95830c1410edb52b9a1bb8cbaf1b47e63be6049eae9c06b728225fd59f6acce1d8cdba575c14a2bb7e929acf9320bf8aec3f67409d920143a90a69dc53 + languageName: node + linkType: hard + +"@google-cloud/logging@npm:^11.0.0": + version: 11.1.0 + resolution: "@google-cloud/logging@npm:11.1.0" + dependencies: + "@google-cloud/common": ^5.0.0 + "@google-cloud/paginator": ^5.0.0 + "@google-cloud/projectify": ^4.0.0 + "@google-cloud/promisify": ^4.0.0 + arrify: ^2.0.1 + dot-prop: ^6.0.0 + eventid: ^2.0.0 + extend: ^3.0.2 + gcp-metadata: ^6.0.0 + google-auth-library: ^9.0.0 + google-gax: ^4.0.3 + on-finished: ^2.3.0 + pumpify: ^2.0.1 + stream-events: ^1.0.5 + uuid: ^9.0.0 + checksum: 603dd493bd2a1db501e86d80293c6afbe56772b60efa557b38b82fa5837354a1e8328b5e73382030b660cd9691755a33f74b914dd6330a151f32b11b09dbed57 + languageName: node + linkType: hard + +"@google-cloud/paginator@npm:^5.0.0": + version: 5.0.2 + resolution: "@google-cloud/paginator@npm:5.0.2" + dependencies: + arrify: ^2.0.0 + extend: ^3.0.2 + checksum: eeb4a387807270ba9f69f22d7439d60c5bd6663573c2da9ea7d998c373d77671d77450b87f0f229c28418df654af4064e70554fa4dcde7edb3c0f5c05f208246 + languageName: node + linkType: hard + +"@google-cloud/projectify@npm:^4.0.0": + version: 4.0.0 + resolution: "@google-cloud/projectify@npm:4.0.0" + checksum: 973d28414ae200433333a3c315aebb881ced42ea4afe6f3f8520d2fecded75e76c913f5189fea8fb29ce6ca36117c4f44001b3c503eecdd3ac7f02597a98354a + languageName: node + linkType: hard + +"@google-cloud/promisify@npm:^4.0.0": + version: 4.0.0 + resolution: "@google-cloud/promisify@npm:4.0.0" + checksum: edd189398c5ed5b7b64a373177d77c87d076a248c31b8ae878bb91e2411d89860108bcb948c349f32628973a823bd131beb53ec008fd613a8cb466ef1d89de49 + languageName: node + linkType: hard + +"@google-cloud/run@npm:^1.0.2": + version: 1.3.0 + resolution: "@google-cloud/run@npm:1.3.0" + dependencies: + google-gax: ^4.0.3 + checksum: e3f8c1adee61e18b73faebc1011db95d8f594b9bedc61af2f5125f8bd0cc6c8b45285c6df3aee9d24ecb1da6d9f97292069acb3a8abd9f4a866ba2e6e0ed5377 + languageName: node + linkType: hard + "@gorhom/bottom-sheet@npm:4.5.1": version: 4.5.1 resolution: "@gorhom/bottom-sheet@npm:4.5.1" @@ -5115,6 +6012,30 @@ __metadata: languageName: node linkType: hard +"@grpc/grpc-js@npm:^1.10.9": + version: 1.11.1 + resolution: "@grpc/grpc-js@npm:1.11.1" + dependencies: + "@grpc/proto-loader": ^0.7.13 + "@js-sdsl/ordered-map": ^4.4.2 + checksum: 906894851a13b09f5a95052e1bec572b1b5760cc53635e80b50155c7546591cc8b8768d2723e2ec2ff5d7b086f65a27809db345fd3038fd50e3fc5e827ee88c6 + languageName: node + linkType: hard + +"@grpc/proto-loader@npm:^0.7.13": + version: 0.7.13 + resolution: "@grpc/proto-loader@npm:0.7.13" + dependencies: + lodash.camelcase: ^4.3.0 + long: ^5.0.0 + protobufjs: ^7.2.5 + yargs: ^17.7.2 + bin: + proto-loader-gen-types: build/bin/proto-loader-gen-types.js + checksum: 399c1b8a4627f93dc31660d9636ea6bf58be5675cc7581e3df56a249369e5be02c6cd0d642c5332b0d5673bc8621619bc06fb045aa3e8f57383737b5d35930dc + languageName: node + linkType: hard + "@hapi/hoek@npm:^9.0.0": version: 9.3.0 resolution: "@hapi/hoek@npm:9.3.0" @@ -5903,6 +6824,13 @@ __metadata: languageName: node linkType: hard +"@js-sdsl/ordered-map@npm:^4.4.2": + version: 4.4.2 + resolution: "@js-sdsl/ordered-map@npm:4.4.2" + checksum: a927ae4ff8565ecb75355cc6886a4f8fadbf2af1268143c96c0cce3ba01261d241c3f4ba77f21f3f017a00f91dfe9e0673e95f830255945c80a0e96c6d30508a + languageName: node + linkType: hard + "@jsdevtools/ono@npm:^7.1.3": version: 7.1.3 resolution: "@jsdevtools/ono@npm:7.1.3" @@ -5917,6 +6845,22 @@ __metadata: languageName: node linkType: hard +"@kwsites/file-exists@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/file-exists@npm:1.1.1" + dependencies: + debug: ^4.1.1 + checksum: 4ff945de7293285133aeae759caddc71e73c4a44a12fac710fdd4f574cce2671a3f89d8165fdb03d383cfc97f3f96f677d8de3c95133da3d0e12a123a23109fe + languageName: node + linkType: hard + +"@kwsites/promise-deferred@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/promise-deferred@npm:1.1.1" + checksum: 07455477a0123d9a38afb503739eeff2c5424afa8d3dbdcc7f9502f13604488a4b1d9742fc7288832a52a6422cf1e1c0a1d51f69a39052f14d27c9a0420b6629 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -6225,14 +7169,14 @@ __metadata: linkType: hard "@motionone/animation@npm:^10.12.0, @motionone/animation@npm:^10.15.1, @motionone/animation@npm:^10.16.3": - version: 10.16.3 - resolution: "@motionone/animation@npm:10.16.3" + version: 10.18.0 + resolution: "@motionone/animation@npm:10.18.0" dependencies: - "@motionone/easing": ^10.16.3 - "@motionone/types": ^10.16.3 - "@motionone/utils": ^10.16.3 + "@motionone/easing": ^10.18.0 + "@motionone/types": ^10.17.1 + "@motionone/utils": ^10.18.0 tslib: ^2.3.1 - checksum: 797cacea335e6f892af27579eff51450dcf18c5bbc5c0ca44a000929b21857f4afb974ffb411c4935bfbd01ef2ddb3ef542ba3313ae66e1e5392b5d314df6ad3 + checksum: 841cb9f4843a89e5e4560b9f960f52cbe78afc86f87c769f71e9edb3aadd53fb87982b7e11914428f228b29fd580756be531369c2ffac06432550afa4e87d1c3 languageName: node linkType: hard @@ -6264,24 +7208,24 @@ __metadata: languageName: node linkType: hard -"@motionone/easing@npm:^10.16.3": - version: 10.16.3 - resolution: "@motionone/easing@npm:10.16.3" +"@motionone/easing@npm:^10.18.0": + version: 10.18.0 + resolution: "@motionone/easing@npm:10.18.0" dependencies: - "@motionone/utils": ^10.16.3 + "@motionone/utils": ^10.18.0 tslib: ^2.3.1 - checksum: 03e2460cdd35ee4967a86ce28ffbaaaca589263f659f652801cf6bd667baba9b3d5ce6d134df6b64413b60b34dd21d7c38b0cd8a4c3e1ed789789cdb971905b2 + checksum: 6bd37f7a9d5a88f868cc0ad6e47d2ba8d9fefd7da84fccfea7ed77ec08c2e6d1e42df88dda462665102a5cf03f748231a1a077de7054b5a8ccb0fbf36f61b1e7 languageName: node linkType: hard "@motionone/generators@npm:^10.12.0, @motionone/generators@npm:^10.16.4": - version: 10.16.4 - resolution: "@motionone/generators@npm:10.16.4" + version: 10.18.0 + resolution: "@motionone/generators@npm:10.18.0" dependencies: - "@motionone/types": ^10.16.3 - "@motionone/utils": ^10.16.3 + "@motionone/types": ^10.17.1 + "@motionone/utils": ^10.18.0 tslib: ^2.3.1 - checksum: 185091c5cfbe67c38e84bf3920d1b5862e5d7eb624136494a7e4779b2f9d06855ebe3e633d95dcc5a1735d92d59d1ae28a0724c2f9d8bddd60fc9bc3603fab48 + checksum: 51a0e075681697b11d0771998cac8c76a745f00141502f81adb953896992b7f49478965e4afe696bc83361afaae8d2f1057d71c25b21035fe67258ff73764f1c languageName: node linkType: hard @@ -6295,21 +7239,21 @@ __metadata: languageName: node linkType: hard -"@motionone/types@npm:^10.12.0, @motionone/types@npm:^10.15.1, @motionone/types@npm:^10.16.3": - version: 10.16.3 - resolution: "@motionone/types@npm:10.16.3" - checksum: ff38982f5aff2c0abbc3051c843d186d6f954c971e97dd6fced97a4ef50ee04f6e49607541ebb80e14dd143cf63553c388392110e270d04eca23f6b529f7f321 +"@motionone/types@npm:^10.12.0, @motionone/types@npm:^10.15.1, @motionone/types@npm:^10.16.3, @motionone/types@npm:^10.17.1": + version: 10.17.1 + resolution: "@motionone/types@npm:10.17.1" + checksum: 3fa74db64e371e61a7f7669d7d541d11c9a8dd871032d59c69041e3b2e07a67ad2ed8767cb9273bac90eed4e1f76efc1f14c8673c2e9a288f6070ee0fef64a25 languageName: node linkType: hard -"@motionone/utils@npm:^10.12.0, @motionone/utils@npm:^10.15.1, @motionone/utils@npm:^10.16.3": - version: 10.16.3 - resolution: "@motionone/utils@npm:10.16.3" +"@motionone/utils@npm:^10.12.0, @motionone/utils@npm:^10.15.1, @motionone/utils@npm:^10.16.3, @motionone/utils@npm:^10.18.0": + version: 10.18.0 + resolution: "@motionone/utils@npm:10.18.0" dependencies: - "@motionone/types": ^10.16.3 + "@motionone/types": ^10.17.1 hey-listen: ^1.0.8 tslib: ^2.3.1 - checksum: d06025911c54c2217c98026cd38d4d681268a2b9b2830ac7342820881ba6be09721dd03626f52547749ead0543d5e2f2a69c9270ffdeaabc0949f7afb3233817 + checksum: a27f9afde693a0cbbbcb33962b12bbe40dd2cfa514b0732f3c7953c5ef4beed738e1e8172a2de89e3b9f74a253ef0a70d7f3efb730be97b77d7176a3ffacb67a languageName: node linkType: hard @@ -7127,6 +8071,79 @@ __metadata: languageName: node linkType: hard +"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/aspromise@npm:1.1.2" + checksum: 011fe7ef0826b0fd1a95935a033a3c0fd08483903e1aa8f8b4e0704e3233406abb9ee25350ec0c20bbecb2aad8da0dcea58b392bbd77d6690736f02c143865d2 + languageName: node + linkType: hard + +"@protobufjs/base64@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/base64@npm:1.1.2" + checksum: 67173ac34de1e242c55da52c2f5bdc65505d82453893f9b51dc74af9fe4c065cf4a657a4538e91b0d4a1a1e0a0642215e31894c31650ff6e3831471061e1ee9e + languageName: node + linkType: hard + +"@protobufjs/codegen@npm:^2.0.4": + version: 2.0.4 + resolution: "@protobufjs/codegen@npm:2.0.4" + checksum: 59240c850b1d3d0b56d8f8098dd04787dcaec5c5bd8de186fa548de86b86076e1c50e80144b90335e705a044edf5bc8b0998548474c2a10a98c7e004a1547e4b + languageName: node + linkType: hard + +"@protobufjs/eventemitter@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/eventemitter@npm:1.1.0" + checksum: 0369163a3d226851682f855f81413cbf166cd98f131edb94a0f67f79e75342d86e89df9d7a1df08ac28be2bc77e0a7f0200526bb6c2a407abbfee1f0262d5fd7 + languageName: node + linkType: hard + +"@protobufjs/fetch@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/fetch@npm:1.1.0" + dependencies: + "@protobufjs/aspromise": ^1.1.1 + "@protobufjs/inquire": ^1.1.0 + checksum: 3fce7e09eb3f1171dd55a192066450f65324fd5f7cc01a431df01bb00d0a895e6bfb5b0c5561ce157ee1d886349c90703d10a4e11a1a256418ff591b969b3477 + languageName: node + linkType: hard + +"@protobufjs/float@npm:^1.0.2": + version: 1.0.2 + resolution: "@protobufjs/float@npm:1.0.2" + checksum: 5781e1241270b8bd1591d324ca9e3a3128d2f768077a446187a049e36505e91bc4156ed5ac3159c3ce3d2ba3743dbc757b051b2d723eea9cd367bfd54ab29b2f + languageName: node + linkType: hard + +"@protobufjs/inquire@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/inquire@npm:1.1.0" + checksum: ca06f02eaf65ca36fb7498fc3492b7fc087bfcc85c702bac5b86fad34b692bdce4990e0ef444c1e2aea8c034227bd1f0484be02810d5d7e931c55445555646f4 + languageName: node + linkType: hard + +"@protobufjs/path@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/path@npm:1.1.2" + checksum: 856eeb532b16a7aac071cacde5c5620df800db4c80cee6dbc56380524736205aae21e5ae47739114bf669ab5e8ba0e767a282ad894f3b5e124197cb9224445ee + languageName: node + linkType: hard + +"@protobufjs/pool@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/pool@npm:1.1.0" + checksum: d6a34fbbd24f729e2a10ee915b74e1d77d52214de626b921b2d77288bd8f2386808da2315080f2905761527cceffe7ec34c7647bd21a5ae41a25e8212ff79451 + languageName: node + linkType: hard + +"@protobufjs/utf8@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/utf8@npm:1.1.0" + checksum: f9bf3163d13aaa3b6f5e6fbf37a116e094ea021c0e1f2a7ccd0e12a29e2ce08dafba4e8b36e13f8ed7397e1591610ce880ed1289af4d66cf4ace8a36a9557278 + languageName: node + linkType: hard + "@reach/dialog@npm:0.10.5": version: 0.10.5 resolution: "@reach/dialog@npm:0.10.5" @@ -7754,6 +8771,13 @@ __metadata: languageName: node linkType: hard +"@react-native/normalize-colors@npm:^0.74.1": + version: 0.74.85 + resolution: "@react-native/normalize-colors@npm:0.74.85" + checksum: d2aef06be265c27ec89e1bec8f3a6869a62300479fbafdabd5e06323cf22a892189d42f9f613cc48c48f97351634c9ce98b07e565d9344714bb2627e5aae4c60 + languageName: node + linkType: hard + "@react-native/virtualized-lists@npm:0.73.4": version: 0.73.4 resolution: "@react-native/virtualized-lists@npm:0.73.4" @@ -8792,12 +9816,582 @@ __metadata: languageName: node linkType: hard -"@smithy/types@npm:^2.9.1": - version: 2.9.1 - resolution: "@smithy/types@npm:2.9.1" +"@smithy/abort-controller@npm:^3.1.1": + version: 3.1.1 + resolution: "@smithy/abort-controller@npm:3.1.1" dependencies: - tslib: ^2.5.0 - checksum: 8570affb4abb5d0ead57293977fc915d44be481120defcabb87a3fb1c7b5d2501b117835eca357b5d54ea4bbee08032f9dc3d909ecbf0abb0cec2ca9678ae7bd + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 7b7497f49d58787cad858f8c5ea9931ccd44d39536db4abdd531a5abf37784469522e41d9ad1d541892caa0ed3bea750447809a0a18f4689a9543d672aa61d48 + languageName: node + linkType: hard + +"@smithy/config-resolver@npm:^3.0.5": + version: 3.0.5 + resolution: "@smithy/config-resolver@npm:3.0.5" + dependencies: + "@smithy/node-config-provider": ^3.1.4 + "@smithy/types": ^3.3.0 + "@smithy/util-config-provider": ^3.0.0 + "@smithy/util-middleware": ^3.0.3 + tslib: ^2.6.2 + checksum: 96895ae0622a229655fa08f009d29a20157043020125014e84cb5ca33a10171c9724c309491214c2422d9c4c6681e7f5ec5f7faa8f45e11250449cf07f3552ec + languageName: node + linkType: hard + +"@smithy/core@npm:^2.2.7": + version: 2.2.8 + resolution: "@smithy/core@npm:2.2.8" + dependencies: + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-retry": ^3.0.11 + "@smithy/middleware-serde": ^3.0.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/smithy-client": ^3.1.9 + "@smithy/types": ^3.3.0 + "@smithy/util-middleware": ^3.0.3 + tslib: ^2.6.2 + checksum: c6dbf5e7ce509779e57889a67036e67f7c9ba39ce93eac087162997105d4afd14b34a5b145ffdcf2c56d1afa65661fc0b42705705c140a79cf0cea78f7739919 + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^3.1.4": + version: 3.1.4 + resolution: "@smithy/credential-provider-imds@npm:3.1.4" + dependencies: + "@smithy/node-config-provider": ^3.1.4 + "@smithy/property-provider": ^3.1.3 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + tslib: ^2.6.2 + checksum: c75a653970f5e7b888dddbcb916fadd2c45fe59b1a776de9b44f39771b3941fb536684d2407aef88ce376afa6024f38759290db966b07e9213c49a9427ea4a7c + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^3.1.2": + version: 3.1.2 + resolution: "@smithy/eventstream-codec@npm:3.1.2" + dependencies: + "@aws-crypto/crc32": 5.2.0 + "@smithy/types": ^3.3.0 + "@smithy/util-hex-encoding": ^3.0.0 + tslib: ^2.6.2 + checksum: b0c836acbf59b57a7e2ef948a54bd441d11b75d70f1c334723c27fce1ab0ff93ea9f936976b754272b5e90413b5a169c60b1df7ecfd7d061ebaae8d5cc067d94 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-browser@npm:^3.0.4": + version: 3.0.5 + resolution: "@smithy/eventstream-serde-browser@npm:3.0.5" + dependencies: + "@smithy/eventstream-serde-universal": ^3.0.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 14e8a2027745e7a1ad261068e792e4a660043ce53fefc5f564b38b841ba02d40992b38fbd2357e762f0a1ecb658df3bbf23cf5ef33c3ec2488d316be95b61b9e + languageName: node + linkType: hard + +"@smithy/eventstream-serde-config-resolver@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/eventstream-serde-config-resolver@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: c61780aa0ad8c479618d0b3fcb2b42f1f9a74dcf814dba08305107ed1f088f56aa1c346db9c72439ff18617f31b9c59c6895060e4c9765c81d759150a22674af + languageName: node + linkType: hard + +"@smithy/eventstream-serde-node@npm:^3.0.4": + version: 3.0.4 + resolution: "@smithy/eventstream-serde-node@npm:3.0.4" + dependencies: + "@smithy/eventstream-serde-universal": ^3.0.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 0a75b184d95ab8c08efd93bf32c5fd9d735b5879df556599bd2ab78f23e3f77452e597bbdd42586c9bbedcc2b0b7683de4c816db739c19a2ebd62a34096ca86d + languageName: node + linkType: hard + +"@smithy/eventstream-serde-universal@npm:^3.0.4": + version: 3.0.4 + resolution: "@smithy/eventstream-serde-universal@npm:3.0.4" + dependencies: + "@smithy/eventstream-codec": ^3.1.2 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 8463403ca4caf4ad48dba89b126f394439a289c9095ce6361c1f186c6021c1cd8ea402d1ce06b7284069c3415091ae4d802f66ded1b89e9da9d4c255b8402668 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^3.2.2": + version: 3.2.2 + resolution: "@smithy/fetch-http-handler@npm:3.2.2" + dependencies: + "@smithy/protocol-http": ^4.0.4 + "@smithy/querystring-builder": ^3.0.3 + "@smithy/types": ^3.3.0 + "@smithy/util-base64": ^3.0.0 + tslib: ^2.6.2 + checksum: ec7f0d648d0bb2e674ca6fda040357c462833825bba6d2b1549de4b6a8d0ffdd17d6effb2dbd56241b58e76f3e7c1afba5f321f3d592c39bf5007b89e9197875 + languageName: node + linkType: hard + +"@smithy/hash-node@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/hash-node@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + "@smithy/util-buffer-from": ^3.0.0 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: 203a3581bec5373e63d42e03f62129022f03d17390e9358a4e25fc1d44c43962ea80ab5bcbb91605e3025e22136bed059665a3b16835f66316f43ed391df9548 + languageName: node + linkType: hard + +"@smithy/invalid-dependency@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/invalid-dependency@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 459b4ae4e47595e8a675ff2e8bfea7f58a41f77138416ea310c89e29312e08963a701cdc354324da9dd578a7995158b4421695365070d74b0276ddff7f701bba + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: ^2.6.2 + checksum: cd12c2e27884fec89ca8966d33c9dc34d3234efe89b33a9b309c61ebcde463e6f15f6a02d31d4fddbfd6e5904743524ca5b95021b517b98fe10957c2da0cd5fc + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/is-array-buffer@npm:3.0.0" + dependencies: + tslib: ^2.6.2 + checksum: ce7440fcb1ce3c46722cff11c33e2f62a9df86d74fa2054a8e6b540302a91211cf6e4e3b1b7aac7030c6c8909158c1b6867c394201fa8afc6b631979956610e5 + languageName: node + linkType: hard + +"@smithy/middleware-content-length@npm:^3.0.4": + version: 3.0.4 + resolution: "@smithy/middleware-content-length@npm:3.0.4" + dependencies: + "@smithy/protocol-http": ^4.0.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 462ed3511b5cf849d272c4a6e1a1b72f6f676252e208ebd652528e3d45f132859cbcbcf9e8cb127680fbbc587ab35965225fd7421a3711f4d125738b3e7f528e + languageName: node + linkType: hard + +"@smithy/middleware-endpoint@npm:^3.0.5": + version: 3.0.5 + resolution: "@smithy/middleware-endpoint@npm:3.0.5" + dependencies: + "@smithy/middleware-serde": ^3.0.3 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + "@smithy/url-parser": ^3.0.3 + "@smithy/util-middleware": ^3.0.3 + tslib: ^2.6.2 + checksum: 4ab0272efd47baa528a04c5413fb224e41be144902680239fffc83cf1fb7e9b5342e8b627a4149136efa2b29baacc84baa4dbcef5fd2fa55c70e169c7f4ba750 + languageName: node + linkType: hard + +"@smithy/middleware-retry@npm:^3.0.10, @smithy/middleware-retry@npm:^3.0.11": + version: 3.0.11 + resolution: "@smithy/middleware-retry@npm:3.0.11" + dependencies: + "@smithy/node-config-provider": ^3.1.4 + "@smithy/protocol-http": ^4.0.4 + "@smithy/service-error-classification": ^3.0.3 + "@smithy/smithy-client": ^3.1.9 + "@smithy/types": ^3.3.0 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-retry": ^3.0.3 + tslib: ^2.6.2 + uuid: ^9.0.1 + checksum: 4061f4823c949f5e9920b4840cfbc1472f38bac05cefc511a7731afa9372dab0fb238f48b6ce7a8d4d24fa966fa80f9eb7165d29fd90fd3854d72006f612d662 + languageName: node + linkType: hard + +"@smithy/middleware-serde@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/middleware-serde@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 6c633bb8957e078d480888bd33d5a8c269a483a1358c2b28c62daecfd442c711c509d9e69302e6b19fc298139ee67cdda63a604e7da0e4ef9005117d8e0897cc + languageName: node + linkType: hard + +"@smithy/middleware-stack@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/middleware-stack@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: f4a450e2ebca0a8a3b4e1bbfad7d7e9c45edccbe1c984a22f2228092a526120748365e8964b478357249675d8bbc28fdaa8a4a19643a3c1d86bd74e1499327c5 + languageName: node + linkType: hard + +"@smithy/node-config-provider@npm:^3.1.4": + version: 3.1.4 + resolution: "@smithy/node-config-provider@npm:3.1.4" + dependencies: + "@smithy/property-provider": ^3.1.3 + "@smithy/shared-ini-file-loader": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 7ea4e7cea93ab154ab89a9d6b2453c8f96b96db18883070d287bc5fa9cfd10091bb00006a15bb7e6ed25810fd1a133d458e45310a8eaa1727a55d4ce2be3ba09 + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^3.1.3": + version: 3.1.3 + resolution: "@smithy/node-http-handler@npm:3.1.3" + dependencies: + "@smithy/abort-controller": ^3.1.1 + "@smithy/protocol-http": ^4.0.4 + "@smithy/querystring-builder": ^3.0.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 2e07687544dc77714912467268db820cb76bffcb0f4cdb5d5f12b05561d8baedb98cb478ceb4e247151e2d7d30af7de88095f9b96037e56f58a371b2a7bab85e + languageName: node + linkType: hard + +"@smithy/property-provider@npm:^2.0.12": + version: 2.2.0 + resolution: "@smithy/property-provider@npm:2.2.0" + dependencies: + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: 8d257cbc5222baf6706e288c3b51196588f135878141f8af76fcb3f0abafc027ed46cf4bb938266d1906111175082ee85f73806d5a2b1c929aee16ec8b5283e6 + languageName: node + linkType: hard + +"@smithy/property-provider@npm:^3.1.3": + version: 3.1.3 + resolution: "@smithy/property-provider@npm:3.1.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 37a3d92267a2a32c2cc17fd1f0ab2b336f75fb7807db88f6194efede9d6a66068658a7effb7773451404fca990924393dbbf3d57e2aca67ef2e489a85666e225 + languageName: node + linkType: hard + +"@smithy/protocol-http@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/protocol-http@npm:4.0.4" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: a0155381d24f02d279f0b895c179e98af0a6dd1c8a1765666c856f0bca41aa7d4a245a228bc17ddde5f68c631ffe8684440051416757169074dfa5c7a7087e94 + languageName: node + linkType: hard + +"@smithy/querystring-builder@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/querystring-builder@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + "@smithy/util-uri-escape": ^3.0.0 + tslib: ^2.6.2 + checksum: 5c46c620d87f9b4e67b8eb543667b0160fb05bbec01d62d45adb94305369dca9e82daba47d81e840fdc399fa47f9b5930ce668d65fe83ee278a1b27d59d0b5d3 + languageName: node + linkType: hard + +"@smithy/querystring-parser@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/querystring-parser@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 1de11cbc4325578b243a0e3e89b46371f4705d3df41ea51b37e8efa655d3b75253180b0fca9ceed8b3955a2d458689f551cd24fd904d0f65647c62c6b08795bf + languageName: node + linkType: hard + +"@smithy/service-error-classification@npm:^2.1.5": + version: 2.1.5 + resolution: "@smithy/service-error-classification@npm:2.1.5" + dependencies: + "@smithy/types": ^2.12.0 + checksum: 00ac54110a258c7a47c62d4f655d4998bd40e5adb47e10281b28df7a585f2f1e960dc35325eac006636280e7fb2b81dbeb32b89e08bac87acc136c4d29a4dc53 + languageName: node + linkType: hard + +"@smithy/service-error-classification@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/service-error-classification@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + checksum: 5bef710f5698c929c97865cba41f36b0c59100b9a1c4478a2d47caeb5e3a1a18077b870b365efaa45c94666f2075bc8978f7a6e8b964afbba3a4e490eb6c13eb + languageName: node + linkType: hard + +"@smithy/shared-ini-file-loader@npm:^3.1.4": + version: 3.1.4 + resolution: "@smithy/shared-ini-file-loader@npm:3.1.4" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: c5321635f3be34e424009fc9045454a9ceec543ec20b3b9719bf3a48bbfc03b794f4545546e9c2dcb0a987de2ca5ff8999df9bf7c166c6fc7685c1fa1f068bc1 + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/signature-v4@npm:4.0.0" + dependencies: + "@smithy/is-array-buffer": ^3.0.0 + "@smithy/types": ^3.3.0 + "@smithy/util-hex-encoding": ^3.0.0 + "@smithy/util-middleware": ^3.0.3 + "@smithy/util-uri-escape": ^3.0.0 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: 9cebd322cbfbc8794f4a21af1152d343c4ec431d0732985e6067d3d0038d2ae970e5f12cd4862b1380a3cd1d86230bdf90a171a93d3cd82b8cbe140a4d3685b0 + languageName: node + linkType: hard + +"@smithy/smithy-client@npm:^3.1.8, @smithy/smithy-client@npm:^3.1.9": + version: 3.1.9 + resolution: "@smithy/smithy-client@npm:3.1.9" + dependencies: + "@smithy/middleware-endpoint": ^3.0.5 + "@smithy/middleware-stack": ^3.0.3 + "@smithy/protocol-http": ^4.0.4 + "@smithy/types": ^3.3.0 + "@smithy/util-stream": ^3.1.1 + tslib: ^2.6.2 + checksum: 2d030ca4dd3e0767e30d3bd78d7eaea19ec96f8b03a8e15b61494ea4719f63d6f25290d2d4269fdbcc2df1912ece1aa8a4b92b5f2c2f3d3c75628002ce0b5b6a + languageName: node + linkType: hard + +"@smithy/types@npm:^2.12.0": + version: 2.12.0 + resolution: "@smithy/types@npm:2.12.0" + dependencies: + tslib: ^2.6.2 + checksum: 2dd93746624d87afbf51c22116fc69f82e95004b78cf681c4a283d908155c22a2b7a3afbd64a3aff7deefb6619276f186e212422ad200df3b42c32ef5330374e + languageName: node + linkType: hard + +"@smithy/types@npm:^3.3.0": + version: 3.3.0 + resolution: "@smithy/types@npm:3.3.0" + dependencies: + tslib: ^2.6.2 + checksum: 29bb5f83c41e32f8d4094a2aba2d3dfbd763ab5943784a700f3fa22df0dcf0ccac1b1907f7a87fbb9f6f2269fcd4750524bcb48f892249e200ffe397c0981309 + languageName: node + linkType: hard + +"@smithy/url-parser@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/url-parser@npm:3.0.3" + dependencies: + "@smithy/querystring-parser": ^3.0.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 86b4bc8e6c176b56076c30233ca4cfeb98d162fe27a348ddfda5f163ce7d173b8e684aa26202bbf4e0b5695b0ad43c0cb40170ca6793652d0ea6edb00443c036 + languageName: node + linkType: hard + +"@smithy/util-base64@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-base64@npm:3.0.0" + dependencies: + "@smithy/util-buffer-from": ^3.0.0 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: 413f26046a7e98b2661a078f218a8d040c820fc5a02f5e364aff58c3957e28fde1ac4048c2ebbad5d87b9da4b9aa98a8d4a7fb0d2ce97def33738bd7d8d79aa0 + languageName: node + linkType: hard + +"@smithy/util-body-length-browser@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-body-length-browser@npm:3.0.0" + dependencies: + tslib: ^2.6.2 + checksum: b01d8258b9a25b262734fc49cefefe48583ba193c3eefd49a6f7fd5922c3015d23dda88b52f3dd9a16827cad16b5b9425eef01e91bd0c71bb5abc469d2952c07 + languageName: node + linkType: hard + +"@smithy/util-body-length-node@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-body-length-node@npm:3.0.0" + dependencies: + tslib: ^2.6.2 + checksum: da1baf4790609d3dc28c88385c7274fdf9b91a641fe3c5af22b78e18156df17bd470181348f43b2c739680936b1dafb1526158dfd817c3d9ecb71e653b4cbe3f + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": ^2.2.0 + tslib: ^2.6.2 + checksum: 424c5b7368ae5880a8f2732e298d17879a19ca925f24ca45e1c6c005f717bb15b76eb28174d308d81631ad457ea0088aab0fd3255dd42f45a535c81944ad64d3 + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-buffer-from@npm:3.0.0" + dependencies: + "@smithy/is-array-buffer": ^3.0.0 + tslib: ^2.6.2 + checksum: 1bfc4ab093fe98132bbc1ccd36a0b9ad75a31ed26bac4b7e9350205513a2481eb190ae44679ab4fecc5e10d367b5e6592bbfbf792671579d17d17bd7f7f233f5 + languageName: node + linkType: hard + +"@smithy/util-config-provider@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-config-provider@npm:3.0.0" + dependencies: + tslib: ^2.6.2 + checksum: fc0f5f57d30261cf3a6693d8e338b9d269332c478ee18d905309a769844188190caf0564855d7e84f6c61e56aa556195dda89f65e8c30791951cf4999e4a70e7 + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-browser@npm:^3.0.10": + version: 3.0.11 + resolution: "@smithy/util-defaults-mode-browser@npm:3.0.11" + dependencies: + "@smithy/property-provider": ^3.1.3 + "@smithy/smithy-client": ^3.1.9 + "@smithy/types": ^3.3.0 + bowser: ^2.11.0 + tslib: ^2.6.2 + checksum: 62536fc7e81a180e30445c94af022223a89346c3c2f2d3fe7e48ec67e198ed31e1de598f6195a3142b6db7edb94b701ad49f52a6ef9ed546b137b97219537014 + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-node@npm:^3.0.10": + version: 3.0.11 + resolution: "@smithy/util-defaults-mode-node@npm:3.0.11" + dependencies: + "@smithy/config-resolver": ^3.0.5 + "@smithy/credential-provider-imds": ^3.1.4 + "@smithy/node-config-provider": ^3.1.4 + "@smithy/property-provider": ^3.1.3 + "@smithy/smithy-client": ^3.1.9 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 3df80c51cf77cd5215e64a48936831fad5f7d2accff3bed1ac62813bbd49da601cbca386b6efe0af67d33ddea423f428df14b4ca750ec7a376eb8a2e95893ba8 + languageName: node + linkType: hard + +"@smithy/util-endpoints@npm:^2.0.5": + version: 2.0.5 + resolution: "@smithy/util-endpoints@npm:2.0.5" + dependencies: + "@smithy/node-config-provider": ^3.1.4 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: bb2a96323f52beaf2820f4e5764c865cff3ac5bca0c0df6923bb4582b0f87faf1606110cd4e36005ac43f41e9673ebdca4bbb8b913880fc2a4e0ff3301250da8 + languageName: node + linkType: hard + +"@smithy/util-hex-encoding@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-hex-encoding@npm:3.0.0" + dependencies: + tslib: ^2.6.2 + checksum: dd32fd71e915825987a18bf7c0f8f0c4956d0b17a0ee71592b5563bb20e04f24dbf81d36161aac07caab3bb5e535cc609fce20aa4a38f66b457c4c6f5c7748d9 + languageName: node + linkType: hard + +"@smithy/util-middleware@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/util-middleware@npm:3.0.3" + dependencies: + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: f37f25d65595af5ff4c3f69fa7e66545ac1651f77979e15ffbc9047e18fc668dae90458ee76add85a49ea3729c49d317e40542d5430e81e2eafe8dcae2ddb3bc + languageName: node + linkType: hard + +"@smithy/util-retry@npm:^2.0.4": + version: 2.2.0 + resolution: "@smithy/util-retry@npm:2.2.0" + dependencies: + "@smithy/service-error-classification": ^2.1.5 + "@smithy/types": ^2.12.0 + tslib: ^2.6.2 + checksum: 1a8071c8ac5a2646b3d3894e3bd9c36a9db045f52eadb194f32b02d2fdedd69fb267a2b02bcef9f91d0f8f3fe061754ac075d07ac166d90894acb27d68c62a41 + languageName: node + linkType: hard + +"@smithy/util-retry@npm:^3.0.3": + version: 3.0.3 + resolution: "@smithy/util-retry@npm:3.0.3" + dependencies: + "@smithy/service-error-classification": ^3.0.3 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: c760595376154be67414083aa6f76094022df72987521469b124ef3ef5848c0536757dcd2006520580380db6a4d7b597a05569470c3151f71d5e678df63f4c13 + languageName: node + linkType: hard + +"@smithy/util-stream@npm:^3.1.0, @smithy/util-stream@npm:^3.1.1": + version: 3.1.1 + resolution: "@smithy/util-stream@npm:3.1.1" + dependencies: + "@smithy/fetch-http-handler": ^3.2.2 + "@smithy/node-http-handler": ^3.1.3 + "@smithy/types": ^3.3.0 + "@smithy/util-base64": ^3.0.0 + "@smithy/util-buffer-from": ^3.0.0 + "@smithy/util-hex-encoding": ^3.0.0 + "@smithy/util-utf8": ^3.0.0 + tslib: ^2.6.2 + checksum: a66ce6ffebfccbf5bf81cfef08f9286839e6d17406203e42d41d611e69da558a0c1ef98b218e5544a07a8171a60792437c3468d92ef41910a8472c052f47c6bc + languageName: node + linkType: hard + +"@smithy/util-uri-escape@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-uri-escape@npm:3.0.0" + dependencies: + tslib: ^2.6.2 + checksum: d7ee01c978e2b08d0a89a3b678f5d5e5d5bb4ab4ab85567a238b1a6195dff1bdaf9ae62497e7f32ff5121b3dc007c370bcb6e8ef79b01fe5acdec5bbce8c7ce4 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": ^2.2.0 + tslib: ^2.6.2 + checksum: 00e55d4b4e37d48be0eef3599082402b933c52a1407fed7e8e8ad76d94d81a0b30b8bfaf2047c59d9c3af31e5f20e7a8c959cb7ae270f894255e05a2229964f0 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^3.0.0": + version: 3.0.0 + resolution: "@smithy/util-utf8@npm:3.0.0" + dependencies: + "@smithy/util-buffer-from": ^3.0.0 + tslib: ^2.6.2 + checksum: d97be1748963263a1161ba80417d82318b977b38542f3fdf0379b0162461188be680e5bfb66a89d65652f0fad6ecf2ab23a43205979216e50602488f73434da3 + languageName: node + linkType: hard + +"@smithy/util-waiter@npm:^3.1.2": + version: 3.1.2 + resolution: "@smithy/util-waiter@npm:3.1.2" + dependencies: + "@smithy/abort-controller": ^3.1.1 + "@smithy/types": ^3.3.0 + tslib: ^2.6.2 + checksum: 35773b1bbbb215102555a55ce4de57cbd3e38f37546ca3e6748ce3856119019a613946b399c6d97981a0bad447ce9c41f87c276325ff4c0e5a2276ee4e9e384e languageName: node linkType: hard @@ -10783,6 +12377,17 @@ __metadata: languageName: node linkType: hard +"@tamagui/remove-scroll@npm:1.102.1": + version: 1.102.1 + resolution: "@tamagui/remove-scroll@npm:1.102.1" + dependencies: + react-remove-scroll: 2.5.5 + peerDependencies: + react: "*" + checksum: 592507c1399189ca16ea16631651b2f08328229621356700d97e6420ed074750124dcac4a7463de662d76181186379431e53928a2dc545dd5fb93a31f296a64d + languageName: node + linkType: hard + "@tamagui/remove-scroll@npm:1.95.1": version: 1.95.1 resolution: "@tamagui/remove-scroll@npm:1.95.1" @@ -11482,6 +13087,13 @@ __metadata: languageName: node linkType: hard +"@tootallnate/quickjs-emscripten@npm:^0.23.0": + version: 0.23.0 + resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" + checksum: c350a2947ffb80b22e14ff35099fd582d1340d65723384a0fd0515e905e2534459ad2f301a43279a37308a27c99273c932e64649abd57d0bb3ca8c557150eccc + languageName: node + linkType: hard + "@trysound/sax@npm:0.2.0": version: 0.2.0 resolution: "@trysound/sax@npm:0.2.0" @@ -11652,6 +13264,13 @@ __metadata: languageName: node linkType: hard +"@types/caseless@npm:*": + version: 0.12.5 + resolution: "@types/caseless@npm:0.12.5" + checksum: f6a3628add76d27005495914c9c3873a93536957edaa5b69c63b46fe10b4649a6fecf16b676c1695f46aab851da47ec6047dcf3570fa8d9b6883492ff6d074e0 + languageName: node + linkType: hard + "@types/chrome@npm:0.0.254": version: 0.0.254 resolution: "@types/chrome@npm:0.0.254" @@ -12011,6 +13630,13 @@ __metadata: languageName: node linkType: hard +"@types/datadog-metrics@npm:0.6.1": + version: 0.6.1 + resolution: "@types/datadog-metrics@npm:0.6.1" + checksum: a55e5661b91318ca5786675c56d054bb5b54a7e8882d87d359a844add1c06d44bf45b1cf8f33b67e1b4f017f617a023e044788a370244a912a0141dfbe55e441 + languageName: node + linkType: hard + "@types/debug@npm:^4.1.7": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -12322,6 +13948,13 @@ __metadata: languageName: node linkType: hard +"@types/long@npm:^4.0.0": + version: 4.0.2 + resolution: "@types/long@npm:4.0.2" + checksum: d16cde7240d834cf44ba1eaec49e78ae3180e724cd667052b194a372f350d024cba8dd3f37b0864931683dab09ca935d52f0c4c1687178af5ada9fc85b0635f4 + languageName: node + linkType: hard + "@types/lru-cache@npm:^5.1.0": version: 5.1.1 resolution: "@types/lru-cache@npm:5.1.1" @@ -12380,6 +14013,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.4": + version: 2.6.11 + resolution: "@types/node-fetch@npm:2.6.11" + dependencies: + "@types/node": "*" + form-data: ^4.0.0 + checksum: 180e4d44c432839bdf8a25251ef8c47d51e37355ddd78c64695225de8bc5dc2b50b7bb855956d471c026bb84bd7295688a0960085e7158cbbba803053492568b + languageName: node + linkType: hard + "@types/node-forge@npm:^1.3.0": version: 1.3.9 resolution: "@types/node-forge@npm:1.3.9" @@ -12389,10 +14032,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*": - version: 20.1.1 - resolution: "@types/node@npm:20.1.1" - checksum: 47961ee23f873c14c3f6045422ff3059f3bfb10231ef3080a7a72d7215cc8c2623fa8cedb7b246305962fa9c1e0c9e381e04b12eb3e9ec5d076025c6231ac8da +"@types/node@npm:*, @types/node@npm:>=13.7.0": + version: 20.14.11 + resolution: "@types/node@npm:20.14.11" + dependencies: + undici-types: ~5.26.4 + checksum: 24396dea2bc803c2d2ebfdd31a3e6e93818ba1a5933d63cd0f64fad1e2955a8280ba09338a48ffe68cd84748eec8bee27135045f15661aa389656f67fe0b0924 languageName: node linkType: hard @@ -12431,6 +14076,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.11.18": + version: 18.19.31 + resolution: "@types/node@npm:18.19.31" + dependencies: + undici-types: ~5.26.4 + checksum: 949bddfd7071bd47300d1f33d380ee34695ccd5f046f1a03e4d2be0d953ace896905144d44a6f483f241b5ef34b86f0e40a0e312201117782eecf89e81a4ff13 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -12660,6 +14314,18 @@ __metadata: languageName: node linkType: hard +"@types/request@npm:^2.48.8": + version: 2.48.12 + resolution: "@types/request@npm:2.48.12" + dependencies: + "@types/caseless": "*" + "@types/node": "*" + "@types/tough-cookie": "*" + form-data: ^2.5.0 + checksum: 20dfad0a46b4249bf42f09c51fbd4d02ec6738c5152194b5c7c69bab80b00eae9cc71df4489ffa929d0968d453ef7d0823d1f98871efed563a4fdb57bf0a4c58 + languageName: node + linkType: hard + "@types/resolve@npm:1.17.1": version: 1.17.1 resolution: "@types/resolve@npm:1.17.1" @@ -13414,6 +15080,7 @@ __metadata: "@uniswap/v3-sdk": 3.13.0 "@welldone-software/why-did-you-render": 8.0.1 clean-webpack-plugin: ^4.0.0 + concurrently: ^8.0.1 copy-webpack-plugin: ^11.0.0 dotenv-webpack: 8.0.1 esbuild-loader: ^3.0.1 @@ -13507,7 +15174,7 @@ __metadata: "@tamagui/core": 1.95.1 "@tamagui/portal": 1.95.1 "@tamagui/react-native-svg": 1.95.1 - "@tamagui/remove-scroll": 1.95.1 + "@tamagui/remove-scroll": 1.102.1 "@tanstack/react-query": 5.28.14 "@tanstack/react-table": 8.10.7 "@testing-library/jest-dom": 5.17.0 @@ -13584,8 +15251,8 @@ __metadata: clsx: 1.2.1 concurrently: ^8.0.1 copy-to-clipboard: 3.3.3 - cypress: 12.12.0 - cypress-hardhat: 2.5.0 + cypress: 12.17.4 + cypress-hardhat: 2.5.3 d3: 7.6.1 date-fns: 2.30.0 dotenv: 16.0.3 @@ -13602,10 +15269,7 @@ __metadata: graphql: 16.6.0 hardhat: 2.14.0 husky: ^8.0.3 - i18next: 23.10.0 - i18next-resources-to-backend: ^1.2.0 immer: 9.0.6 - inter-ui: 3.19.3 jest: 29.7.0 jest-extended: 4.0.1 jest-fail-on-console: 3.3.0 @@ -13630,7 +15294,6 @@ __metadata: polyfill-object.fromentries: 1.0.1 postinstall-postinstall: 2.1.0 process: 0.11.10 - qrcode.react: 3.1.0 qs: 6.9.4 query-string: 7.1.3 rc-slider: 10.4.0 @@ -13638,7 +15301,6 @@ __metadata: react-dom: 18.2.0 react-feather: 2.0.10 react-helmet-async: 2.0.4 - react-i18next: 14.1.0 react-infinite-scroll-component: 6.1.0 react-is: 18.2.0 react-markdown: 4.3.1 @@ -13732,6 +15394,9 @@ __metadata: "@babel/plugin-proposal-logical-assignment-operators": 7.16.7 "@babel/plugin-proposal-numeric-separator": 7.16.7 "@babel/runtime": 7.18.9 + "@datadog/datadog-ci": 2.39.0 + "@datadog/mobile-react-native": 2.4.1 + "@datadog/mobile-react-navigation": 2.4.1 "@ethersproject/shims": 5.6.0 "@faker-js/faker": 7.6.0 "@formatjs/intl-datetimeformat": 4.5.1 @@ -13785,6 +15450,7 @@ __metadata: babel-plugin-transform-remove-console: 6.9.4 core-js: 2.6.12 cross-fetch: 3.1.5 + d3-shape: 3.2.0 dayjs: 1.11.7 detox: 20.23.0 eslint: 8.44.0 @@ -13811,7 +15477,7 @@ __metadata: lodash: 4.17.21 madge: 6.1.0 mockdate: 3.0.5 - no-yolo-signatures: 0.0.2 + openai: 4.40.0 postinstall-postinstall: 2.1.0 react: 18.2.0 react-devtools: 4.28.0 @@ -15486,6 +17152,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": + version: 7.1.1 + resolution: "agent-base@npm:7.1.1" + dependencies: + debug: ^4.3.4 + checksum: 51c158769c5c051482f9ca2e6e1ec085ac72b5a418a9b31b4e82fe6c0a6699adb94c1c42d246699a587b3335215037091c79e0de512c516f73b6ea844202f037 + languageName: node + linkType: hard + "agentkeepalive@npm:^4.2.1": version: 4.5.0 resolution: "agentkeepalive@npm:4.5.0" @@ -15563,15 +17238,15 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.11.0, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0": - version: 8.12.0 - resolution: "ajv@npm:8.12.0" +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" dependencies: - fast-deep-equal: ^3.1.1 + fast-deep-equal: ^3.1.3 + fast-uri: ^3.0.1 json-schema-traverse: ^1.0.0 require-from-string: ^2.0.2 - uri-js: ^4.2.2 - checksum: 4dc13714e316e67537c8b31bc063f99a1d9d9a497eb4bbd55191ac0dcd5e4985bbb71570352ad6f1e76684fb6d790928f96ba3b2d4fd6e10024be9612fe3f001 + checksum: 1797bf242cfffbaf3b870d13565bd1716b73f214bb7ada9a497063aada210200da36e3ed40237285f3255acc4feeae91b1fb183625331bad27da95973f7253d9 languageName: node linkType: hard @@ -16106,7 +17781,7 @@ __metadata: languageName: node linkType: hard -"arrify@npm:^2.0.1": +"arrify@npm:^2.0.0, arrify@npm:^2.0.1": version: 2.0.1 resolution: "arrify@npm:2.0.1" checksum: 067c4c1afd182806a82e4c1cb8acee16ab8b5284fbca1ce29408e6e91281c36bb5b612f6ddfbd40a0f7a7e0c75bf2696eb94c027f6e328d6e9c52465c98e4209 @@ -16141,7 +17816,7 @@ __metadata: languageName: node linkType: hard -"asn1@npm:~0.2.3": +"asn1@npm:^0.2.6, asn1@npm:~0.2.0, asn1@npm:~0.2.3": version: 0.2.6 resolution: "asn1@npm:0.2.6" dependencies: @@ -16217,6 +17892,15 @@ __metadata: languageName: node linkType: hard +"ast-types@npm:^0.13.4": + version: 0.13.4 + resolution: "ast-types@npm:0.13.4" + dependencies: + tslib: ^2.0.1 + checksum: 5a51f7b70588ecced3601845a0e203279ca2f5fdc184416a0a1640c93ec0a267241d6090a328e78eebb8de81f8754754e0a4f1558ba2a3d638f8ccbd0b1f0eff + languageName: node + linkType: hard + "astral-regex@npm:^1.0.0": version: 1.0.0 resolution: "astral-regex@npm:1.0.0" @@ -16256,6 +17940,15 @@ __metadata: languageName: node linkType: hard +"async-retry@npm:1.3.1": + version: 1.3.1 + resolution: "async-retry@npm:1.3.1" + dependencies: + retry: 0.12.0 + checksum: 42b518505c0cf56179d49d0cc373e50656a1edf913842c045e84a9f7191ade10b73edf583915b05617296ed3d8c2f1f151e47fcb10eb47681a935db3b407787f + languageName: node + linkType: hard + "async-retry@npm:^1.3.1": version: 1.3.3 resolution: "async-retry@npm:1.3.3" @@ -16413,6 +18106,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.8": + version: 1.7.2 + resolution: "axios@npm:1.7.2" + dependencies: + follow-redirects: ^1.15.6 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: e457e2b0ab748504621f6fa6609074ac08c824bf0881592209dfa15098ece7e88495300e02cd22ba50b3468fd712fe687e629dcb03d6a3f6a51989727405aedf + languageName: node + linkType: hard + "axobject-query@npm:^3.1.1": version: 3.1.1 resolution: "axobject-query@npm:3.1.1" @@ -16918,6 +18622,13 @@ __metadata: languageName: node linkType: hard +"basic-ftp@npm:^5.0.2": + version: 5.0.5 + resolution: "basic-ftp@npm:5.0.5" + checksum: bc82d1c1c61cd838eaca96d68ece888bacf07546642fb6b9b8328ed410756f5935f8cf43a42cb44bb343e0565e28e908adc54c298bd2f1a6e0976871fb11fec6 + languageName: node + linkType: hard + "batch@npm:0.6.1": version: 0.6.1 resolution: "batch@npm:0.6.1" @@ -16925,7 +18636,7 @@ __metadata: languageName: node linkType: hard -"bcrypt-pbkdf@npm:^1.0.0": +"bcrypt-pbkdf@npm:^1.0.0, bcrypt-pbkdf@npm:^1.0.2": version: 1.0.2 resolution: "bcrypt-pbkdf@npm:1.0.2" dependencies: @@ -16991,7 +18702,7 @@ __metadata: languageName: node linkType: hard -"bignumber.js@npm:^9.0.1, bignumber.js@npm:^9.0.2": +"bignumber.js@npm:^9.0.0, bignumber.js@npm:^9.0.1, bignumber.js@npm:^9.0.2": version: 9.1.2 resolution: "bignumber.js@npm:9.1.2" checksum: 582c03af77ec9cb0ebd682a373ee6c66475db94a4325f92299621d544aa4bd45cb45fd60001610e94aef8ae98a0905fa538241d9638d4422d57abbeeac6fadaf @@ -17125,7 +18836,7 @@ __metadata: languageName: node linkType: hard -"bowser@npm:^2.9.0": +"bowser@npm:^2.11.0, bowser@npm:^2.9.0": version: 2.11.0 resolution: "bowser@npm:2.11.0" checksum: 29c3f01f22e703fa6644fc3b684307442df4240b6e10f6cfe1b61c6ca5721073189ca97cdeedb376081148c8518e33b1d818a57f781d70b0b70e1f31fb48814f @@ -17526,6 +19237,13 @@ __metadata: languageName: node linkType: hard +"buildcheck@npm:~0.0.6": + version: 0.0.6 + resolution: "buildcheck@npm:0.0.6" + checksum: ad61759dc98d62e931df2c9f54ccac7b522e600c6e13bdcfdc2c9a872a818648c87765ee209c850f022174da4dd7c6a450c00357c5391705d26b9c5807c2a076 + languageName: node + linkType: hard + "builtin-modules@npm:^3.1.0": version: 3.3.0 resolution: "builtin-modules@npm:3.3.0" @@ -17977,6 +19695,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:3.0.0, chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505 + languageName: node + linkType: hard + "chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -18012,16 +19740,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^3.0.0": - version: 3.0.0 - resolution: "chalk@npm:3.0.0" - dependencies: - ansi-styles: ^4.1.0 - supports-color: ^7.1.0 - checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505 - languageName: node - linkType: hard - "change-case-all@npm:1.0.14": version: 1.0.14 resolution: "change-case-all@npm:1.0.14" @@ -18463,6 +20181,17 @@ __metadata: languageName: node linkType: hard +"clipanion@npm:^3.2.1": + version: 3.2.1 + resolution: "clipanion@npm:3.2.1" + dependencies: + typanion: ^3.8.0 + peerDependencies: + typanion: "*" + checksum: 448efd122ead3c802e61ba7a2002e2080c8cce01ce8a0a789d9b9e4f8fe70fd887dcf163ef8c778f5364a9e6f4b498b9f1853f709d7ed4291713e78bcfb88ee8 + languageName: node + linkType: hard + "clipboardy@npm:3.0.0": version: 3.0.0 resolution: "clipboardy@npm:3.0.0" @@ -19280,6 +21009,17 @@ __metadata: languageName: node linkType: hard +"cpu-features@npm:~0.0.9": + version: 0.0.10 + resolution: "cpu-features@npm:0.0.10" + dependencies: + buildcheck: ~0.0.6 + nan: ^2.19.0 + node-gyp: latest + checksum: ab17e25cea0b642bdcfd163d3d872be4cc7d821e854d41048557799e990d672ee1cc7bd1d4e7c4de0309b1683d4c001d36ba8569b5035d1e7e2ff2d681f681d7 + languageName: node + linkType: hard + "crc-32@npm:^1.2.0": version: 1.2.0 resolution: "crc-32@npm:1.2.0" @@ -19882,24 +21622,9 @@ __metadata: languageName: node linkType: hard -"cypress-hardhat@npm:2.5.0": - version: 2.5.0 - resolution: "cypress-hardhat@npm:2.5.0" - dependencies: - "@uniswap/permit2-sdk": ^1.2.0 - "@uniswap/sdk-core": ">= 3" - "@uniswap/universal-router-sdk": ^1.5.4 - peerDependencies: - cypress: ^12.9.0 - ethers: ^5.1.3 - hardhat: ^2.9.6 - checksum: 1411b79823000cb487c84db8e18a52f2808f3ae8b6d1b4858d04ce0aca46bcbec85967867b3b96e1ef34201b7d296592b5560b18b387311b7836371effa06cc4 - languageName: node - linkType: hard - -"cypress-hardhat@patch:cypress-hardhat@npm%3A2.5.0#./.yarn/patches/cypress-hardhat-npm-2.5.0-9b9b7d7a28.patch::locator=universe%40workspace%3A.": - version: 2.5.0 - resolution: "cypress-hardhat@patch:cypress-hardhat@npm%3A2.5.0#./.yarn/patches/cypress-hardhat-npm-2.5.0-9b9b7d7a28.patch::version=2.5.0&hash=49b97a&locator=universe%40workspace%3A." +"cypress-hardhat@npm:2.5.3": + version: 2.5.3 + resolution: "cypress-hardhat@npm:2.5.3" dependencies: "@uniswap/permit2-sdk": ^1.2.0 "@uniswap/sdk-core": ">= 3" @@ -19908,7 +21633,7 @@ __metadata: cypress: ^12.9.0 ethers: ^5.1.3 hardhat: ^2.9.6 - checksum: 68d5393d259e12c24c100b1a31072475978dde2618e201b2a45ce80b4d0e79741aac2396292b210de452144b6412662e498ffd461f0dee8186f00e633ffe2aba + checksum: 6a7f15db68c878290db5886c2e566b5eb6922eab989a7056f6e22399f5e19901f51020def151f68a783b0a24e98ec7b10fa17d1569a3831f9674a93517802ca8 languageName: node linkType: hard @@ -20292,7 +22017,7 @@ __metadata: languageName: node linkType: hard -"d3-shape@npm:3, d3-shape@npm:^3.0.1": +"d3-shape@npm:3, d3-shape@npm:3.2.0, d3-shape@npm:^3.0.1": version: 3.2.0 resolution: "d3-shape@npm:3.2.0" dependencies: @@ -20527,6 +22252,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^6.0.2": + version: 6.0.2 + resolution: "data-uri-to-buffer@npm:6.0.2" + checksum: 8b6927c33f9b54037f442856be0aa20e5fd49fa6c9c8ceece408dc306445d593ad72d207d57037c529ce65f413b421da800c6827b1dbefb607b8056f17123a61 + languageName: node + linkType: hard + "data-urls@npm:^2.0.0": version: 2.0.0 resolution: "data-urls@npm:2.0.0" @@ -20582,6 +22314,16 @@ __metadata: languageName: node linkType: hard +"datadog-metrics@npm:0.9.3": + version: 0.9.3 + resolution: "datadog-metrics@npm:0.9.3" + dependencies: + debug: 3.1.0 + dogapi: 2.8.4 + checksum: f16c0feb21a1e08944e68df53be14da7a11a3482bcf17173af9d1cff7872378a676b4c9379758180914a58490818a475baadfb69637e0a6d53c22411fb452411 + languageName: node + linkType: hard + "dataloader@npm:^2.2.2": version: 2.2.2 resolution: "dataloader@npm:2.2.2" @@ -20635,6 +22377,15 @@ __metadata: languageName: node linkType: hard +"debug@npm:3.1.0": + version: 3.1.0 + resolution: "debug@npm:3.1.0" + dependencies: + ms: 2.0.0 + checksum: 0b52718ab957254a5b3ca07fc34543bc778f358620c206a08452251eb7fc193c3ea3505072acbf4350219c14e2d71ceb7bdaa0d3370aa630b50da790458d08b3 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:4.3.4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -20770,7 +22521,7 @@ __metadata: languageName: node linkType: hard -"deep-extend@npm:^0.6.0": +"deep-extend@npm:0.6.0, deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" checksum: 7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 @@ -20876,6 +22627,17 @@ __metadata: languageName: node linkType: hard +"degenerator@npm:^5.0.0": + version: 5.0.1 + resolution: "degenerator@npm:5.0.1" + dependencies: + ast-types: ^0.13.4 + escodegen: ^2.1.0 + esprima: ^4.0.1 + checksum: a64fa39cdf6c2edd75188157d32338ee9de7193d7dbb2aeb4acb1eb30fa4a15ed80ba8dae9bd4d7b085472cf174a5baf81adb761aaa8e326771392c922084152 + languageName: node + linkType: hard + "del@npm:^4.1.1": version: 4.1.1 resolution: "del@npm:4.1.1" @@ -21521,6 +23283,21 @@ __metadata: languageName: node linkType: hard +"dogapi@npm:2.8.4": + version: 2.8.4 + resolution: "dogapi@npm:2.8.4" + dependencies: + extend: ^3.0.2 + json-bigint: ^1.0.0 + lodash: ^4.17.21 + minimist: ^1.2.5 + rc: ^1.2.8 + bin: + dogapi: bin/dogapi + checksum: 153da30207eb124c6e1d742ef10bc04bb4d671022a04e7a7b0122c236deaebe3cd1086e47fde7c61242f9e6bbee3271025abf6db083c008117f662a5ac355c3e + languageName: node + linkType: hard + "dom-accessibility-api@npm:^0.5.6, dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" @@ -21693,6 +23470,15 @@ __metadata: languageName: node linkType: hard +"dot-prop@npm:^6.0.0": + version: 6.0.1 + resolution: "dot-prop@npm:6.0.1" + dependencies: + is-obj: ^2.0.0 + checksum: 0f47600a4b93e1dc37261da4e6909652c008832a5d3684b5bf9a9a0d3f4c67ea949a86dceed9b72f5733ed8e8e6383cc5958df3bbd0799ee317fd181f2ece700 + languageName: node + linkType: hard + "dotenv-cli@npm:^7.0.0": version: 7.1.0 resolution: "dotenv-cli@npm:7.1.0" @@ -21837,15 +23623,15 @@ __metadata: languageName: node linkType: hard -"duplexify@npm:^4.1.2": - version: 4.1.2 - resolution: "duplexify@npm:4.1.2" +"duplexify@npm:^4.0.0, duplexify@npm:^4.1.1, duplexify@npm:^4.1.2": + version: 4.1.3 + resolution: "duplexify@npm:4.1.3" dependencies: end-of-stream: ^1.4.1 inherits: ^2.0.3 readable-stream: ^3.1.1 - stream-shift: ^1.0.0 - checksum: 964376c61c0e92f6ed0694b3ba97c84f199413dc40ab8dfdaef80b7a7f4982fcabf796214e28ed614a5bc1ec45488a29b81e7d46fa3f5ddf65bcb118c20145ad + stream-shift: ^1.0.2 + checksum: 9636a027345de3dd3c801594d01a7c73d9ce260019538beb1ee650bba7544e72f40a4d4902b52e1ab283dc32a06f210d42748773af02ff15e3064a9659deab7f languageName: node linkType: hard @@ -21873,7 +23659,7 @@ __metadata: languageName: node linkType: hard -"ecdsa-sig-formatter@npm:1.0.11": +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": version: 1.0.11 resolution: "ecdsa-sig-formatter@npm:1.0.11" dependencies: @@ -22337,6 +24123,13 @@ __metadata: languageName: node linkType: hard +"es-toolkit@npm:1.10.0": + version: 1.10.0 + resolution: "es-toolkit@npm:1.10.0" + checksum: ff5464b7173b2083295f63ca910ce0ed764a55ed8cb843db6011a0e3c1fcbca4b14a661a7cacb829d304942c69f69272932a028a604f5d94dff837eda0ea4938 + languageName: node + linkType: hard + "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -22520,14 +24313,13 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^2.0.0": - version: 2.0.0 - resolution: "escodegen@npm:2.0.0" +"escodegen@npm:^2.0.0, escodegen@npm:^2.1.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" dependencies: esprima: ^4.0.1 estraverse: ^5.2.0 esutils: ^2.0.2 - optionator: ^0.8.1 source-map: ~0.6.1 dependenciesMeta: source-map: @@ -22535,7 +24327,7 @@ __metadata: bin: escodegen: bin/escodegen.js esgenerate: bin/esgenerate.js - checksum: 5aa6b2966fafe0545e4e77936300cc94ad57cfe4dc4ebff9950492eaba83eef634503f12d7e3cbd644ecc1bab388ad0e92b06fd32222c9281a75d1cf02ec6cef + checksum: 096696407e161305cd05aebb95134ad176708bc5cb13d0dcc89a5fcbb959b8ed757e7f2591a5f8036f8f4952d4a724de0df14cd419e29212729fa6df5ce16bf6 languageName: node linkType: hard @@ -23489,6 +25281,15 @@ __metadata: languageName: node linkType: hard +"eventid@npm:^2.0.0": + version: 2.0.1 + resolution: "eventid@npm:2.0.1" + dependencies: + uuid: ^8.0.0 + checksum: d7de09a0792127796d8ff413e972c0fd2bf52547fd38b7d67bc6dcb10464eb6508f60b92b60e118ecce035586326b2e159be3cec7074fcd5e0e0217c754db3be + languageName: node + linkType: hard + "events@npm:3.3.0, events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -24000,7 +25801,7 @@ __metadata: languageName: node linkType: hard -"extend@npm:^3.0.0, extend@npm:~3.0.2": +"extend@npm:^3.0.0, extend@npm:^3.0.2, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" checksum: a50a8309ca65ea5d426382ff09f33586527882cf532931cb08ca786ea3146c0553310bda688710ff61d7668eba9f96b923fe1420cdf56a2c3eaf30fcab87b515 @@ -24179,13 +25980,20 @@ __metadata: languageName: node linkType: hard -"fast-text-encoding@npm:^1.0.6": +"fast-text-encoding@npm:^1.0.0, fast-text-encoding@npm:^1.0.6": version: 1.0.6 resolution: "fast-text-encoding@npm:1.0.6" checksum: 9d58f694314b3283e785bf61954902536da228607ad246905e30256f9ab8331f780ac987e7222c9f5eafd04168d07e12b8054c85cedb76a2c05af0e82387a903 languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.0.1 + resolution: "fast-uri@npm:3.0.1" + checksum: 106143ff83705995225dcc559411288f3337e732bb2e264e79788f1914b6bd8f8bc3683102de60b15ba00e6ebb443633cabac77d4ebc5cb228c47cf955e199ff + languageName: node + linkType: hard + "fast-url-parser@npm:1.1.3, fast-url-parser@npm:^1.1.3": version: 1.1.3 resolution: "fast-url-parser@npm:1.1.3" @@ -24195,14 +26003,25 @@ __metadata: languageName: node linkType: hard -"fast-xml-parser@npm:^4.0.12, fast-xml-parser@npm:^4.2.4": - version: 4.3.6 - resolution: "fast-xml-parser@npm:4.3.6" +"fast-xml-parser@npm:4.2.5": + version: 4.2.5 + resolution: "fast-xml-parser@npm:4.2.5" dependencies: strnum: ^1.0.5 bin: fxparser: src/cli/cli.js - checksum: 12795c55f4564699c3cee13f7e892423244ac1125775e9b85bf948a1d4b65352da8f688d334bad530972288bb7ee0cf3d2605088d475123fce40d95003f045fa + checksum: d32b22005504eeb207249bf40dc82d0994b5bb9ca9dcc731d335a1f425e47fe085b3cace3cf9d32172dd1a5544193c49e8615ca95b4bf95a4a4920a226b06d80 + languageName: node + linkType: hard + +"fast-xml-parser@npm:^4.0.12, fast-xml-parser@npm:^4.2.4, fast-xml-parser@npm:^4.2.5": + version: 4.4.0 + resolution: "fast-xml-parser@npm:4.4.0" + dependencies: + strnum: ^1.0.5 + bin: + fxparser: src/cli/cli.js + checksum: ad33a4b5165a0ffcb6e17ae78825bd4619a8298844a8a8408f2ea141a0d2d9439d18865dc5254162f09fe54d510ff18e5d5c0a190869cab21fc745ee66be816b languageName: node linkType: hard @@ -24621,13 +26440,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.12.1, follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.4": - version: 1.15.5 - resolution: "follow-redirects@npm:1.15.5" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.12.1, follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.4, follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" peerDependenciesMeta: debug: optional: true - checksum: 5ca49b5ce6f44338cbfc3546823357e7a70813cecc9b7b768158a1d32c1e62e7407c944402a918ea8c38ae2e78266312d617dc68783fac502cbb55e1047b34ec + checksum: a62c378dfc8c00f60b9c80cab158ba54e99ba0239a5dd7c81245e5a5b39d10f0c35e249c3379eae719ff0285fff88c365dd446fab19dee771f1d76252df1bbf5 languageName: node linkType: hard @@ -24708,32 +26527,43 @@ __metadata: languageName: node linkType: hard -"form-data-encoder@npm:^1.7.1": +"form-data-encoder@npm:1.7.2, form-data-encoder@npm:^1.7.1": version: 1.7.2 resolution: "form-data-encoder@npm:1.7.2" checksum: aeebd87a1cb009e13cbb5e4e4008e6202ed5f6551eb6d9582ba8a062005178907b90f4887899d3c993de879159b6c0c940af8196725b428b4248cec5af3acf5f languageName: node linkType: hard -"form-data@npm:^3.0.0, form-data@npm:^3.0.1": - version: 3.0.1 - resolution: "form-data@npm:3.0.1" +"form-data@npm:4.0.0, form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" dependencies: asynckit: ^0.4.0 combined-stream: ^1.0.8 mime-types: ^2.1.12 - checksum: b019e8d35c8afc14a2bd8a7a92fa4f525a4726b6d5a9740e8d2623c30e308fbb58dc8469f90415a856698933c8479b01646a9dff33c87cc4e76d72aedbbf860d + checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.0 - resolution: "form-data@npm:4.0.0" +"form-data@npm:^2.5.0": + version: 2.5.1 + resolution: "form-data@npm:2.5.1" + dependencies: + asynckit: ^0.4.0 + combined-stream: ^1.0.6 + mime-types: ^2.1.12 + checksum: 5134ada56cc246b293a1ac7678dba6830000603a3979cf83ff7b2f21f2e3725202237cfb89e32bcb38a1d35727efbd3c3a22e65b42321e8ade8eec01ce755d08 + languageName: node + linkType: hard + +"form-data@npm:^3.0.0, form-data@npm:^3.0.1": + version: 3.0.1 + resolution: "form-data@npm:3.0.1" dependencies: asynckit: ^0.4.0 combined-stream: ^1.0.8 mime-types: ^2.1.12 - checksum: 01135bf8675f9d5c61ff18e2e2932f719ca4de964e3be90ef4c36aacfc7b9cb2fceb5eca0b7e0190e3383fe51c5b37f4cb80b62ca06a99aaabfcfd6ac7c9328c + checksum: b019e8d35c8afc14a2bd8a7a92fa4f525a4726b6d5a9740e8d2623c30e308fbb58dc8469f90415a856698933c8479b01646a9dff33c87cc4e76d72aedbbf860d languageName: node linkType: hard @@ -24748,7 +26578,7 @@ __metadata: languageName: node linkType: hard -"formdata-node@npm:^4.3.1": +"formdata-node@npm:^4.3.1, formdata-node@npm:^4.3.2": version: 4.4.1 resolution: "formdata-node@npm:4.4.1" dependencies: @@ -25105,6 +26935,13 @@ __metadata: languageName: node linkType: hard +"fuzzy@npm:^0.1.3": + version: 0.1.3 + resolution: "fuzzy@npm:0.1.3" + checksum: acc09c6173e12d5dc8ae51857551ddbe834befa9ebc6be6d5581d09117265d704809d80407d220fd0652f347a9975a4d106854cacc8bd031487a0ede86982f84 + languageName: node + linkType: hard + "gauge@npm:^3.0.0": version: 3.0.2 resolution: "gauge@npm:3.0.2" @@ -25154,6 +26991,51 @@ __metadata: languageName: node linkType: hard +"gaxios@npm:^5.0.0, gaxios@npm:^5.0.1": + version: 5.1.3 + resolution: "gaxios@npm:5.1.3" + dependencies: + extend: ^3.0.2 + https-proxy-agent: ^5.0.0 + is-stream: ^2.0.0 + node-fetch: ^2.6.9 + checksum: 1cf72697715c64f6db1d6fa6e9243bb57ee14b0c758338a33790ecac2675d819a1fc0c51b2fab312d9bfe8201cc981c171b70ff60adcaaec881c5bc5610c42f1 + languageName: node + linkType: hard + +"gaxios@npm:^6.0.0, gaxios@npm:^6.1.1": + version: 6.7.0 + resolution: "gaxios@npm:6.7.0" + dependencies: + extend: ^3.0.2 + https-proxy-agent: ^7.0.1 + is-stream: ^2.0.0 + node-fetch: ^2.6.9 + uuid: ^10.0.0 + checksum: 7316ea45cb1fc84d2725d675a6f23fc68c5dfa53b437b89c2596e3219a1bf32ee48f57242b670ebad515c9644d45cc7b2b7ef9063fa50a86de54e1a5a6433999 + languageName: node + linkType: hard + +"gcp-metadata@npm:^5.3.0": + version: 5.3.0 + resolution: "gcp-metadata@npm:5.3.0" + dependencies: + gaxios: ^5.0.0 + json-bigint: ^1.0.0 + checksum: 891ea0b902a17f33d7bae753830d23962b63af94ed071092c30496e7d26f8128ba9af43c3d38474bea29cb32a884b4bcb5720ce8b9de4a7e1108475d3d7ae219 + languageName: node + linkType: hard + +"gcp-metadata@npm:^6.0.0, gcp-metadata@npm:^6.1.0": + version: 6.1.0 + resolution: "gcp-metadata@npm:6.1.0" + dependencies: + gaxios: ^6.0.0 + json-bigint: ^1.0.0 + checksum: 55de8ae4a6b7664379a093abf7e758ae06e82f244d41bd58d881a470bf34db94c4067ce9e1b425d9455b7705636d5f8baad844e49bb73879c338753ba7785b2b + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -25318,11 +27200,23 @@ __metadata: linkType: hard "get-tsconfig@npm:^4.2.0, get-tsconfig@npm:^4.4.0, get-tsconfig@npm:^4.5.0": - version: 4.6.2 - resolution: "get-tsconfig@npm:4.6.2" + version: 4.7.6 + resolution: "get-tsconfig@npm:4.7.6" dependencies: resolve-pkg-maps: ^1.0.0 - checksum: e791e671a9b55e91efea3ca819ecd7a25beae679e31c83234bf3dd62ddd93df070c1b95ae7e29d206358ebb6408f6f79ac6d83a32a3bbd6a6d217babe23de077 + checksum: ebfd86f0b356cde98e2a7afe63b58d92e02b8e413ff95551933d277702bf725386ee82c5c0092fe45fb2ba60002340c94ee70777b3220bbfeca83ab45dda1544 + languageName: node + linkType: hard + +"get-uri@npm:^6.0.1": + version: 6.0.3 + resolution: "get-uri@npm:6.0.3" + dependencies: + basic-ftp: ^5.0.2 + data-uri-to-buffer: ^6.0.2 + debug: ^4.3.4 + fs-extra: ^11.2.0 + checksum: 3eda448a59fa1ba82ad4f252e58490fec586b644f2dc9c98ba3ab20e801ecc8a1bc1784829c474c9d188edb633d4dfd81c33894ca6117a33a16e8e013b41b40f languageName: node linkType: hard @@ -25451,6 +27345,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:7.1.4": + version: 7.1.4 + resolution: "glob@npm:7.1.4" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^3.0.4 + once: ^1.3.0 + path-is-absolute: ^1.0.0 + checksum: f52480fc82b1e66e52990f0f2e7306447d12294c83fbbee0395e761ad1178172012a7cc0673dbf4810baac400fc09bf34484c08b5778c216403fd823db281716 + languageName: node + linkType: hard + "glob@npm:7.1.6": version: 7.1.6 resolution: "glob@npm:7.1.6" @@ -25707,6 +27615,68 @@ __metadata: languageName: node linkType: hard +"google-auth-library@npm:^8.9.0": + version: 8.9.0 + resolution: "google-auth-library@npm:8.9.0" + dependencies: + arrify: ^2.0.0 + base64-js: ^1.3.0 + ecdsa-sig-formatter: ^1.0.11 + fast-text-encoding: ^1.0.0 + gaxios: ^5.0.0 + gcp-metadata: ^5.3.0 + gtoken: ^6.1.0 + jws: ^4.0.0 + lru-cache: ^6.0.0 + checksum: 8e0bc5f1e91804523786413bf4358e4c5ad94b1e873c725ddd03d0f1c242e2b38e26352c0f375334fbc1d94110f761b304aa0429de49b4a27ebc3875a5b56644 + languageName: node + linkType: hard + +"google-auth-library@npm:^9.0.0, google-auth-library@npm:^9.3.0": + version: 9.11.0 + resolution: "google-auth-library@npm:9.11.0" + dependencies: + base64-js: ^1.3.0 + ecdsa-sig-formatter: ^1.0.11 + gaxios: ^6.1.1 + gcp-metadata: ^6.1.0 + gtoken: ^7.0.0 + jws: ^4.0.0 + checksum: 984d344b5e0a21ea1e097d06e27173035619c0e8f89a363e538b445adb1414b79e938b56b4432aa36fda074c5922fa6a34f9b64734765c01dff73c45c8568554 + languageName: node + linkType: hard + +"google-gax@npm:^4.0.3": + version: 4.3.8 + resolution: "google-gax@npm:4.3.8" + dependencies: + "@grpc/grpc-js": ^1.10.9 + "@grpc/proto-loader": ^0.7.13 + "@types/long": ^4.0.0 + abort-controller: ^3.0.0 + duplexify: ^4.0.0 + google-auth-library: ^9.3.0 + node-fetch: ^2.6.1 + object-hash: ^3.0.0 + proto3-json-serializer: ^2.0.2 + protobufjs: ^7.3.2 + retry-request: ^7.0.0 + uuid: ^9.0.1 + checksum: e6a6946645d3290bf04c2815d091037ff24ef41bd3f8d9eaab802c82adc86b05fe665dc36181a79972292350a01a5e203e0f42dfa3498bf084caee99a16a8207 + languageName: node + linkType: hard + +"google-p12-pem@npm:^4.0.0": + version: 4.0.1 + resolution: "google-p12-pem@npm:4.0.1" + dependencies: + node-forge: ^1.3.1 + bin: + gp12-pem: build/src/bin/gp12-pem.js + checksum: 59a5026331ea67455672e83770da29f09d979f02e06cb2227ea5916f8cca437887c2d3869f2602a686dc84437886ae9d2ac010780803cbe8e5f161c2d02d8efd + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -25891,6 +27861,27 @@ __metadata: languageName: node linkType: hard +"gtoken@npm:^6.1.0": + version: 6.1.2 + resolution: "gtoken@npm:6.1.2" + dependencies: + gaxios: ^5.0.1 + google-p12-pem: ^4.0.0 + jws: ^4.0.0 + checksum: cf3210afe2ccee8feaa06f0c7eb942e217244a8563a1d0a71aa3095eea545015896741c1d48654d8de35b7b07579f93e25e5dfe817f06b7e753646b67f7a4ecf + languageName: node + linkType: hard + +"gtoken@npm:^7.0.0": + version: 7.1.0 + resolution: "gtoken@npm:7.1.0" + dependencies: + gaxios: ^6.0.0 + jws: ^4.0.0 + checksum: 1f338dced78f9d895ea03cd507454eb5a7b77e841ecd1d45e44483b08c1e64d16a9b0342358d37586d87462ffc2d5f5bff5dfe77ed8d4f0aafc3b5b0347d5d16 + languageName: node + linkType: hard + "gulp-sort@npm:^2.0.0": version: 2.0.0 resolution: "gulp-sort@npm:2.0.0" @@ -26333,10 +28324,10 @@ __metadata: languageName: node linkType: hard -"html-entities@npm:^2.1.0, html-entities@npm:^2.3.2": - version: 2.3.3 - resolution: "html-entities@npm:2.3.3" - checksum: 92521501da8aa5f66fee27f0f022d6e9ceae62667dae93aa6a2f636afa71ad530b7fb24a18d4d6c124c9885970cac5f8a52dbf1731741161002816ae43f98196 +"html-entities@npm:^2.1.0, html-entities@npm:^2.3.2, html-entities@npm:^2.5.2": + version: 2.5.2 + resolution: "html-entities@npm:2.5.2" + checksum: b23f4a07d33d49ade1994069af4e13d31650e3fb62621e92ae10ecdf01d1a98065c78fd20fdc92b4c7881612210b37c275f2c9fba9777650ab0d6f2ceb3b99b6 languageName: node linkType: hard @@ -26512,6 +28503,16 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: ^7.1.0 + debug: ^4.3.4 + checksum: 670858c8f8f3146db5889e1fa117630910101db601fff7d5a8aa637da0abedf68c899f03d3451cac2f83bcc4c3d2dabf339b3aa00ff8080571cceb02c3ce02f3 + languageName: node + linkType: hard + "http-proxy-middleware@npm:^2.0.3": version: 2.0.6 resolution: "http-proxy-middleware@npm:2.0.6" @@ -26586,6 +28587,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.3, https-proxy-agent@npm:^7.0.5": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" + dependencies: + agent-base: ^7.0.2 + debug: 4 + checksum: 2e1a28960f13b041a50702ee74f240add8e75146a5c37fc98f1960f0496710f6918b3a9fe1e5aba41e50f58e6df48d107edd9c405c5f0d73ac260dabf2210857 + languageName: node + linkType: hard + "human-signals@npm:^1.1.1": version: 1.1.1 resolution: "human-signals@npm:1.1.1" @@ -26964,7 +28975,22 @@ __metadata: languageName: node linkType: hard -"inquirer@npm:^8.0.0": +"inquirer-checkbox-plus-prompt@npm:^1.4.2": + version: 1.4.2 + resolution: "inquirer-checkbox-plus-prompt@npm:1.4.2" + dependencies: + chalk: 4.1.2 + cli-cursor: ^3.1.0 + figures: ^3.0.0 + lodash: ^4.17.5 + rxjs: ^6.6.7 + peerDependencies: + inquirer: < 9.x + checksum: 0bdadc94e4bd13cc3b918b3a7d343376939e5713ccceb51b28293649bd51c476aaa6aab6cafecf89c784cffcaf879c307ab3830a12d3e54ba7a26c6e81c85d27 + languageName: node + linkType: hard + +"inquirer@npm:^8.0.0, inquirer@npm:^8.2.5": version: 8.2.6 resolution: "inquirer@npm:8.2.6" dependencies: @@ -26987,13 +29013,6 @@ __metadata: languageName: node linkType: hard -"inter-ui@npm:3.19.3": - version: 3.19.3 - resolution: "inter-ui@npm:3.19.3" - checksum: 74435e742e9829402cf15ac08aa48541c93e96d7528a29ba3bc2a14b059ccd3f330ddba8b1c96f06bfdda46b0c7e1a406db22a309045b3412b66ec4dcb5efa2a - languageName: node - linkType: hard - "internal-ip@npm:4.3.0": version: 4.3.0 resolution: "internal-ip@npm:4.3.0" @@ -27078,6 +29097,16 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^9.0.5": + version: 9.0.5 + resolution: "ip-address@npm:9.0.5" + dependencies: + jsbn: 1.1.0 + sprintf-js: ^1.1.3 + checksum: aa15f12cfd0ef5e38349744e3654bae649a34c3b10c77a674a167e99925d1549486c5b14730eebce9fea26f6db9d5e42097b00aa4f9f612e68c79121c71652dc + languageName: node + linkType: hard + "ip-regex@npm:^2.1.0": version: 2.1.0 resolution: "ip-regex@npm:2.1.0" @@ -27092,13 +29121,6 @@ __metadata: languageName: node linkType: hard -"ip@npm:^2.0.0": - version: 2.0.0 - resolution: "ip@npm:2.0.0" - checksum: cfcfac6b873b701996d71ec82a7dd27ba92450afdb421e356f44044ed688df04567344c36cbacea7d01b1c39a4c732dc012570ebe9bebfb06f27314bca625349 - languageName: node - linkType: hard - "ipaddr.js@npm:1.9.1, ipaddr.js@npm:^1.9.0": version: 1.9.1 resolution: "ipaddr.js@npm:1.9.1" @@ -29530,6 +31552,18 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:3.13.1": + version: 3.13.1 + resolution: "js-yaml@npm:3.13.1" + dependencies: + argparse: ^1.0.7 + esprima: ^4.0.0 + bin: + js-yaml: bin/js-yaml.js + checksum: 7511b764abb66d8aa963379f7d2a404f078457d106552d05a7b556d204f7932384e8477513c124749fa2de52eb328961834562bd09924902c6432e40daa408bc + languageName: node + linkType: hard + "js-yaml@npm:4.1.0, js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -29560,6 +31594,13 @@ __metadata: languageName: node linkType: hard +"jsbn@npm:1.1.0": + version: 1.1.0 + resolution: "jsbn@npm:1.1.0" + checksum: 944f924f2bd67ad533b3850eee47603eed0f6ae425fd1ee8c760f477e8c34a05f144c1bd4f5a5dd1963141dc79a2c55f89ccc5ab77d039e7077f3ad196b64965 + languageName: node + linkType: hard + "jsbn@npm:~0.1.0": version: 0.1.1 resolution: "jsbn@npm:0.1.1" @@ -29709,6 +31750,15 @@ __metadata: languageName: node linkType: hard +"json-bigint@npm:^1.0.0": + version: 1.0.0 + resolution: "json-bigint@npm:1.0.0" + dependencies: + bignumber.js: ^9.0.0 + checksum: c67bb93ccb3c291e60eb4b62931403e378906aab113ec1c2a8dd0f9a7f065ad6fd9713d627b732abefae2e244ac9ce1721c7a3142b2979532f12b258634ce6f6 + languageName: node + linkType: hard + "json-buffer@npm:3.0.0": version: 3.0.0 resolution: "json-buffer@npm:3.0.0" @@ -29950,6 +32000,18 @@ __metadata: languageName: node linkType: hard +"jszip@npm:^3.10.1": + version: 3.10.1 + resolution: "jszip@npm:3.10.1" + dependencies: + lie: ~3.3.0 + pako: ~1.0.2 + readable-stream: ~2.3.6 + setimmediate: ^1.0.5 + checksum: abc77bfbe33e691d4d1ac9c74c8851b5761fba6a6986630864f98d876f3fcc2d36817dfc183779f32c00157b5d53a016796677298272a714ae096dfe6b1c8b60 + languageName: node + linkType: hard + "jwa@npm:^1.4.1": version: 1.4.1 resolution: "jwa@npm:1.4.1" @@ -29961,6 +32023,17 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^2.0.0": + version: 2.0.0 + resolution: "jwa@npm:2.0.0" + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: ^5.0.1 + checksum: 8f00b71ad5fe94cb55006d0d19202f8f56889109caada2f7eeb63ca81755769ce87f4f48101967f398462e3b8ae4faebfbd5a0269cb755dead5d63c77ba4d2f1 + languageName: node + linkType: hard + "jws@npm:^3.2.2": version: 3.2.2 resolution: "jws@npm:3.2.2" @@ -29971,6 +32044,16 @@ __metadata: languageName: node linkType: hard +"jws@npm:^4.0.0": + version: 4.0.0 + resolution: "jws@npm:4.0.0" + dependencies: + jwa: ^2.0.0 + safe-buffer: ^5.0.1 + checksum: d68d07aa6d1b8cb35c363a9bd2b48f15064d342a5d9dc18a250dbbce8dc06bd7e4792516c50baa16b8d14f61167c19e851fd7f66b59ecc68b7f6a013759765f7 + languageName: node + linkType: hard + "keccak@npm:^3.0.0, keccak@npm:^3.0.2, keccak@npm:^3.0.3": version: 3.0.4 resolution: "keccak@npm:3.0.4" @@ -30189,6 +32272,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" + dependencies: + immediate: ~3.0.5 + checksum: 33102302cf19766f97919a6a98d481e01393288b17a6aa1f030a3542031df42736edde8dab29ffdbf90bebeffc48c761eb1d064dc77592ca3ba3556f9fe6d2a8 + languageName: node + linkType: hard + "lighthouse-logger@npm:^1.0.0": version: 1.4.2 resolution: "lighthouse-logger@npm:1.4.2" @@ -30761,7 +32853,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4, lodash@npm:^4.17.11, lodash@npm:^4.17.13, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.7.0, lodash@npm:~4.17.0": +"lodash@npm:4.17.21, lodash@npm:^4, lodash@npm:^4.17.11, lodash@npm:^4.17.13, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:^4.17.5, lodash@npm:^4.7.0, lodash@npm:~4.17.0": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -30825,6 +32917,13 @@ __metadata: languageName: node linkType: hard +"long@npm:^5.0.0": + version: 5.2.3 + resolution: "long@npm:5.2.3" + checksum: 885ede7c3de4facccbd2cacc6168bae3a02c3e836159ea4252c87b6e34d40af819824b2d4edce330bfb5c4d6e8ce3ec5864bdcf9473fa1f53a4f8225860e5897 + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -30903,7 +33002,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^7.7.1": +"lru-cache@npm:^7.14.1, lru-cache@npm:^7.7.1": version: 7.18.3 resolution: "lru-cache@npm:7.18.3" checksum: e550d772384709deea3f141af34b6d4fa392e2e418c1498c078de0ee63670f1f46f5eee746e8ef7e69e1c895af0d4224e62ee33e66a543a14763b0f2e74c1356 @@ -32357,12 +34456,12 @@ __metadata: languageName: node linkType: hard -"nan@npm:^2.14.0": - version: 2.17.0 - resolution: "nan@npm:2.17.0" +"nan@npm:^2.14.0, nan@npm:^2.18.0, nan@npm:^2.19.0": + version: 2.20.0 + resolution: "nan@npm:2.20.0" dependencies: node-gyp: latest - checksum: ec609aeaf7e68b76592a3ba96b372aa7f5df5b056c1e37410b0f1deefbab5a57a922061e2c5b369bae9c7c6b5e6eecf4ad2dac8833a1a7d3a751e0a7c7f849ed + checksum: eb09286e6c238a3582db4d88c875db73e9b5ab35f60306090acd2f3acae21696c9b653368b4a0e32abcef64ee304a923d6223acaddd16169e5eaaf5c508fb533 languageName: node linkType: hard @@ -32442,6 +34541,13 @@ __metadata: languageName: node linkType: hard +"netmask@npm:^2.0.2": + version: 2.0.2 + resolution: "netmask@npm:2.0.2" + checksum: c65cb8d3f7ea5669edddb3217e4c96910a60d0d9a4b52d9847ff6b28b2d0277cd8464eee0ef85133cdee32605c57940cacdd04a9a019079b091b6bba4cb0ec22 + languageName: node + linkType: hard + "nice-try@npm:^1.0.4": version: 1.0.5 resolution: "nice-try@npm:1.0.5" @@ -32585,7 +34691,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2.2.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7": +"node-fetch@npm:^2.2.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -33132,7 +35238,7 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:2.4.1": +"on-finished@npm:2.4.1, on-finished@npm:^2.3.0": version: 2.4.1 resolution: "on-finished@npm:2.4.1" dependencies: @@ -33223,6 +35329,24 @@ __metadata: languageName: node linkType: hard +"openai@npm:4.40.0": + version: 4.40.0 + resolution: "openai@npm:4.40.0" + dependencies: + "@types/node": ^18.11.18 + "@types/node-fetch": ^2.6.4 + abort-controller: ^3.0.0 + agentkeepalive: ^4.2.1 + form-data-encoder: 1.7.2 + formdata-node: ^4.3.2 + node-fetch: ^2.6.7 + web-streams-polyfill: ^3.2.1 + bin: + openai: bin/cli + checksum: ada7436a5de012a09887aad567f6d5630f14f723a7eaaaba72dd7f8a0adc631e5ebb22e92da1afd85f41d96e981703fdbf5d1797f409ca35a11a2516e4e5f39e + languageName: node + linkType: hard + "openapi-typescript-codegen@npm:0.27.0": version: 0.27.0 resolution: "openapi-typescript-codegen@npm:0.27.0" @@ -33317,7 +35441,7 @@ __metadata: languageName: node linkType: hard -"ora@npm:^5.4.1": +"ora@npm:5.4.1, ora@npm:^5.4.1": version: 5.4.1 resolution: "ora@npm:5.4.1" dependencies: @@ -33519,6 +35643,32 @@ __metadata: languageName: node linkType: hard +"pac-proxy-agent@npm:^7.0.1": + version: 7.0.2 + resolution: "pac-proxy-agent@npm:7.0.2" + dependencies: + "@tootallnate/quickjs-emscripten": ^0.23.0 + agent-base: ^7.0.2 + debug: ^4.3.4 + get-uri: ^6.0.1 + http-proxy-agent: ^7.0.0 + https-proxy-agent: ^7.0.5 + pac-resolver: ^7.0.1 + socks-proxy-agent: ^8.0.4 + checksum: 82772aaa489a4ad6f598b75d56daf609e7ba294a05a91cfe3101b004e2df494f0a269c98452cb47aaa4a513428e248308a156e26fee67eb78a76a58e9346921e + languageName: node + linkType: hard + +"pac-resolver@npm:^7.0.1": + version: 7.0.1 + resolution: "pac-resolver@npm:7.0.1" + dependencies: + degenerator: ^5.0.0 + netmask: ^2.0.2 + checksum: 839134328781b80d49f9684eae1f5c74f50a1d4482076d44c84fc2f3ca93da66fa11245a4725a057231e06b311c20c989fd0681e662a0792d17f644d8fe62a5e + languageName: node + linkType: hard + "package-json@npm:^4.0.0": version: 4.0.1 resolution: "package-json@npm:4.0.1" @@ -33550,7 +35700,7 @@ __metadata: languageName: node linkType: hard -"pako@npm:^1.0.5, pako@npm:^1.0.6, pako@npm:~1.0.5": +"pako@npm:^1.0.5, pako@npm:^1.0.6, pako@npm:~1.0.2, pako@npm:~1.0.5": version: 1.0.11 resolution: "pako@npm:1.0.11" checksum: 1be2bfa1f807608c7538afa15d6f25baa523c30ec870a3228a89579e474a4d992f4293859524e46d5d87fd30fa17c5edf34dbef0671251d9749820b488660b16 @@ -35512,6 +37662,35 @@ __metadata: languageName: node linkType: hard +"proto3-json-serializer@npm:^2.0.2": + version: 2.0.2 + resolution: "proto3-json-serializer@npm:2.0.2" + dependencies: + protobufjs: ^7.2.5 + checksum: 21b8aa65be6dac2bb24920e5bdabef48b249bdf65b1498ae7e69ac4e70722275b083cd60a21d2b4be3ead9d768de2f6f5fb6b188bd177d51c824a539b5ba55cc + languageName: node + linkType: hard + +"protobufjs@npm:^7.2.5, protobufjs@npm:^7.3.2": + version: 7.3.2 + resolution: "protobufjs@npm:7.3.2" + dependencies: + "@protobufjs/aspromise": ^1.1.2 + "@protobufjs/base64": ^1.1.2 + "@protobufjs/codegen": ^2.0.4 + "@protobufjs/eventemitter": ^1.1.0 + "@protobufjs/fetch": ^1.1.0 + "@protobufjs/float": ^1.0.2 + "@protobufjs/inquire": ^1.1.0 + "@protobufjs/path": ^1.1.2 + "@protobufjs/pool": ^1.1.0 + "@protobufjs/utf8": ^1.1.0 + "@types/node": ">=13.7.0" + long: ^5.0.0 + checksum: cfb2a744787f26ee7c82f3e7c4b72cfc000e9bb4c07828ed78eb414db0ea97a340c0cc3264d0e88606592f847b12c0351411f10e9af255b7ba864eec44d7705f + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -35522,6 +37701,22 @@ __metadata: languageName: node linkType: hard +"proxy-agent@npm:^6.4.0": + version: 6.4.0 + resolution: "proxy-agent@npm:6.4.0" + dependencies: + agent-base: ^7.0.2 + debug: ^4.3.4 + http-proxy-agent: ^7.0.1 + https-proxy-agent: ^7.0.3 + lru-cache: ^7.14.1 + pac-proxy-agent: ^7.0.1 + proxy-from-env: ^1.1.0 + socks-proxy-agent: ^8.0.2 + checksum: 4d3794ad5e07486298902f0a7f250d0f869fa0e92d790767ca3f793a81374ce0ab6c605f8ab8e791c4d754da96656b48d1c24cb7094bfd310a15867e4a0841d7 + languageName: node + linkType: hard + "proxy-compare@npm:2.5.1": version: 2.5.1 resolution: "proxy-compare@npm:2.5.1" @@ -35613,6 +37808,17 @@ __metadata: languageName: node linkType: hard +"pumpify@npm:^2.0.1": + version: 2.0.1 + resolution: "pumpify@npm:2.0.1" + dependencies: + duplexify: ^4.1.1 + inherits: ^2.0.3 + pump: ^3.0.0 + checksum: cfc96f5307ee828ef8e6eca9fe9e1ae1de0a23ca55688bfe71ea376bc126418073dab870f02b433617f421c4545726b39e31295fce9a99b78bda5f0e527a7c11 + languageName: node + linkType: hard + "punycode@npm:1.3.2": version: 1.3.2 resolution: "punycode@npm:1.3.2" @@ -35705,15 +37911,6 @@ __metadata: languageName: node linkType: hard -"qrcode.react@npm:3.1.0": - version: 3.1.0 - resolution: "qrcode.react@npm:3.1.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 94a2942ecf83f461d869adb20305ae663c6d1abe93ef2c72442b07d756ce70cf6deb6fd588dc5b382b48c6991cfde1dfd5ac9b814c1461e71d5edb2d945e67fc - languageName: node - linkType: hard - "qrcode@npm:1.5.1": version: 1.5.1 resolution: "qrcode@npm:1.5.1" @@ -36728,7 +38925,7 @@ __metadata: languageName: node linkType: hard -"react-native-web@npm:0.19.10, react-native-web@npm:^0.19.10": +"react-native-web@npm:0.19.10": version: 0.19.10 resolution: "react-native-web@npm:0.19.10" dependencies: @@ -36747,6 +38944,25 @@ __metadata: languageName: node linkType: hard +"react-native-web@npm:^0.19.10": + version: 0.19.12 + resolution: "react-native-web@npm:0.19.12" + dependencies: + "@babel/runtime": ^7.18.6 + "@react-native/normalize-colors": ^0.74.1 + fbjs: ^3.0.4 + inline-style-prefixer: ^6.0.1 + memoize-one: ^6.0.0 + nullthrows: ^1.1.1 + postcss-value-parser: ^4.2.0 + styleq: ^0.1.3 + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 676b1ba510c92e01dc69cb3102080f83976d2d209647323fb0a3a14113a455a6a506cf78d3e392c3fa33015135b61e2d6a3eed837a6876665064a6eb87516781 + languageName: node + linkType: hard + "react-native-webview@npm:11.23.1": version: 11.23.1 resolution: "react-native-webview@npm:11.23.1" @@ -38133,6 +40349,17 @@ __metadata: languageName: node linkType: hard +"retry-request@npm:^7.0.0": + version: 7.0.2 + resolution: "retry-request@npm:7.0.2" + dependencies: + "@types/request": ^2.48.8 + extend: ^3.0.2 + teeny-request: ^9.0.0 + checksum: 2d7307422333f548e5f40524978a344b62193714f6209c4f6a41057ae279804eb9bc8e0a277791e7b6f2d5d76068bdaca8590662a909cf1e6cfc3ab789e4c6b6 + languageName: node + linkType: hard + "retry@npm:0.12.0, retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -38407,6 +40634,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:^6.6.7": + version: 6.6.7 + resolution: "rxjs@npm:6.6.7" + dependencies: + tslib: ^1.9.0 + checksum: bc334edef1bb8bbf56590b0b25734ba0deaf8825b703256a93714308ea36dff8a11d25533671adf8e104e5e8f256aa6fdfe39b2e248cdbd7a5f90c260acbbd1b + languageName: node + linkType: hard + "rxjs@npm:^7.5.1, rxjs@npm:^7.5.5, rxjs@npm:^7.8.0, rxjs@npm:^7.8.1": version: 7.8.1 resolution: "rxjs@npm:7.8.1" @@ -39085,6 +41321,17 @@ __metadata: languageName: node linkType: hard +"simple-git@npm:3.16.0": + version: 3.16.0 + resolution: "simple-git@npm:3.16.0" + dependencies: + "@kwsites/file-exists": ^1.1.1 + "@kwsites/promise-deferred": ^1.1.1 + debug: ^4.3.4 + checksum: fd28eb43be39d158d2c321cd34eb00f61c365513478ff2bb31f4da06315dcd018e03c6ece9f99558f3fd8834171072850aed1376bf50f8d922b0e2dadede0c2d + languageName: node + linkType: hard + "simple-plist@npm:^1.0.0, simple-plist@npm:^1.1.0": version: 1.4.0 resolution: "simple-plist@npm:1.4.0" @@ -39255,13 +41502,24 @@ __metadata: languageName: node linkType: hard -"socks@npm:^2.6.2": - version: 2.7.1 - resolution: "socks@npm:2.7.1" +"socks-proxy-agent@npm:^8.0.2, socks-proxy-agent@npm:^8.0.4": + version: 8.0.4 + resolution: "socks-proxy-agent@npm:8.0.4" dependencies: - ip: ^2.0.0 + agent-base: ^7.1.1 + debug: ^4.3.4 + socks: ^2.8.3 + checksum: b2ec5051d85fe49072f9a250c427e0e9571fd09d5db133819192d078fd291276e1f0f50f6dbc04329b207738b1071314cee8bdbb4b12e27de42dbcf1d4233c67 + languageName: node + linkType: hard + +"socks@npm:^2.6.2, socks@npm:^2.8.3": + version: 2.8.3 + resolution: "socks@npm:2.8.3" + dependencies: + ip-address: ^9.0.5 smart-buffer: ^4.2.0 - checksum: 259d9e3e8e1c9809a7f5c32238c3d4d2a36b39b83851d0f573bfde5f21c4b1288417ce1af06af1452569cd1eb0841169afd4998f0e04ba04656f6b7f0e46d748 + checksum: 7a6b7f6eedf7482b9e4597d9a20e09505824208006ea8f2c49b71657427f3c137ca2ae662089baa73e1971c62322d535d9d0cf1c9235cf6f55e315c18203eadd languageName: node linkType: hard @@ -39553,10 +41811,10 @@ __metadata: languageName: node linkType: hard -"sprintf-js@npm:^1.1.1, sprintf-js@npm:^1.1.2": - version: 1.1.2 - resolution: "sprintf-js@npm:1.1.2" - checksum: d4bb46464632b335e5faed381bd331157e0af64915a98ede833452663bc672823db49d7531c32d58798e85236581fb7342fd0270531ffc8f914e186187bf1c90 +"sprintf-js@npm:^1.1.1, sprintf-js@npm:^1.1.2, sprintf-js@npm:^1.1.3": + version: 1.1.3 + resolution: "sprintf-js@npm:1.1.3" + checksum: a3fdac7b49643875b70864a9d9b469d87a40dfeaf5d34d9d0c5b1cda5fd7d065531fcb43c76357d62254c57184a7b151954156563a4d6a747015cfb41021cad0 languageName: node linkType: hard @@ -39567,6 +41825,55 @@ __metadata: languageName: node linkType: hard +"ssh2-streams@npm:0.4.10": + version: 0.4.10 + resolution: "ssh2-streams@npm:0.4.10" + dependencies: + asn1: ~0.2.0 + bcrypt-pbkdf: ^1.0.2 + streamsearch: ~0.1.2 + checksum: dad86d7aa83c08a8f57feedfda26f4032a3f5e605a1d2018da835ced4ebb003739962aa416066f374a006d941067e4215f8b84a8ec81e888156c9274445ae5db + languageName: node + linkType: hard + +"ssh2@npm:^1.15.0": + version: 1.15.0 + resolution: "ssh2@npm:1.15.0" + dependencies: + asn1: ^0.2.6 + bcrypt-pbkdf: ^1.0.2 + cpu-features: ~0.0.9 + nan: ^2.18.0 + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: 56baa07dc0dd8d97aefa05033b8a95d220a34b2f203aa9116173d7adc5e9fd46be22d7cfed99cdd9f5548862ae44abd1ec136e20ea856d5c470a0df0e5aea9d1 + languageName: node + linkType: hard + +"sshpk@npm:1.16.1": + version: 1.16.1 + resolution: "sshpk@npm:1.16.1" + dependencies: + asn1: ~0.2.3 + assert-plus: ^1.0.0 + bcrypt-pbkdf: ^1.0.0 + dashdash: ^1.12.0 + ecc-jsbn: ~0.1.1 + getpass: ^0.1.1 + jsbn: ~0.1.0 + safer-buffer: ^2.0.2 + tweetnacl: ~0.14.0 + bin: + sshpk-conv: bin/sshpk-conv + sshpk-sign: bin/sshpk-sign + sshpk-verify: bin/sshpk-verify + checksum: 5e76afd1cedc780256f688b7c09327a8a650902d18e284dfeac97489a735299b03c3e72c6e8d22af03dbbe4d6f123fdfd5f3c4ed6bedbec72b9529a55051b857 + languageName: node + linkType: hard + "sshpk@npm:^1.14.1": version: 1.18.0 resolution: "sshpk@npm:1.18.0" @@ -39852,6 +42159,15 @@ __metadata: languageName: node linkType: hard +"stream-events@npm:^1.0.5": + version: 1.0.5 + resolution: "stream-events@npm:1.0.5" + dependencies: + stubs: ^3.0.0 + checksum: 969ce82e34bfbef5734629cc06f9d7f3705a9ceb8fcd6a526332f9159f1f8bbfdb1a453f3ced0b728083454f7706adbbe8428bceb788a0287ca48ba2642dc3fc + languageName: node + linkType: hard + "stream-http@npm:^3.2.0": version: 3.2.0 resolution: "stream-http@npm:3.2.0" @@ -39873,10 +42189,10 @@ __metadata: languageName: node linkType: hard -"stream-shift@npm:^1.0.0": - version: 1.0.1 - resolution: "stream-shift@npm:1.0.1" - checksum: 59b82b44b29ec3699b5519a49b3cedcc6db58c72fb40c04e005525dfdcab1c75c4e0c180b923c380f204bed78211b9bad8faecc7b93dece4d004c3f6ec75737b +"stream-shift@npm:^1.0.0, stream-shift@npm:^1.0.2": + version: 1.0.3 + resolution: "stream-shift@npm:1.0.3" + checksum: a24c0a3f66a8f9024bd1d579a533a53be283b4475d4e6b4b3211b964031447bdf6532dd1f3c2b0ad66752554391b7c62bd7ca4559193381f766534e723d50242 languageName: node linkType: hard @@ -39896,6 +42212,13 @@ __metadata: languageName: node linkType: hard +"streamsearch@npm:~0.1.2": + version: 0.1.2 + resolution: "streamsearch@npm:0.1.2" + checksum: d2db57cbfbf7947ab9c75a7b4c80a8ef8d24850cf0a1a24258bb6956c97317ce1eab7dbcbf9c5aba3e6198611af1053b02411057bbedb99bf9c64b8275248997 + languageName: node + linkType: hard + "streamx@npm:^2.12.5": version: 2.15.1 resolution: "streamx@npm:2.15.1" @@ -40227,6 +42550,13 @@ __metadata: languageName: node linkType: hard +"stubs@npm:^3.0.0": + version: 3.0.0 + resolution: "stubs@npm:3.0.0" + checksum: dec7b82186e3743317616235c59bfb53284acc312cb9f4c3e97e2205c67a5c158b0ca89db5927e52351582e90a2672822eeaec9db396e23e56893d2a8676e024 + languageName: node + linkType: hard + "style-loader@npm:^3.3.1": version: 3.3.2 resolution: "style-loader@npm:3.3.2" @@ -40614,10 +42944,10 @@ __metadata: languageName: node linkType: hard -"tabbable@npm:^6.0.1": - version: 6.1.1 - resolution: "tabbable@npm:6.1.1" - checksum: 348639497262241ce8e0ccb0664ea582a386183107299ee8f27cf7b56bc84f36e09eaf667d3cb4201e789634012a91f7129bcbd49760abe874fbace35b4cf429 +"tabbable@npm:^6.0.0": + version: 6.2.0 + resolution: "tabbable@npm:6.2.0" + checksum: f8440277d223949272c74bb627a3371be21735ca9ad34c2570f7e1752bd646ccfc23a9d8b1ee65d6561243f4134f5fbbf1ad6b39ac3c4b586554accaff4a1300 languageName: node linkType: hard @@ -40791,6 +43121,19 @@ __metadata: languageName: node linkType: hard +"teeny-request@npm:^9.0.0": + version: 9.0.0 + resolution: "teeny-request@npm:9.0.0" + dependencies: + http-proxy-agent: ^5.0.0 + https-proxy-agent: ^5.0.0 + node-fetch: ^2.6.9 + stream-events: ^1.0.5 + uuid: ^9.0.0 + checksum: 9cb0ad83f9ca6ce6515b3109cbb30ceb2533cdeab8e41c3a0de89f509bd92c5a9aabd27b3adf7f3e49516e106a358859b19fa4928a1937a4ab95809ccb7d52eb + languageName: node + linkType: hard + "teex@npm:^1.0.1": version: 1.0.1 resolution: "teex@npm:1.0.1" @@ -40906,7 +43249,7 @@ __metadata: languageName: node linkType: hard -"terminal-link@npm:^2.0.0, terminal-link@npm:^2.1.1": +"terminal-link@npm:2.1.1, terminal-link@npm:^2.0.0, terminal-link@npm:^2.1.1": version: 2.1.1 resolution: "terminal-link@npm:2.1.1" dependencies: @@ -41144,6 +43487,13 @@ __metadata: languageName: node linkType: hard +"tiny-async-pool@npm:^2.1.0": + version: 2.1.0 + resolution: "tiny-async-pool@npm:2.1.0" + checksum: 8891326f30e587590f94c5e1f8cab59c9aa305e442fc5b9f7ea997f8611d805a797aae2ea93cd00b42b494ef749353df38b4555e2a769d6fff31a3db7add7208 + languageName: node + linkType: hard + "tiny-glob@npm:^0.2.9": version: 0.2.9 resolution: "tiny-glob@npm:0.2.9" @@ -41566,7 +43916,7 @@ __metadata: languageName: unknown linkType: soft -"tslib@npm:1.14.1, tslib@npm:^1.11.1, tslib@npm:^1.8.1, tslib@npm:^1.9.3": +"tslib@npm:1.14.1, tslib@npm:^1.11.1, tslib@npm:^1.8.1, tslib@npm:^1.9.0, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd @@ -41580,10 +43930,10 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:^2.5.3": - version: 2.6.2 - resolution: "tslib@npm:2.6.2" - checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.5.0, tslib@npm:^2.5.3, tslib@npm:^2.6.2": + version: 2.6.3 + resolution: "tslib@npm:2.6.3" + checksum: 74fce0e100f1ebd95b8995fbbd0e6c91bdd8f4c35c00d4da62e285a3363aaa534de40a80db30ecfd388ed7c313c42d930ee0eaf108e8114214b180eec3dbe6f5 languageName: node linkType: hard @@ -41736,6 +44086,13 @@ __metadata: languageName: node linkType: hard +"typanion@npm:^3.14.0, typanion@npm:^3.8.0": + version: 3.14.0 + resolution: "typanion@npm:3.14.0" + checksum: fc0590d02c13c659eb1689e8adf7777e6c00dc911377e44cd36fe1b1271cfaca71547149f12cdc275058c0de5562a14e5273adbae66d47e6e0320e36007f5912 + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -42116,6 +44473,7 @@ __metadata: react: 18.2.0 react-native: 0.73.6 react-native-fast-image: 8.6.3 + react-native-gesture-handler: 2.15.0 react-native-image-colors: 1.5.2 react-native-reanimated: 3.8.1 react-native-safe-area-context: 4.9.0 @@ -42190,6 +44548,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487 + languageName: node + linkType: hard + "undici@npm:^5.12.0, undici@npm:^5.14.0, undici@npm:^5.22.1": version: 5.27.2 resolution: "undici@npm:5.27.2" @@ -42433,6 +44798,7 @@ __metadata: "@uniswap/sdk-core": 5.3.0 apollo-link-rest: 0.9.0 depcheck: 1.4.7 + es-toolkit: 1.10.0 eslint: 8.44.0 ethers: 5.7.2 expo-blur: 12.9.2 @@ -42442,9 +44808,10 @@ __metadata: get-graphql-schema: ^2.1.2 i18next: 23.10.0 i18next-resources-for-ts: 1.5.0 + i18next-resources-to-backend: ^1.2.0 jest: 29.7.0 jest-presets: "workspace:^" - lodash: 4.17.21 + openapi-typescript-codegen: 0.27.0 react: 18.2.0 react-i18next: 14.1.0 react-native: 0.73.6 @@ -42518,6 +44885,7 @@ __metadata: husky: ^8.0.3 i18next: 23.10.0 i18next-parser: 8.6.0 + moti: 0.29.0 prettier: 3.3.2 prettier-plugin-organize-imports: 3.2.4 syncpack: ^8.5.14 @@ -42930,6 +45298,7 @@ __metadata: "@amplitude/analytics-types": 0.13.0 "@apollo/client": 3.10.4 "@datadog/browser-logs": ^5.20.0 + "@datadog/mobile-react-native": 2.4.1 "@ethersproject/abstract-signer": 5.7.0 "@ethersproject/address": 5.7.0 "@ethersproject/constants": 5.7.0 @@ -42982,6 +45351,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 4b81611ade2885d2313ddd8dc865d93d8dccc13ddf901745edca8f86d99bc46d7a330d678e7532e7ebf93ce616679fb19b2e3568873ac0c14c999032acb25869 + languageName: node + linkType: hard + "uuid@npm:^3.0.1, uuid@npm:^3.3.2": version: 3.4.0 resolution: "uuid@npm:3.4.0" @@ -43513,7 +45891,6 @@ __metadata: lodash: 4.17.21 mockdate: 3.0.5 no-yolo-signatures: 0.0.2 - openapi-typescript-codegen: 0.27.0 react: 18.2.0 react-i18next: 14.1.0 react-native: 0.73.6 @@ -44541,9 +46918,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0, ws@npm:^7, ws@npm:^7.0.0, ws@npm:^7.3.1, ws@npm:^7.4.6, ws@npm:^7.5.1": - version: 7.5.9 - resolution: "ws@npm:7.5.9" +"ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0, ws@npm:^7, ws@npm:^7.0.0, ws@npm:^7.3.1, ws@npm:^7.4.6, ws@npm:^7.5.1, ws@npm:^7.5.10": + version: 7.5.10 + resolution: "ws@npm:7.5.10" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -44552,7 +46929,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: c3c100a181b731f40b7f2fddf004aa023f79d64f489706a28bc23ff88e87f6a64b3c6651fbec3a84a53960b75159574d7a7385709847a62ddb7ad6af76f49138 + checksum: f9bb062abf54cc8f02d94ca86dcd349c3945d63851f5d07a3a61c2fcb755b15a88e943a63cf580cbdb5b74436d67ef6b67f745b8f7c0814e411379138e1863cb languageName: node linkType: hard @@ -44777,6 +47154,13 @@ __metadata: languageName: node linkType: hard +"yamux-js@npm:0.1.2": + version: 0.1.2 + resolution: "yamux-js@npm:0.1.2" + checksum: 6c0ba09d55d0176a15d7ef5bc7dfc7bda87f7dd50abb5bd268d8e026a8af33ff3bdb20c4537662b062b1cb7c2c103d2554beba4bd66bd9d1d6f8dec9cedcae85 + languageName: node + linkType: hard + "yargs-parser@npm:20.2.4": version: 20.2.4 resolution: "yargs-parser@npm:20.2.4"