diff --git a/.agents/skills/docs-writer b/.agents/skills/docs-writer new file mode 120000 index 00000000000..a0dab145f75 --- /dev/null +++ b/.agents/skills/docs-writer @@ -0,0 +1 @@ +../../.claude/skills/docs-writer \ No newline at end of file diff --git a/.bun-version b/.bun-version index 3a3cd8cc8b0..17e63e7affd 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.3.1 +1.3.11 diff --git a/.claude/prompts/claude-pr-bot.md b/.claude/prompts/claude-pr-bot.md deleted file mode 100644 index f8b66d4433d..00000000000 --- a/.claude/prompts/claude-pr-bot.md +++ /dev/null @@ -1,413 +0,0 @@ -# Claude PR Review Assistant - -You are Claude, an AI assistant specialized in GitHub PR code reviews. You operate in REVIEW MODE, providing thorough feedback using GitHub MCP tools. - -## Your Mission - -Help developers ship better code by providing high-signal feedback that prevents bugs, improves maintainability, and teaches valuable principles. Every comment should make the codebase measurably better. - - -**CRITICAL OPERATING CONSTRAINTS:** -1. You can ONLY submit "COMMENT" reviews (technical limitation) -2. You MUST check existing comments first to avoid duplicates (respects reviewer time) -3. You MUST verify patch-id before reviewing (prevents wasted work) -4. You MUST update the sticky comment (your primary communication channel) - - -## Review Philosophy - -**Every PR tells a story.** Help make it clearer and more maintainable without rewriting it entirely. - -**Review the code, not the coder.** Focus on patterns and principles. - -**Teach through specifics.** Concrete examples stick better than abstract feedback. - -**Keep it high signal.** Every comment should prevent a bug, improve maintainability, or teach something valuable. - -**Engineering principles make daily work easier.** When you spot opportunities to apply separation of concerns, designing against contracts, or dependency injection, show how it makes testing and maintenance simpler. - - -Don't hold back on teaching opportunities. When you see code that could demonstrate better engineering principles, give it your all - provide concrete examples, show the transformation, explain the immediate benefits. Go above and beyond to help developers understand not just what to change, but why it makes their daily work easier. - - - -Repository: $REPOSITORY -PR Number: $PR_NUMBER -PR Title: $PR_TITLE -PR Body: $PR_BODY -Current Patch ID: $CURRENT_PATCH_ID -Existing Claude Comment ID: $CLAUDE_COMMENT_ID -Existing Comments: $PR_COMMENTS -Review Comments: $REVIEW_COMMENTS -Changed Files: $CHANGED_FILES -Trigger: $TRIGGER_COMMENT - - -## Review Workflow - -You will follow these steps sequentially, producing specific outputs at each stage. This ensures thoroughness and prevents common review mistakes like duplicate comments. - - - -### Step 1: Check Existing Comments (MANDATORY FIRST OUTPUT) - -**Why this matters:** Duplicate comments waste everyone's time and make reviews harder to follow. By checking first, you ensure every comment adds unique value. - -**Action:** -```bash -mcp__github__get_pull_request_comments -``` - -**Required output format:** -```xml - -Found X inline comments: -- path/to/file.ts:42 - "Missing null check for user object" -- path/to/other.ts:15 - "Console.log should be removed" -- path/to/component.tsx:88 - "Function doing too many things" -[List ALL existing comments with file:line and issue summary] - -Total existing inline comments: X -Proceeding to Step 2... - -``` - -**You cannot proceed without completing this output.** - -### Step 2: Verify Patch-ID - -**Why this matters:** PRs often get rebased or amended. If nothing actually changed, re-reviewing wastes time and creates noise. - -**Actions:** -1. Extract CURRENT_PATCH_ID from context -2. Check your existing sticky comment for previous patch-id -3. Compare the two - -**Required output format:** -```xml - -Current patch-id: ${CURRENT_PATCH_ID:0:12} -Previous patch-id: [from sticky comment or "none"] -Status: [CHANGED - proceeding with review | UNCHANGED - skipping review] - -``` - -**If UNCHANGED:** Update sticky comment with timestamp only and STOP. - -### Step 3: Analyze Changes - -**Why this matters:** Understanding the full context leads to better, more relevant feedback. - -**Actions to take:** -- `mcp__github__get_pull_request` - Full PR metadata -- `mcp__github__get_pull_request_status` - CI/CD status -- `Read`, `Grep`, `Glob` - Examine files directly -- Git commands for history analysis - -**While analyzing, reflect on:** -- Which functions are hardest to test due to mixed concerns? -- Where would dependency injection eliminate mocking complexity? -- What interfaces would enable parallel team development? -- Are there examples of these principles done well? - -**Useful git commands:** -```bash -git rev-parse HEAD # Current commit SHA -git log --oneline -10 # Recent commits -git diff ..HEAD --name-status # What changed -git log --since="4 hours ago" -p # Recent changes -``` - -**Required output format:** -```xml - -Files analyzed: X -Key changes identified: -- [Component/area]: [Type of change] -- [Component/area]: [Type of change] -Focus areas for review: [List 3-5 most important areas] -Engineering opportunities spotted: [Y opportunities for better patterns] - -``` - -### Step 4: Create Pending Review - -**Action:** -``` -mcp__github__create_pending_pull_request_review -``` - -**Required output:** -```xml - -Pending review created successfully - -``` - -### Step 5: Add Inline Comments - -**Why this matters:** Inline comments provide context-specific feedback exactly where it's needed, making it easier for developers to understand and fix issues. - - -For each potential comment, follow this decision tree: - -1. Check against existing comments from Step 1: - - Same file and line? → SKIP - - Same issue already mentioned? → SKIP - - Similar pattern already noted? → SKIP - -2. If checks pass, evaluate importance: - - Critical bug or security issue? → ADD COMMENT - - Clear improvement with obvious fix? → ADD COMMENT - - Engineering principle opportunity with clear benefit? → ADD COMMENT - - Minor style preference? → SKIP - - Already in sticky comment summary? → SKIP - -3. For comments you ADD: - ``` - mcp__github__add_comment_to_pending_review - Parameters: - - path: "src/file.ts" - - line: 42 (or startLine + line for multi-line) - - side: "RIGHT" - - subjectType: "line" - - body: "Issue description with suggestion" - ``` - -**GitHub suggestion block format:** -```suggestion -ONLY the replacement code for the commented lines -``` - -Remember: Suggestion blocks must contain ONLY the replacement lines, not surrounding context. - - -**Required output format:** -```xml - -Added X new inline comments: -- file.ts:42 - Security issue: SQL injection vulnerability -- other.ts:88 - Bug: Potential null reference -- service.ts:15 - Pattern: Function doing multiple jobs -Skipped Y duplicate issues already covered - -``` - -### Step 6: Update Sticky Comment - -**Why this matters:** The sticky comment provides a persistent, comprehensive overview of your review that doesn't get lost in the PR discussion. - -**Action:** -``` -mcp__github_comment__update_claude_comment -``` - -**Required format:** -```markdown -
-🤖 Claude's Code Review (click to expand) - -### Review Summary -- **Updated:** [timestamp] -- **Commit:** [SHA from git rev-parse HEAD] -- **Patch ID:** `${CURRENT_PATCH_ID:0:12}` -- **Review Stats:** Found X existing comments, added Y new comments - -### Changes Since Last Review -[Only if this is a re-review after rebase/changes] -- Previous commit: [SHA] -- Key changes: [What actually changed vs just moved] - -### Critical Issues 🔴 -[Must-fix problems that could break production] -- [Issue description and location] - -### Improvements Suggested 🟡 -[Patterns and maintainability enhancements] -- [Suggestion with rationale] - -[When teaching a principle, include a brief example:] -**Example: Simplifying Testing Through Separation** -```ts -// From: handleSwap() doing validation + fetching + building -// To: Three focused functions that test independently -validateSwapInputs(token, amount) // Test with just inputs -fetchTokenPrice(tokenId) // Test with mock response -buildSwapTransaction(token, price) // Test with fixed values -``` - -### Good Practices Observed ✅ -[Only if truly noteworthy - especially good applications of engineering principles] -- Clean separation of concerns in [specific function/module] -- Excellent use of dependency injection in [specific area] - -### Action Items -1. [Most important fix] -2. [Second priority] -3. [Third priority] - -
-``` - -### Step 7: Submit Review - -**Action:** -``` -mcp__github__submit_pending_pull_request_review -Parameters: -- event: "COMMENT" # ALWAYS -- body: "Review complete - see inline comments and summary above" -``` - -**Required output:** -```xml - -Review submitted successfully with X inline comments - -``` - -
- -## Review Priorities - - -### Phase 1: Critical Issues (Must Fix) -Focus on problems that would cause immediate harm: -- Bugs or logic errors -- Security vulnerabilities -- Performance problems impacting users -- Data corruption risks -- Race conditions - -### Phase 2: Patterns & Principles -Improve code maintainability and team velocity: -- **Functions doing too many things** → *Why it matters: Can't test pieces independently, changes ripple everywhere* -- **Hidden dependencies** → *Why it matters: Makes testing require complex mocking, creates surprising behaviors* -- **Missing abstractions/contracts** → *Why it matters: Couples code to specific implementations, blocks parallel development* -- **Missing error handling** → *Why it matters: Silent failures in production, hard to debug issues* -- **Direct imports instead of injection** → *Why it matters: Can't swap implementations, hard to test* - -### Phase 3: Polish (Only if valuable) -Nice-to-haves that make code better: -- Naming improvements -- Test coverage -- Documentation -- Refactoring opportunities - - - -### What These Patterns Look Like in Code - -**Spot this pattern (mixed concerns):** -```ts -async function handleUserAction(userId, action) { - // Validation - if (!userId) throw new Error('Invalid user'); - // Fetching - const user = await db.getUser(userId); - // Business logic - const result = processAction(user, action); - // Saving - await db.save(result); - return result; -} -``` - -**Teach this improvement:** -"This function has 4 separate responsibilities. Splitting them makes testing trivial: -- `validateInput(userId)` - test with simple inputs -- `fetchUser(userId, db)` - test with mock db: `{ getUser: () => mockUser }` -- `processAction(user, action)` - pure function test -- `saveResult(result, db)` - test save logic alone - -Each can be tested without mocking the others!" - -**Spot this pattern (hardcoded dependency):** -```javascript -import { stripeClient } from './stripe'; -async function chargeCard(amount) { - return stripeClient.charge(amount); -} -``` - -**Teach this improvement:** -"Accepting the payment client as a parameter would make this more flexible: -```javascript -async function chargeCard(amount, paymentClient) { - return paymentClient.charge(amount); -} -``` -Benefits: -- Test with: `chargeCard(100, { charge: async () => ({ success: true }) })` -- Swap providers without changing this code -- No vendor lock-in" - - - -Actively look for opportunities to recognize good patterns. When you see: -- Clean separation of concerns (functions doing one thing) -- Well-defined interfaces/contracts -- Proper dependency injection -- Good error handling patterns - -Call it out specifically and explain why it's excellent. This reinforces good practices. Example: -"Excellent separation here - `validateOrder()` only validates, making it a pure function that's trivial to test!" - - -## Communication Guidelines - - -### Tone Examples - -**For bugs:** -> "I found a potential issue here: accessing `user.preferences` could throw if user is null. Since this comes from an API response, we should add a safety check: -> -> ```suggestion -> const theme = user?.preferences?.theme || 'default'; -> ``` -> -> This prevents those frustrating 'cannot read property of undefined' production errors." - -**For patterns:** -> "This function handles validation, data fetching, and UI updates. Breaking these into separate functions would make testing much easier: -> - Test validation without any API mocking -> - Test data fetching with a simple mock response -> - Test UI updates with fixed data -> -> Each piece becomes independently testable, and changes stay contained to their specific function." - -**For enhancements:** -> "Consider extracting this price calculation logic into a utility function. Not required, but it would make this cleaner and easier to test." - -### What to avoid: -- Starting with "Great job!" or "Nice work!" -- Apologizing ("Sorry, but...") -- Hedging ("Maybe you could...") -- Nitpicking without value -- Abstract theory without concrete examples - - -## Quality Checklist - -Before submitting your review, verify: -- ✅ Completed Step 1 existing comments check? -- ✅ No duplicate comments added? -- ✅ All comments are actionable? -- ✅ Updated sticky comment with summary? -- ✅ Using "COMMENT" review type? -- ✅ Limited to 5-7 inline comments max? -- ✅ Included at least one teaching moment if opportunity existed? - -## Remember - -You're helping developers: -1. Ship working code safely -2. Learn better patterns through their actual code -3. Build maintainable systems that scale with the team -4. Make testing and debugging easier today, not someday - -Balance teaching with shipping. Balance idealism with pragmatism. When teaching principles, always connect to immediate, practical benefits. - ---- - -**BEGIN REVIEW:** Start with Step 1 - check existing comments and show what you found. diff --git a/.cursor/rules/mobile/styling.mdc b/.cursor/rules/mobile/styling.mdc deleted file mode 100644 index 8f8f08af0dc..00000000000 --- a/.cursor/rules/mobile/styling.mdc +++ /dev/null @@ -1,31 +0,0 @@ ---- -description: Mobile styling conventions -globs: apps/mobile/**/*.ts* -alwaysApply: false ---- -# Mobile Styling Conventions - -## Component Styling -- Prefer Tamagui inline props over other methods - -## Theme Usage -- Use theme tokens from the UI package instead of hardcoded values -- Reference color tokens like `$neutral1` instead of hex values -- Use spacing tokens like `$spacing16` instead of raw numbers - -## Layout -- Use `Flex` from `ui/src` instead of View when possible -- Avoid nested ScrollViews which can cause performance issues -- Minimize view hierarchy depth - -## Platform Specific Code -- Use `..tsx` extensions for platform-specific components -- The bundler will grab the appropriate file during the build and always fallback to `.tsx` -- The `platform` variable must be one of the following: ios, android, macos, windows, web, native -- Use the `Platform.select` API for inline platform-specific code. This method expects an object keyed by `platform`. -- Also consider using our custom platform variables like `isMobileApp`, `isInterface`, etc. for more specific platform detection needs. - -## Performance -- Memoize complex style calculations -- Avoid large inline styles -- Use hardware acceleration for animations when possible diff --git a/.cursor/rules/shared/components.mdc b/.cursor/rules/shared/components.mdc deleted file mode 100644 index 2706e96faad..00000000000 --- a/.cursor/rules/shared/components.mdc +++ /dev/null @@ -1,82 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# Component Structure and Best Practices - -## Component Organization -- Place state and hooks at the top of the component -- Group related state variables together -- Define handlers after state declarations -- Place JSX return statement at the end of the component - -## Props -- Use interface for component props -- Place prop interface directly above component -- Complex or shared types can be moved to a types.ts file -- Use descriptive prop names -- Provide default props where appropriate - -## Performance Optimizations -- Memoize expensive calculations with useMemo -- Memoize event handlers with useCallback or use our custom useEvent hook -- Use React.memo for pure components that render often -- Avoid anonymous functions in render - -## Component Size -- Keep components focused on a single responsibility -- Extract complex components into smaller, reusable pieces -- Aim for less than 250 lines per component file -- Extract prop interfaces and types to separate files if they become complex - -## Component Structure Example - -```typescript -interface ExampleComponentProps { - prop1: string; - prop2: () => void; -} - -export function ExampleComponent({ prop1, prop2 }: ExampleComponentProps): JSX.Element { - // State declarations - const [state1, setState1] = useState(false) - const [state2, setState2] = useState('') - - // Queries and mutations - const { data, isPending } = useQuery(exampleQueries.getData(prop1)) - const mutation = useMutation({ - mutationFn: () => exampleService.submit(prop1), - onSuccess: prop2 - }) - - // Derived values - const derivedValue = useMemo(() => { - return someCalculation(state1, data) - }, [state1, data]) - - // Event handlers - const handleClick = useCallback(() => { - setState1(!state1) - mutation.mutate() - }, [state1, mutation]) - - // Side effects - useEffect(() => { - // Effect logic - }, [prop2]) - - // Conditional rendering logic - if (isPending) { - return - } - - // Component JSX - return ( - - {derivedValue} - + + ) +} diff --git a/apps/extension/src/app/components/buttons/OptionCard.tsx b/apps/extension/src/app/components/buttons/OptionCard.tsx index 0273a209ea1..a535ee98249 100644 --- a/apps/extension/src/app/components/buttons/OptionCard.tsx +++ b/apps/extension/src/app/components/buttons/OptionCard.tsx @@ -18,7 +18,7 @@ export function OptionCard({ shadowColor="$shadowColor" shadowOpacity={0.05} shadowRadius={8} - borderWidth={1} + borderWidth="$spacing1" borderColor="$surface3" borderRadius="$rounded20" onPress={onPress} @@ -33,7 +33,7 @@ export function OptionCard({ - + {title} diff --git a/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx index 012f3db4949..71a588543cf 100644 --- a/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx +++ b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx @@ -5,7 +5,7 @@ import { WALLET_PREVIEW_CARD_MIN_HEIGHT } from 'wallet/src/components/WalletPrev export function SelectWalletsSkeleton({ repeat = 3 }: { repeat?: number }): JSX.Element { return ( - {/* eslint-disable-next-line max-params */} + {/* oxlint-disable-next-line max-params */} {new Array(repeat).fill(null).map((_, i, { length }) => ( ))} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.tsx b/apps/extension/src/app/components/loading/SkeletonBox.tsx index faa8ebfc37d..eabca0b1e05 100644 --- a/apps/extension/src/app/components/loading/SkeletonBox.tsx +++ b/apps/extension/src/app/components/loading/SkeletonBox.tsx @@ -12,6 +12,6 @@ export function SkeletonBox({ height: number | string borderRadius?: string }): JSX.Element { - // biome-ignore lint/correctness/noRestrictedElements: needed here + // oxlint-disable-next-line react/forbid-elements -- needed here return
} diff --git a/apps/extension/src/app/components/tabs/ActivityTab.tsx b/apps/extension/src/app/components/tabs/ActivityTab.tsx index 95ca43b900f..bb2d0e200dc 100644 --- a/apps/extension/src/app/components/tabs/ActivityTab.tsx +++ b/apps/extension/src/app/components/tabs/ActivityTab.tsx @@ -3,7 +3,7 @@ import { Flex, Loader, ScrollView } from 'ui/src' import { useInfiniteScroll } from 'utilities/src/react/useInfiniteScroll' import { useActivityDataWallet } from 'wallet/src/features/activity/useActivityDataWallet' -export const ActivityTab = memo(function _ActivityTab({ +export const ActivityTab = memo(function ActivityTabInner({ address, skip, }: { diff --git a/apps/extension/src/app/components/tabs/NftsTab.tsx b/apps/extension/src/app/components/tabs/NftsTab.tsx index 781cc7da8a0..1e34b3ad905 100644 --- a/apps/extension/src/app/components/tabs/NftsTab.tsx +++ b/apps/extension/src/app/components/tabs/NftsTab.tsx @@ -9,7 +9,7 @@ import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constan import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { useAccounts } from 'wallet/src/features/wallet/hooks' -export const NftsTab = memo(function _NftsTab({ owner, skip }: { owner: Address; skip?: boolean }): JSX.Element { +export const NftsTab = memo(function NftsTabInner({ owner, skip }: { owner: Address; skip?: boolean }): JSX.Element { const accounts = useAccounts() const renderNFTItem = useCallback( diff --git a/apps/extension/src/app/context/SmartWalletNudgesContext.tsx b/apps/extension/src/app/context/SmartWalletNudgesContext.tsx index 09f0f11af6c..1591c19be34 100644 --- a/apps/extension/src/app/context/SmartWalletNudgesContext.tsx +++ b/apps/extension/src/app/context/SmartWalletNudgesContext.tsx @@ -74,7 +74,7 @@ export function SmartWalletNudgesProvider({ children }: { children: ReactNode }) delegationStatus.status === SmartWalletDelegationAction.PromptUpgrade && !delegationStatus.loading - // biome-ignore lint/correctness/useExhaustiveDependencies: delegationStatus is used in shouldShowNudge calculation above + // oxlint-disable-next-line react/exhaustive-deps -- delegationStatus is used in shouldShowNudge calculation above useEffect(() => { if (last5792DappInfo && shouldShowNudge) { setDappInfo({ diff --git a/apps/extension/src/app/core/BaseAppContainer.tsx b/apps/extension/src/app/core/BaseAppContainer.tsx index c29cba1056e..f2ff6aa9995 100644 --- a/apps/extension/src/app/core/BaseAppContainer.tsx +++ b/apps/extension/src/app/core/BaseAppContainer.tsx @@ -1,40 +1,103 @@ import { ApiInit, getEntryGatewayUrl, provideSessionService } from '@universe/api' import { + getIsHashcashSolverEnabled, getIsSessionServiceEnabled, + getIsSessionsPerformanceTrackingEnabled, getIsSessionUpgradeAutoEnabled, + getIsTurnstileSolverEnabled, useIsSessionServiceEnabled, } from '@universe/gating' import { + type ChallengeSolver, + ChallengeType, createChallengeSolverService, + createHashcashMockSolver, + createHashcashSolver, + createHashcashWorkerChannel, + createPerformanceTracker, createSessionInitializationService, - SessionInitializationService, + createTurnstileMockSolver, + type SessionInitializationService, } from '@universe/sessions' -import { PropsWithChildren } from 'react' +import { PropsWithChildren, useEffect } from 'react' import { I18nextProvider } from 'react-i18next' import { GraphqlProvider } from 'src/app/apollo' import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties' -import { SmartWalletNudgesProvider } from 'src/app/context/SmartWalletNudgesContext' import { ExtensionStatsigProvider } from 'src/app/core/StatsigProvider' -import { DatadogAppNameTag } from 'src/app/datadog' +import { type DatadogAppNameTag } from 'src/app/datadog' +import { onHashcashSolveCompleted, sessionInitAnalytics } from 'src/app/features/sessions/analytics' +import { useOnCrashAppStateResetter } from 'src/store/appStateResetter' import { getReduxStore } from 'src/store/store' import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext' +import { useCurrentLanguage } from 'uniswap/src/features/language/hooks' import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext' +import { getLocale } from 'uniswap/src/features/language/navigatorLocale' import Trace from 'uniswap/src/features/telemetry/Trace' -import i18n from 'uniswap/src/i18n' +import i18n, { changeLanguage } from 'uniswap/src/i18n' +import { getLogger } from 'utilities/src/logger/logger' import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' -import { AccountsStoreContextProvider } from 'wallet/src/features/accounts/store/provider' +import { StatsigUserIdentifiersUpdater } from 'wallet/src/features/gating/StatsigUserIdentifiersUpdater' import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider' -const provideSessionInitializationService = (): SessionInitializationService => - createSessionInitializationService({ +const provideSessionInitializationService = (): SessionInitializationService => { + // Create performance tracker with feature flag control + const performanceTracker = createPerformanceTracker({ + getIsPerformanceTrackingEnabled: getIsSessionsPerformanceTrackingEnabled, + getNow: () => performance.now(), + }) + + const solvers = new Map() + + if (getIsTurnstileSolverEnabled()) { + solvers.set(ChallengeType.TURNSTILE, createTurnstileMockSolver()) + } else { + solvers.set(ChallengeType.TURNSTILE, createTurnstileMockSolver()) + } + + if (getIsHashcashSolverEnabled()) { + solvers.set( + ChallengeType.HASHCASH, + createHashcashSolver({ + performanceTracker, + getWorkerChannel: () => + createHashcashWorkerChannel({ + getWorker: () => + new Worker( + new URL('@universe/sessions/src/challenge-solvers/hashcash/worker/hashcash.worker.ts', import.meta.url), + { type: 'module' }, + ), + }), + onSolveCompleted: onHashcashSolveCompleted, + getLogger, + }), + ) + } else { + solvers.set(ChallengeType.HASHCASH, createHashcashMockSolver()) + } + + return createSessionInitializationService({ getSessionService: () => provideSessionService({ getBaseUrl: getEntryGatewayUrl, getIsSessionServiceEnabled, }), - challengeSolverService: createChallengeSolverService(), + challengeSolverService: createChallengeSolverService({ + solvers, + }), + performanceTracker, getIsSessionUpgradeAutoEnabled, + getLogger, + analytics: sessionInitAnalytics, }) +} + +/** + * Inner component that uses hooks requiring Redux context. + */ +function ErrorBoundaryWrapper({ children }: PropsWithChildren): JSX.Element { + const onCrashAppStateResetter = useOnCrashAppStateResetter() + return {children} +} function BaseAppContainerInner({ children }: PropsWithChildren): JSX.Element { const isSessionServiceEnabled = useIsSessionServiceEnabled() @@ -42,24 +105,22 @@ function BaseAppContainerInner({ children }: PropsWithChildren): JSX.Element { return ( - - - - - - - - - {children} - - - - - - + + + + + + + + + {children} + + + + ) @@ -77,3 +138,13 @@ export function BaseAppContainer({ ) } + +function LanguageSync(): null { + const currentLanguage = useCurrentLanguage() + + useEffect(() => { + changeLanguage(getLocale(currentLanguage)).catch(() => undefined) + }, [currentLanguage]) + + return null +} diff --git a/apps/extension/src/app/core/DevMenuModal.tsx b/apps/extension/src/app/core/DevMenuModal.tsx index 098151b962e..20881f4c531 100644 --- a/apps/extension/src/app/core/DevMenuModal.tsx +++ b/apps/extension/src/app/core/DevMenuModal.tsx @@ -22,7 +22,7 @@ export function DevMenuModal(): JSX.Element { p="$spacing4" left="$spacing24" zIndex={Number.MAX_SAFE_INTEGER} - borderWidth={1} + borderWidth="$spacing1" borderColor="$neutral2" borderRadius="$rounded4" cursor="pointer" diff --git a/apps/extension/src/app/core/OnboardingApp.test.tsx b/apps/extension/src/app/core/OnboardingApp.test.tsx index f5d23347a8c..b5506947974 100644 --- a/apps/extension/src/app/core/OnboardingApp.test.tsx +++ b/apps/extension/src/app/core/OnboardingApp.test.tsx @@ -7,7 +7,7 @@ jest.mock('wallet/src/features/transactions/contexts/WalletUniswapContext', () = })) describe('OnboardingApp', () => { - // eslint-disable-next-line jest/expect-expect + // oxlint-disable-next-line jest/expect-expect it('renders without error', async () => { initializeReduxStore() render() diff --git a/apps/extension/src/app/core/OnboardingApp.tsx b/apps/extension/src/app/core/OnboardingApp.tsx index bb3e054587c..5dafe03d522 100644 --- a/apps/extension/src/app/core/OnboardingApp.tsx +++ b/apps/extension/src/app/core/OnboardingApp.tsx @@ -1,7 +1,6 @@ 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 { createHashRouter, RouteObject, RouterProvider } from 'react-router' import { PersistGate } from 'redux-persist/integration/react' @@ -32,8 +31,8 @@ import { OnboardingWrapper } from 'src/app/features/onboarding/OnboardingWrapper import { PasswordImport } from 'src/app/features/onboarding/PasswordImport' 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 { ScanToOnboard } from 'src/app/features/onboarding/scan/ScanToOnboard' import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' import { OnboardingNavigationProvider } from 'src/app/navigation/providers' import { setRouter, setRouterState } from 'src/app/navigation/state' @@ -43,6 +42,7 @@ import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebu import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { AccountsStoreContextProvider } from 'wallet/src/features/accounts/store/provider' import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts/WalletUniswapContext' import { getReduxPersistor } from 'wallet/src/state/persistor' @@ -196,8 +196,10 @@ export default function OnboardingApp(): JSX.Element { - - + + + + diff --git a/apps/extension/src/app/core/PopupApp.tsx b/apps/extension/src/app/core/PopupApp.tsx index 7e4b15af8d9..79fc8880a85 100644 --- a/apps/extension/src/app/core/PopupApp.tsx +++ b/apps/extension/src/app/core/PopupApp.tsx @@ -1,6 +1,5 @@ import '@tamagui/core/reset.css' import 'src/app/Global.css' - import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { createHashRouter, RouterProvider } from 'react-router' diff --git a/apps/extension/src/app/core/SidebarApp.tsx b/apps/extension/src/app/core/SidebarApp.tsx index eeadc95bfa9..0f5f366c2d4 100644 --- a/apps/extension/src/app/core/SidebarApp.tsx +++ b/apps/extension/src/app/core/SidebarApp.tsx @@ -1,6 +1,5 @@ import '@tamagui/core/reset.css' import 'src/app/Global.css' - import { SharedEventName } from '@uniswap/analytics-events' import { useEffect, useRef, useState } from 'react' import { useDispatch } from 'react-redux' @@ -19,12 +18,15 @@ import { SendFlow } from 'src/app/features/send/SendFlow' import { BackupRecoveryPhraseScreen } from 'src/app/features/settings/BackupRecoveryPhrase/BackupRecoveryPhraseScreen' import { DeviceAccessScreen } from 'src/app/features/settings/DeviceAccessScreen' import { DevMenuScreen } from 'src/app/features/settings/DevMenuScreen' +import { HashcashBenchmarkScreen } from 'src/app/features/settings/HashcashBenchmarkScreen' +import { SessionsDebugScreen } from 'src/app/features/settings/SessionsDebugScreen' import { SettingsManageConnectionsScreen } from 'src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen' import { RemoveRecoveryPhraseVerify } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify' import { RemoveRecoveryPhraseWallets } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets' import { ViewRecoveryPhraseScreen } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen' import { SettingsScreen } from 'src/app/features/settings/SettingsScreen' import { SettingsScreenWrapper } from 'src/app/features/settings/SettingsScreenWrapper' +import { SettingsStorageScreen } from 'src/app/features/settings/SettingsStorageScreen' import { SmartWalletSettingsScreen } from 'src/app/features/settings/SmartWalletSettingsScreen' import { SwapFlowScreen } from 'src/app/features/swap/SwapFlowScreen' import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' @@ -75,12 +77,22 @@ const router = createHashRouter([ path: SettingsRoutes.DeviceAccess, element: , }, - isDevEnv() - ? { - path: SettingsRoutes.DevMenu, - element: , - } - : {}, + ...(isDevEnv() + ? [ + { + path: SettingsRoutes.DevMenu, + element: , + }, + { + path: SettingsRoutes.SessionsDebug, + element: , + }, + { + path: SettingsRoutes.HashcashBenchmark, + element: , + }, + ] + : []), { path: SettingsRoutes.ViewRecoveryPhrase, element: , @@ -110,6 +122,10 @@ const router = createHashRouter([ path: SettingsRoutes.SmartWallet, element: , }, + { + path: SettingsRoutes.Storage, + element: , + }, ], }, { @@ -134,13 +150,14 @@ function useDappRequestPortListener(): void { const [currentPortChannel, setCurrentPortChannel] = useState() const [windowId, setWindowId] = useState() - // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on component mount for initial setup, disconnect cleanup is managed separately + // oxlint-disable-next-line react/exhaustive-deps -- Only run on component mount for initial setup, disconnect cleanup is managed separately useEffect(() => { chrome.windows.getCurrent((window) => { setWindowId(window.id?.toString()) }) return () => currentPortChannel?.port.disconnect() + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, []) useEffect(() => { diff --git a/apps/extension/src/app/core/UnitagClaimApp.tsx b/apps/extension/src/app/core/UnitagClaimApp.tsx index b8fc851fc8a..24c20b5f7da 100644 --- a/apps/extension/src/app/core/UnitagClaimApp.tsx +++ b/apps/extension/src/app/core/UnitagClaimApp.tsx @@ -1,6 +1,5 @@ import '@tamagui/core/reset.css' import 'src/app/Global.css' - import { PropsWithChildren, useEffect } from 'react' import { createHashRouter, Outlet, RouterProvider, useSearchParams } from 'react-router' import { ErrorElement } from 'src/app/components/ErrorElement' @@ -52,7 +51,7 @@ const router = createHashRouter([ * router/router state to a different file so it can be imported by those pages */ -// biome-ignore lint/suspicious/noExplicitAny: Router state object has dynamic structure from react-router +// oxlint-disable-next-line typescript/no-explicit-any -- Router state object has dynamic structure from react-router router.subscribe((state: any) => { setRouterState(state) }) @@ -77,7 +76,7 @@ function UnitagAppInner(): JSX.Element { // needed to reload on address param change for hash router router .navigate(0) - // biome-ignore lint/suspicious/noExplicitAny: Router state object has dynamic structure from react-router + // oxlint-disable-next-line typescript/no-explicit-any -- Router state object has dynamic structure from react-router .catch((e: any) => logger.error(e, { tags: { file: 'UnitagClaimApp.tsx', function: 'UnitagClaimAppInner' } })) } }, [address, prevAddress]) diff --git a/apps/extension/src/app/features/accounts/AccountItem.tsx b/apps/extension/src/app/features/accounts/AccountItem.tsx index 99eb7319601..9889dbdda0b 100644 --- a/apps/extension/src/app/features/accounts/AccountItem.tsx +++ b/apps/extension/src/app/features/accounts/AccountItem.tsx @@ -1,5 +1,5 @@ import { SharedEventName } from '@uniswap/analytics-events' -import { BaseSyntheticEvent, useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal' @@ -10,6 +10,8 @@ import { Flex, Text, TouchableArea } from 'ui/src' import { CopySheets, Edit, Ellipsis, Globe, TrashFilled } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { ContextMenu, MenuOptionItem } from 'uniswap/src/components/menus/ContextMenu' +import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' import { DisplayNameType } from 'uniswap/src/features/accounts/types' @@ -18,10 +20,9 @@ import { pushNotification } from 'uniswap/src/features/notifications/slice/slice import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' 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 { setClipboard } from 'utilities/src/clipboard/clipboard' import { NumberType } from 'utilities/src/format/types' -import { ContextMenu } from 'wallet/src/components/menu/ContextMenu' -import { MenuContentItem } from 'wallet/src/components/menu/types' +import { useBooleanState } from 'utilities/src/react/useBooleanState' import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' @@ -41,6 +42,7 @@ export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountIte const formattedBalance = convertFiatAmountFormatted(balanceUSD, NumberType.PortfolioBalance) const [showEditLabelModal, setShowEditLabelModal] = useState(false) + const { value: isContextMenuOpen, setTrue: openMenu, setFalse: closeMenu } = useBooleanState(false) const accounts = useSignerAccounts() const displayName = useDisplayName(address) @@ -63,30 +65,21 @@ export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountIte ) }, [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 onPressCopyAddress = useCallback(async (): Promise => { + 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[] => { + const menuOptions = useMemo((): MenuOptionItem[] => { return [ { label: t('account.wallet.menu.copy.title'), @@ -97,12 +90,7 @@ export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountIte label: !accountHasUnitag ? t('account.wallet.menu.edit.title') : t('settings.setting.wallet.action.editProfile'), - onPress: async (e: BaseSyntheticEvent): Promise => { - // 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() - + onPress: async (): Promise => { if (accountHasUnitag) { await focusOrCreateUnitagTab(address, UnitagClaimRoutes.EditProfile) } else { @@ -113,29 +101,20 @@ export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountIte }, { label: t('account.wallet.menu.manageConnections'), - 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() - - navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`) + onPress: async (): Promise => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}?address=${address}`) }, Icon: Globe, }, { 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() - + onPress: (): void => { setShowRemoveWalletModal(true) }, - textProps: { color: '$statusCritical' }, + textColor: '$statusCritical', Icon: TrashFilled, - iconProps: { color: '$statusCritical' }, + iconColor: '$statusCritical', + destructive: true, }, ] }, [accountHasUnitag, onPressCopyAddress, navigateTo, t, address]) @@ -171,16 +150,22 @@ export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountIte size={iconSizes.icon40} variant="subheading2" /> - + - + {formattedBalance} () @@ -192,37 +195,35 @@ export function AccountSwitcherScreen(): JSX.Element { zIndex: 1, } - const menuOptions = useMemo((): MenuContentItem[] => { + const menuOptions = useMemo((): MenuOptionItem[] => { return [ ...(canClaimUnitag ? [ { label: t('account.wallet.menu.claimUsername'), - - onPress: async () => await focusOrCreateUnitagTab(activeAddress, UnitagClaimRoutes.ClaimIntro), - + onPress: async (): Promise => { + await focusOrCreateUnitagTab(activeAddress, UnitagClaimRoutes.ClaimIntro) + }, Icon: Person, }, ] : []), - { label: t('account.wallet.menu.manageConnections'), - onPress: () => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`), + onPress: (): void => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}?address=${activeAddress}`) + }, Icon: Globe, }, { 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() + onPress: (): void => { setShowRemoveWalletModal(true) }, - textProps: { color: '$statusCritical' }, + textColor: '$statusCritical', Icon: TrashFilled, - iconProps: { color: '$statusCritical' }, + iconColor: '$statusCritical', + destructive: true, }, ] }, [canClaimUnitag, activeAddress, navigateTo, t]) @@ -270,12 +271,11 @@ export function AccountSwitcherScreen(): JSX.Element { Icon={X} rightColumn={ - + { + // oxlint-disable-next-line typescript/await-thenable -- biome-parity: oxlint is stricter here await dispatch( editAccountActions.trigger({ type: EditAccountAction.Rename, 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 index eed50298533..032019f493e 100644 --- a/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap +++ b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap @@ -46,11 +46,9 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _pr-t-space-spa94665593 _pl-t-space-spa94665593 _pt-t-space-spa94665589 _pb-t-space-spa94665589 _width-10037" >
@@ -90,11 +88,9 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` >
- - - - - - - + +
- - 0x​9EB67f...D9A2Ca -
+ + 0x​9EB67f...D9A2Ca +
- - - + + + +
@@ -259,12 +245,10 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row" >
Add wallet @@ -416,11 +400,9 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _pr-t-space-spa94665593 _pl-t-space-spa94665593 _pt-t-space-spa94665589 _pb-t-space-spa94665589 _width-10037" >
@@ -460,11 +442,9 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` >
- - - - - - - + +
- - 0x​9EB67f...D9A2Ca -
+ + 0x​9EB67f...D9A2Ca +
- - - + + + +
@@ -629,12 +599,10 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row" >
Add wallet diff --git a/apps/extension/src/app/features/accounts/useSortedAccountList.ts b/apps/extension/src/app/features/accounts/useSortedAccountList.ts index 94c418963a8..db605147f6e 100644 --- a/apps/extension/src/app/features/accounts/useSortedAccountList.ts +++ b/apps/extension/src/app/features/accounts/useSortedAccountList.ts @@ -12,7 +12,7 @@ export function useSortedAccountList(addresses: Address[]): AddressWithBalance[] addresses, }) - /* + /* Why are we using previousAccountBalanceData? This is a workaround for a data fetching inefficiency. When removing an address, we send a new query diff --git a/apps/extension/src/app/features/appRating/hooks/useAppRating.ts b/apps/extension/src/app/features/appRating/hooks/useAppRating.ts deleted file mode 100644 index f9f29ceac61..00000000000 --- a/apps/extension/src/app/features/appRating/hooks/useAppRating.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useState } from 'react' -import { useSelector } from 'react-redux' -import { appRatingStateSelector } from 'wallet/src/features/appRating/selectors' - -export const useAppRating = (): { - appRatingModalVisible: boolean - onAppRatingModalClose: () => void -} => { - const { shouldPrompt } = useSelector(appRatingStateSelector) - const [appRatingModalVisible, setAppRatingModalVisible] = useState(false) - - useEffect(() => { - if (shouldPrompt) { - setAppRatingModalVisible(true) - } - }, [shouldPrompt]) - - const onAppRatingModalClose = (): void => { - setAppRatingModalVisible(false) - } - - return { - appRatingModalVisible, - onAppRatingModalClose, - } -} diff --git a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts index 07f13845870..76793d5b259 100644 --- a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts +++ b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockDisableMutation.ts @@ -1,6 +1,6 @@ import { UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query' -import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' import { biometricUnlockCredentialQuery } from 'src/app/features/biometricUnlock/biometricUnlockCredentialQuery' +import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' import { logger } from 'utilities/src/logger/logger' export function useBiometricUnlockDisableMutation(): UseMutationResult { @@ -11,6 +11,7 @@ export function useBiometricUnlockDisableMutation(): UseMutationResult { + // oxlint-disable-next-line typescript/no-floating-promises -- biome-parity: oxlint is stricter here queryClient.invalidateQueries(biometricUnlockCredentialQuery()) }, onError: (error) => { diff --git a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts index bd0e4616f51..b7592f2b7ee 100644 --- a/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts +++ b/apps/extension/src/app/features/biometricUnlock/useBiometricUnlockSetupMutation.ts @@ -1,10 +1,10 @@ import { UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query' +import { encryptPasswordWithBiometricData } from 'src/app/features/biometricUnlock/biometricAuthUtils' +import { biometricUnlockCredentialQuery } from 'src/app/features/biometricUnlock/biometricUnlockCredentialQuery' import { BiometricUnlockStorage, BiometricUnlockStorageData, } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' -import { encryptPasswordWithBiometricData } from 'src/app/features/biometricUnlock/biometricAuthUtils' -import { biometricUnlockCredentialQuery } from 'src/app/features/biometricUnlock/biometricUnlockCredentialQuery' import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal' import { assertPublicKeyCredential } from 'src/app/features/biometricUnlock/utils/assertPublicKeyCredential' import { isUserVerifyingPlatformAuthenticatorAvailable } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery' @@ -16,6 +16,11 @@ import { getEncryptionKeyFromBuffer, } from 'wallet/src/features/wallet/Keyring/crypto' +// Extend PublicKeyCredentialCreationOptions to include Chrome 128+ hints property +interface PublicKeyCredentialCreationOptionsWithHints extends PublicKeyCredentialCreationOptions { + hints?: string[] +} + export function useBiometricUnlockSetupMutation(options?: { onSuccess?: () => void onError?: (error: Error) => void @@ -35,6 +40,7 @@ export function useBiometricUnlockSetupMutation(options?: { }, retry: false, onSettled: () => { + // oxlint-disable-next-line typescript/no-floating-promises -- biome-parity: oxlint is stricter here queryClient.invalidateQueries(biometricUnlockCredentialQuery()) }, onSuccess: options?.onSuccess, @@ -136,12 +142,12 @@ async function createCredential({ residentKey: 'required', userVerification: 'required', }, - // @ts-expect-error - `hints` is a new property, only available in Chrome 128+. - // This forces the credential to use the built-in passkey instead of prompting the user where to save it. - hints: ['client-device'], pubKeyCredParams: CREDENTIAL_ALGORITHMS, timeout: 15 * ONE_SECOND_MS, - }, + // `hints` is a new property, only available in Chrome 128+. + // This forces the credential to use the built-in passkey instead of prompting the user where to save it. + hints: ['client-device'], + } as PublicKeyCredentialCreationOptionsWithHints, signal: abortSignal, }) diff --git a/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts index c8f23d90136..b97ee6462c5 100644 --- a/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts +++ b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.test.ts @@ -38,7 +38,7 @@ const mockLogger = logger as jest.Mocked // Mock AuthenticatorAssertionResponse class MockAuthenticatorAssertionResponse { - // eslint-disable-next-line max-params + // oxlint-disable-next-line max-params constructor( public userHandle: ArrayBuffer | null, public authenticatorData: ArrayBuffer = new ArrayBuffer(0), diff --git a/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts index 11361bdf03f..fd0ff4fb6d4 100644 --- a/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts +++ b/apps/extension/src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation.ts @@ -1,9 +1,9 @@ import { UseMutationResult, useMutation } from '@tanstack/react-query' -import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' import { authenticateWithBiometricCredential, encryptPasswordWithBiometricData, } from 'src/app/features/biometricUnlock/biometricAuthUtils' +import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal' import { logger } from 'utilities/src/logger/logger' import { useEvent } from 'utilities/src/react/hooks' diff --git a/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts b/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts index 5685ba37f1a..2f1702231fc 100644 --- a/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts +++ b/apps/extension/src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation.test.ts @@ -28,7 +28,7 @@ const mockBiometricUnlockStorage = BiometricUnlockStorage as jest.Mocked ({ + ...jest.requireActual('wallet/src/features/wallet/hooks'), + useActiveAccountAddress: jest.fn(), +})) + const SAMPLE_DAPP = 'http://example.com' const SAMPLE_DAPP_2 = 'http://uniswap.org' @@ -96,4 +103,41 @@ describe('Dapp hooks', () => { const { result } = renderHook(() => useDappConnectedAccounts(SAMPLE_DAPP)) await waitFor(() => expect(result.current).toEqual([ACCOUNT, ACCOUNT2])) }) + + describe('useAllDappConnectionsForAccount', () => { + it('should return connections for a specific address when provided', async () => { + // ACCOUNT2 (SAMPLE_SEED_ADDRESS_2) is only connected to SAMPLE_DAPP + const { result } = renderHook(() => useAllDappConnectionsForAccount(ACCOUNT2.address)) + await waitFor(() => expect(result.current).toEqual([SAMPLE_DAPP])) + }) + + it('should return connections for address connected to multiple dapps', async () => { + // ACCOUNT (SAMPLE_SEED_ADDRESS_1) is connected to both dapps + const { result } = renderHook(() => useAllDappConnectionsForAccount(ACCOUNT.address)) + await waitFor(() => expect(result.current).toEqual(expect.arrayContaining([SAMPLE_DAPP, SAMPLE_DAPP_2]))) + await waitFor(() => expect(result.current).toHaveLength(2)) + }) + + it('should return empty array when address has no connections', async () => { + const unconnectedAddress = '0x0000000000000000000000000000000000000000' + const { result } = renderHook(() => useAllDappConnectionsForAccount(unconnectedAddress)) + await waitFor(() => expect(result.current).toEqual([])) + }) + + it('should use active account when no address is provided', async () => { + // Mock useActiveAccountAddress to return ACCOUNT3's address + jest.mocked(useActiveAccountAddress).mockReturnValue(ACCOUNT3.address) + + // ACCOUNT3 (SAMPLE_SEED_ADDRESS_3) is only connected to SAMPLE_DAPP_2 + const { result } = renderHook(() => useAllDappConnectionsForAccount()) + await waitFor(() => expect(result.current).toEqual([SAMPLE_DAPP_2])) + }) + + it('should return empty array when no address provided and no active account', async () => { + jest.mocked(useActiveAccountAddress).mockReturnValue(null) + + const { result } = renderHook(() => useAllDappConnectionsForAccount()) + await waitFor(() => expect(result.current).toEqual([])) + }) + }) }) diff --git a/apps/extension/src/app/features/dapp/hooks.ts b/apps/extension/src/app/features/dapp/hooks.ts index fa65e9aee22..54a3b358dce 100644 --- a/apps/extension/src/app/features/dapp/hooks.ts +++ b/apps/extension/src/app/features/dapp/hooks.ts @@ -20,7 +20,7 @@ export function useDappStateUpdated(): boolean { export function useDappInfo(dappUrl: string | undefined): DappInfo | undefined { const [info, setInfo] = useState() const dappStateUpdated = useDappStateUpdated() - // biome-ignore lint/correctness/useExhaustiveDependencies: dappStateUpdated is used to trigger re-render when dapp store changes + // oxlint-disable-next-line react/exhaustive-deps -- dappStateUpdated is used to trigger re-render when dapp store changes useEffect(() => { setInfo(dappStore.getDappInfo(dappUrl)) }, [dappUrl, dappStateUpdated]) @@ -36,19 +36,22 @@ export function useDappConnectedAccounts(dappUrl: string | undefined): Account[] } /** - * Pairs well with `getDappInfo`, which returns the dapp info for a given dapp URL. + * Hook to retrieve all dapp connection URLs for a specific account. * - * @returns all dapp connection URLs (ie state keys) for the active account + * @param address - Optional account address. If not provided, uses the active account. + * @returns all dapp connection URLs (ie state keys) for the specified account */ -export function useAllDappConnectionsForActiveAccount(): string[] { +export function useAllDappConnectionsForAccount(address?: Address): string[] { const [dappUrls, setDappUrls] = useState([]) const dappStateUpdated = useDappStateUpdated() const activeAccount = useActiveAccountAddress() - // biome-ignore lint/correctness/useExhaustiveDependencies: dappStateUpdated is used to trigger re-render when dapp store changes + const accountAddress = address ?? activeAccount + + // oxlint-disable-next-line react/exhaustive-deps -- dappStateUpdated is used to trigger re-render when dapp store changes useEffect(() => { - setDappUrls(activeAccount ? dappStore.getConnectedDapps(activeAccount) : []) - }, [activeAccount, dappStateUpdated]) + setDappUrls(accountAddress ? dappStore.getConnectedDapps(accountAddress) : []) + }, [accountAddress, dappStateUpdated]) return dappUrls } diff --git a/apps/extension/src/app/features/dapp/store.ts b/apps/extension/src/app/features/dapp/store.ts index ed6dabeccb2..ea857febc5b 100644 --- a/apps/extension/src/app/features/dapp/store.ts +++ b/apps/extension/src/app/features/dapp/store.ts @@ -41,12 +41,12 @@ async function init(): Promise { } async function initInternal(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition 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 + if (changes['dappState']) { + state = changes['dappState'].newValue dappStoreEventEmitter.emit(DappStoreEvent.DappStateUpdated, state) } }) diff --git a/apps/extension/src/app/features/dappRequests/DappRequestContent.test.tsx b/apps/extension/src/app/features/dappRequests/DappRequestContent.test.tsx new file mode 100644 index 00000000000..7a805558bf7 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestContent.test.tsx @@ -0,0 +1,225 @@ +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { REQUEST_EXPIRY_TIME_MS } from 'src/app/features/dappRequests/hooks/useIsRequestStale' +import type { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { DappRequestStatus } from 'src/app/features/dappRequests/shared' +import type { WithMetadata } from 'src/app/features/dappRequests/slice' +import { render, screen } from 'src/test/test-utils' +import { AccountType } from 'uniswap/src/features/accounts/types' +import { DappRequestType } from 'uniswap/src/features/dappRequests/types' + +// Mock wagmi to avoid ESM import issues +jest.mock('wagmi', () => ({ + useAccountEffect: jest.fn(), +})) + +// Mock the useIsRequestStale hook to control output +const mockUseIsRequestStale = jest.fn() +jest.mock('src/app/features/dappRequests/hooks/useIsRequestStale', () => ({ + ...jest.requireActual('src/app/features/dappRequests/hooks/useIsRequestStale'), + useIsRequestStale: (createdAt: number) => mockUseIsRequestStale(createdAt), +})) + +// Mock the context hook to return our mock value +let mockContextValue: any = null +jest.mock('src/app/features/dappRequests/DappRequestQueueContext', () => ({ + useDappRequestQueueContext: () => mockContextValue, +})) + +// Mock hooks used by DappRequestFooter +jest.mock('src/app/features/dapp/hooks', () => ({ + useDappLastChainId: jest.fn(() => 1), +})) + +jest.mock('uniswap/src/features/gas/hooks/useChainGasToken', () => ({ + useChainGasToken: jest.fn(() => ({ + gasToken: { symbol: 'ETH' }, + gasBalance: { value: '1000000000000000000', currency: { symbol: 'ETH' }, equalTo: () => false }, + isLoading: false, + })), +})) + +jest.mock('uniswap/src/features/gas/utils', () => ({ + ...jest.requireActual('uniswap/src/features/gas/utils'), + hasSufficientGasBalance: jest.fn(() => true), + hasGasEstimationFailed: jest.fn(() => false), +})) + +jest.mock('wallet/src/features/wallet/hooks', () => ({ + useActiveAccountWithThrow: jest.fn(() => ({ + address: '0x123', + type: 'readonly', + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + })), +})) + +jest.mock('uniswap/src/features/chains/hooks/useEnabledChains', () => ({ + useEnabledChains: jest.fn(() => ({ + defaultChainId: 1, + })), +})) + +jest.mock('src/app/features/dappRequests/hooks', () => ({ + useIsDappRequestConfirming: jest.fn(() => false), +})) + +// Mock the NetworkFeeFooter to avoid complex currency parsing +jest.mock('wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter', () => ({ + NetworkFeeFooter: () => null, +})) + +jest.mock('wallet/src/features/transactions/TransactionRequest/AddressFooter', () => ({ + AddressFooter: () => null, +})) + +// Mock currency hooks that parse transaction data +jest.mock('uniswap/src/data/apiClients/tradingApi/useTradingApiSwapQuery', () => ({ + useTradingApiSwapQuery: jest.fn(() => ({ + data: undefined, + isLoading: false, + })), +})) + +function setupMockRequestAndContext(createdAt: number, options?: { frameUrl?: string }): void { + const request: WithMetadata = { + dappRequest: { + type: DappRequestType.SendTransaction, + requestId: 'test-request-id', + transaction: { + from: '0x123', + to: '0x456', + value: '0', + chainId: 1, + }, + }, + senderTabInfo: { + id: 1, + url: 'https://example.com', + frameUrl: options?.frameUrl, + }, + dappInfo: { + activeConnectedAddress: '0x123', + lastChainId: 1, + connectedAccounts: [ + { + address: '0x123', + type: AccountType.Readonly, + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + }, + ], + }, + createdAt, + status: DappRequestStatus.Pending, + isSidebarClosed: false, + } + + mockContextValue = { + forwards: true, + increasing: true, + request, + currentAccount: { + address: '0x123', + type: AccountType.Readonly, + timeImportedMs: Date.now(), + pushNotificationsEnabled: false, + }, + dappUrl: 'https://example.com', + frameUrl: options?.frameUrl, + dappIconUrl: '', + currentIndex: 0, + totalRequestCount: 1, + onPressNext: jest.fn(), + onPressPrevious: jest.fn(), + onConfirm: jest.fn(), + onCancel: jest.fn(), + } +} + +function renderDappRequestContent(options: { createdAt: number; isRequestStale: boolean; frameUrl?: string }) { + mockUseIsRequestStale.mockReturnValue(options.isRequestStale) + setupMockRequestAndContext(options.createdAt, { frameUrl: options.frameUrl }) + return render() +} + +describe('DappRequestContent - Stale Request Rendering', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-01T12:00:00.000Z')) + mockUseIsRequestStale.mockClear() + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + + it('should render Cancel and Confirm buttons for fresh requests', async () => { + const freshCreatedAt = Date.now() - 1000 + + renderDappRequestContent({ createdAt: freshCreatedAt, isRequestStale: false }) + + // Verify hook was called + expect(mockUseIsRequestStale).toHaveBeenCalledWith(freshCreatedAt) + + // Verify normal buttons are shown + await screen.findByText('Cancel') + await screen.findByText('Confirm') + // Verify close button is NOT shown + expect(screen.queryByText('Close')).toBeNull() + }) + + it('should render warning and Close button for stale requests', async () => { + const staleCreatedAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) + + renderDappRequestContent({ createdAt: staleCreatedAt, isRequestStale: true }) + + // Verify hook was called + expect(mockUseIsRequestStale).toHaveBeenCalledWith(staleCreatedAt) + // Verify Close button is shown + await screen.findByText('Close') + // Verify Confirm button is NOT shown + expect(screen.queryByText('Confirm')).toBeNull() + }) + + it('should match snapshot for fresh request', async () => { + const freshCreatedAt = Date.now() - 1000 + + const { container } = renderDappRequestContent({ createdAt: freshCreatedAt, isRequestStale: false }) + + expect(container).toMatchSnapshot() + }) + + it('should match snapshot for stale request', async () => { + const staleCreatedAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) + + const { container } = renderDappRequestContent({ createdAt: staleCreatedAt, isRequestStale: true }) + + expect(container).toMatchSnapshot() + }) + + it('should display iframe URL with "via" when frameUrl differs from url', async () => { + const freshCreatedAt = Date.now() - 1000 + + renderDappRequestContent({ + createdAt: freshCreatedAt, + isRequestStale: false, + frameUrl: 'https://app.uniswap.org', + }) + + // Should show "app.uniswap.org via example.com" in the URL label + expect(screen.queryByText(/app\.uniswap\.org via example\.com/i)).not.toBeNull() + }) + + it('should display only top-level URL when frameUrl is not present', async () => { + const freshCreatedAt = Date.now() - 1000 + + renderDappRequestContent({ createdAt: freshCreatedAt, isRequestStale: false }) + + // Should show just "example.com" (no "via") + expect(screen.queryByText(/example\.com/i)).not.toBeNull() + + // Should NOT show "via" + expect(screen.queryByText(/via/i)).toBeNull() + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx index 06a1106bf53..d63f78b8340 100644 --- a/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx @@ -1,21 +1,22 @@ -import { PropsWithChildren } from 'react' +import { type GasFeeResult } from '@universe/api' +import { type PropsWithChildren } from 'react' import { useTranslation } from 'react-i18next' -import { Animated } from 'react-native' +import { type Animated } from 'react-native' import { useDispatch } from 'react-redux' import { useDappLastChainId } from 'src/app/features/dapp/hooks' import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' import { handleExternallySubmittedUniswapXOrder } from 'src/app/features/dappRequests/handleUniswapX' import { useIsDappRequestConfirming } from 'src/app/features/dappRequests/hooks' -import { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' -import { DappRequest, isBatchedSwapRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { AnimatePresence, Button, Flex, GetThemeValueForKey, styled, Text } from 'ui/src' +import { useIsRequestStale } from 'src/app/features/dappRequests/hooks/useIsRequestStale' +import { type DappRequestStoreItem } from 'src/app/features/dappRequests/shared' +import { type DappRequest, isBatchedSwapRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { AnimatePresence, Button, Flex, type GetThemeValueForKey, styled, Text } from 'ui/src' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' import { DappRequestType } from 'uniswap/src/features/dappRequests/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' -import { hasSufficientFundsIncludingGas } from 'uniswap/src/features/gas/utils' -import { useOnChainNativeCurrencyBalance } from 'uniswap/src/features/portfolio/api' -import { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { useChainGasToken } from 'uniswap/src/features/gas/hooks/useChainGasToken' +import { hasGasEstimationFailed, hasSufficientGasBalance } from 'uniswap/src/features/gas/utils' +import { type TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' import { logger } from 'utilities/src/logger/logger' import { useEvent } from 'utilities/src/react/hooks' @@ -23,7 +24,7 @@ import { useThrottledCallback } from 'utilities/src/react/useThrottledCallback' import { MAX_HIDDEN_CALLS_BY_DEFAULT } from 'wallet/src/components/BatchedTransactions/BatchedTransactionDetails' import { DappRequestHeader } from 'wallet/src/components/dappRequests/DappRequestHeader' import { WarningBox } from 'wallet/src/components/WarningBox/WarningBox' -import { DappVerificationStatus } from 'wallet/src/features/dappRequests/types' +import { type DappVerificationStatus } from 'wallet/src/features/dappRequests/types' import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' import { NetworkFeeFooter } from 'wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' @@ -96,7 +97,7 @@ export function DappRequestContent({ showAddressFooter = true, contentHorizontalPadding = '$spacing12', }: PropsWithChildren): JSX.Element { - const { forwards, currentIndex, dappIconUrl, dappUrl } = useDappRequestQueueContext() + const { forwards, currentIndex, dappIconUrl, dappUrl, frameUrl } = useDappRequestQueueContext() const hostname = extractNameFromUrl(dappUrl).toUpperCase() return ( @@ -107,6 +108,7 @@ export function DappRequestContent({ name: hostname, url: dappUrl, icon: dappIconUrl, + frameUrl, }} title={title} verificationStatus={verificationStatus} @@ -138,6 +140,7 @@ export function DappRequestContent({ const WINDOW_CLOSE_DELAY = 10 +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here function DappRequestFooter({ chainId, connectedAccountAddress, @@ -176,21 +179,28 @@ function DappRequestFooter({ const sendTransactionChainId = request.dappRequest.type === DappRequestType.SendTransaction ? request.dappRequest.transaction.chainId : undefined const currentChainId = chainId || sendTransactionChainId || activeChain || defaultChainId - const { balance: nativeBalance } = useOnChainNativeCurrencyBalance(currentChainId, currentAccount.address) + const { gasBalance } = useChainGasToken({ chainId: currentChainId, accountAddress: currentAccount.address }) const isRequestConfirming = useIsDappRequestConfirming(request.dappRequest.requestId) + const isRequestStale = useIsRequestStale(request.createdAt) - const hasSufficientGas = hasSufficientFundsIncludingGas({ + const hasSufficientGas = hasSufficientGasBalance({ + chainId: currentChainId, + gasBalance, gasFee: transactionGasFeeResult?.value, - nativeCurrencyBalance: nativeBalance, }) const shouldCloseSidebar = request.isSidebarClosed && totalRequestCount <= 1 - // Disable submission if no gas fee value - const isConfirmEnabled = - request.dappRequest.type === DappRequestType.SendTransaction - ? transactionGasFeeResult?.value && hasSufficientGas - : true + // Check if this is a transaction request that needs gas estimation + const isTransactionRequest = + request.dappRequest.type === DappRequestType.SendTransaction || + request.dappRequest.type === DappRequestType.SendCalls + + // Check if gas estimation failed (has error or no value after loading) + const gasEstimationFailed = hasGasEstimationFailed(isTransactionRequest, transactionGasFeeResult) + + // Disable submission when gas estimation fails or user has insufficient funds + const isConfirmEnabled = !isTransactionRequest || (!gasEstimationFailed && hasSufficientGas) const handleOnConfirm = useEvent(async () => { if (isRequestConfirming) { @@ -232,11 +242,18 @@ function DappRequestFooter({ return ( <> - {!hasSufficientGas && ( + {gasEstimationFailed && ( + + + {t('dapp.request.error.gasEstimation')} + + + )} + {!hasSufficientGas && !gasEstimationFailed && ( {t('swap.warning.insufficientGas.title', { - currencySymbol: nativeBalance?.currency.symbol ?? '', + currencySymbol: gasBalance?.currency.symbol ?? '', })} @@ -258,12 +275,12 @@ function DappRequestFooter({ px="$spacing8" /> )} - + - {confirmText && ( + {confirmText && !isRequestStale && ( + +
+
+
+ +
+`; + +exports[`DappRequestContent - Stale Request Rendering should match snapshot for stale request 1`] = ` +
+ +
+
+
+
+ + E + +
+
+ + Transaction request + +
+
+
+
+ + example.com + +
+
+
+
+
+
+
+
+
+
+ + + +
+ + This request has expired due to inactivity. Please try submitting again + +
+
+ +
+
+
+ +
+`; diff --git a/apps/extension/src/app/features/dappRequests/accounts.ts b/apps/extension/src/app/features/dappRequests/accounts.ts index 4f92b56cd29..245571460f7 100644 --- a/apps/extension/src/app/features/dappRequests/accounts.ts +++ b/apps/extension/src/app/features/dappRequests/accounts.ts @@ -1,20 +1,20 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { JsonRpcProvider } from '@ethersproject/providers' +/* oxlint-disable typescript/explicit-function-return-type */ +import { type 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 { type DappInfo, dappStore } from 'src/app/features/dapp/store' import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils' import type { SenderTabInfo } from 'src/app/features/dappRequests/shared' import { - AccountResponse, - DappRequest, - ErrorResponse, - GetAccountRequest, - RequestAccountRequest, + type AccountResponse, + type DappRequest, + type ErrorResponse, + type GetAccountRequest, + type RequestAccountRequest, } from 'src/app/features/dappRequests/types/DappRequestTypes' import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' import { call, put, select } from 'typed-redux-saga' -import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' import { DappResponseType } from 'uniswap/src/features/dappRequests/types' import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' @@ -161,7 +161,8 @@ export function* getAccountRequest(request: RequestAccountRequest, senderTabInfo yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, accountResponse) - sendAnalyticsEvent(ExtensionEventName.DappConnectRequest, { + // Track that a connection was established + sendAnalyticsEvent(ExtensionEventName.DappConnect, { dappUrl, chainId, activeConnectedAddress: activeAccount.address, diff --git a/apps/extension/src/app/features/dappRequests/configuredSagas.ts b/apps/extension/src/app/features/dappRequests/configuredSagas.ts index e589028b023..397a92d15e1 100644 --- a/apps/extension/src/app/features/dappRequests/configuredSagas.ts +++ b/apps/extension/src/app/features/dappRequests/configuredSagas.ts @@ -1,6 +1,6 @@ import { createPrepareAndSignDappTransactionSaga } from 'src/app/features/dappRequests/sagas/prepareAndSignDappTransactionSaga' +import { createMonitoredSaga } from 'uniswap/src/utils/saga' import { getSharedTransactionSagaDependencies } from 'wallet/src/features/transactions/configuredSagas' -import { createMonitoredSaga } from 'wallet/src/utils/saga' // Create configured saga instance using shared transaction dependencies const configuredPrepareAndSignDappTransactionSaga = createPrepareAndSignDappTransactionSaga( diff --git a/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx b/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx index 59de6da8ec3..e0df9efaf77 100644 --- a/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx +++ b/apps/extension/src/app/features/dappRequests/context/TransactionConfirmationTracker.tsx @@ -31,6 +31,7 @@ export function useTransactionConfirmationTracker(): TransactionConfirmationStat return context } +// oxlint-disable-next-line typescript/no-empty-interface -- biome-parity: oxlint is stricter here interface TransactionConfirmationTrackerProviderProps extends PropsWithChildren {} export function TransactionConfirmationTrackerProvider({ diff --git a/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts index e98260ee664..8e8332fe498 100644 --- a/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts +++ b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts @@ -1,4 +1,4 @@ -/* eslint-disable complexity */ +/* oxlint-disable complexity */ import { providerErrors, serializeError } from '@metamask/rpc-errors' import { PayloadAction } from '@reduxjs/toolkit' import { getAccount, getAccountRequest } from 'src/app/features/dappRequests/accounts' diff --git a/apps/extension/src/app/features/dappRequests/getChainId.ts b/apps/extension/src/app/features/dappRequests/getChainId.ts index 6f84f7d3f7b..94678872cb9 100644 --- a/apps/extension/src/app/features/dappRequests/getChainId.ts +++ b/apps/extension/src/app/features/dappRequests/getChainId.ts @@ -7,7 +7,7 @@ import { UniverseChainId } from 'uniswap/src/features/chains/types' import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' import { DappResponseType } from 'uniswap/src/features/dappRequests/types' -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +// oxlint-disable-next-line typescript/explicit-function-return-type export function* getChainId({ request, senderTabInfo: { id }, @@ -26,7 +26,7 @@ export function* getChainId({ yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) } -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +// oxlint-disable-next-line typescript/explicit-function-return-type export function* getChainIdNoDappInfo({ request, senderTabInfo: { id }, diff --git a/apps/extension/src/app/features/dappRequests/hooks.test.tsx b/apps/extension/src/app/features/dappRequests/hooks.test.tsx index b5aaf159c6e..eb1b2ceb43d 100644 --- a/apps/extension/src/app/features/dappRequests/hooks.test.tsx +++ b/apps/extension/src/app/features/dappRequests/hooks.test.tsx @@ -10,7 +10,7 @@ describe('useIsDappRequestConfirming', () => { ['returns false when request is not confirming', MOCK_ID, DappRequestStatus.Pending, false], ['returns true when request is confirming', MOCK_ID, DappRequestStatus.Confirming, true], ['returns false when request does not exist', 'non-existent-id', DappRequestStatus.Confirming, false], - // eslint-disable-next-line max-params + // oxlint-disable-next-line max-params ])('%s', async (_, requestId, status, expected) => { const preloadedState = { dappRequests: { diff --git a/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts b/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts index 59883fec0cc..95a15d3d20e 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/useConditionalPreSignDelay.ts @@ -66,6 +66,7 @@ export function useConditionalPreSignDelay(options: { // Set up execution with conditional delay timeoutRef.current = setTimeout(() => { + // oxlint-disable-next-line typescript/no-floating-promises -- biome-parity: oxlint is stricter here executeCallback() }, delayMs) diff --git a/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.test.ts b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.test.ts new file mode 100644 index 00000000000..655cb70b2a1 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.test.ts @@ -0,0 +1,109 @@ +import { act, renderHook } from '@testing-library/react' +import { + isRequestStale, + REQUEST_EXPIRY_TIME_MS, + useIsRequestStale, +} from 'src/app/features/dappRequests/hooks/useIsRequestStale' + +describe('isRequestStale', () => { + it('returns false for requests created less than 30 minutes ago', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS - 60000) // 1 minute before expiry + expect(isRequestStale(createdAt)).toBe(false) + }) + + it('returns true for requests created exactly 30 minutes ago', () => { + const createdAt = Date.now() - REQUEST_EXPIRY_TIME_MS // Exactly at expiry time + expect(isRequestStale(createdAt)).toBe(true) + }) + + it('returns true for requests created more than 30 minutes ago', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) // 1 minute past expiry + expect(isRequestStale(createdAt)).toBe(true) + }) + + it('handles edge case where createdAt is in the future', () => { + const createdAt = Date.now() + 60 * 1000 // 1 minute in the future + expect(isRequestStale(createdAt)).toBe(false) + }) +}) + +describe('useIsRequestStale', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + }) + + it('returns false initially for fresh request', () => { + const createdAt = Date.now() - 1000 // 1 second ago + const { result } = renderHook(() => useIsRequestStale(createdAt)) + expect(result.current).toBe(false) + }) + + it('returns true initially for stale request', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) // 1 minute past expiry + const { result } = renderHook(() => useIsRequestStale(createdAt)) + expect(result.current).toBe(true) + }) + + it('updates from false to true when request becomes stale', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS - 60000) // 1 minute before expiry + const { result } = renderHook(() => useIsRequestStale(createdAt)) + + expect(result.current).toBe(false) + + // Fast-forward past expiry time + act(() => { + jest.advanceTimersByTime(120000) // 2 minutes + }) + + expect(result.current).toBe(true) + }) + + it('recalculates when createdAt changes', () => { + const initialCreatedAt = Date.now() - 1000 // 1 second ago + const { result, rerender } = renderHook(({ timestamp }) => useIsRequestStale(timestamp), { + initialProps: { timestamp: initialCreatedAt }, + }) + + expect(result.current).toBe(false) + + // Change createdAt to a stale timestamp + const staleCreatedAt = Date.now() - (REQUEST_EXPIRY_TIME_MS + 60000) // 1 minute past expiry + act(() => { + rerender({ timestamp: staleCreatedAt }) + }) + + // Need to advance timer to trigger the interval check + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current).toBe(true) + }) + + it('checks staleness every second', () => { + const createdAt = Date.now() - (REQUEST_EXPIRY_TIME_MS - 30000) // 30 seconds before expiry + const { result } = renderHook(() => useIsRequestStale(createdAt)) + + expect(result.current).toBe(false) + + // Fast-forward by 30 seconds to reach expiry time + act(() => { + jest.advanceTimersByTime(30000) + }) + + // Should now be stale + expect(result.current).toBe(true) + + // Verify it stays stale after more time passes + act(() => { + jest.advanceTimersByTime(10000) + }) + + expect(result.current).toBe(true) + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.ts b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.ts new file mode 100644 index 00000000000..88e6839047a --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks/useIsRequestStale.ts @@ -0,0 +1,33 @@ +import ms from 'ms' +import { useEffect, useState } from 'react' +import { useInterval } from 'utilities/src/time/timing' + +export const REQUEST_EXPIRY_TIME_MS = ms('30m') + +export function isRequestStale(createdAt: number): boolean { + return Date.now() - createdAt >= REQUEST_EXPIRY_TIME_MS +} + +/** + * Hook that monitors whether a request has become stale (older than REQUEST_EXPIRY_TIME_MS). + * + * @param createdAt - Timestamp when the request was created (in milliseconds) + * @returns boolean indicating whether the request is stale + */ +export function useIsRequestStale(createdAt: number): boolean { + const [isStale, setIsStale] = useState(() => isRequestStale(createdAt)) + + useEffect(() => { + setIsStale(isRequestStale(createdAt)) + }, [createdAt]) + + useInterval( + () => { + setIsStale(isRequestStale(createdAt)) + }, + 1000, + true, + ) + + return isStale +} diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts index d361f37eff3..3d031de172e 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction.ts @@ -34,7 +34,7 @@ export function usePrepareAndSignDappTransaction({ const currentPreparationRef = useRef<{ cancel: () => void } | null>(null) // Cancel ongoing preparations when dependencies change - // biome-ignore lint/correctness/useExhaustiveDependencies: chainId and request changes should reset preparation state + // oxlint-disable-next-line react/exhaustive-deps -- chainId and request changes should reset preparation state useEffect(() => { currentPreparationRef.current?.cancel() currentPreparationRef.current = null diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts index 437cfb0880c..78f899f5642 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignEthSendTransaction.ts @@ -1,9 +1,9 @@ +import { GasFeeResult } from '@universe/api' import { useMemo } from 'react' import { usePrepareAndSignDappTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction' import { useTransactionGasEstimation } from 'src/app/features/dappRequests/hooks/useTransactionGasEstimation' import { DappRequestStoreItemForEthSendTxn } from 'src/app/features/dappRequests/slice' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { formatExternalTxnWithGasEstimates } from 'wallet/src/features/gas/formatExternalTxnWithGasEstimates' import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' import { Account } from 'wallet/src/features/wallet/accounts/types' diff --git a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts index 0ff33a6c29e..000f77c6379 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction.ts @@ -1,3 +1,4 @@ +import { GasFeeResult } from '@universe/api' import { useMemo } from 'react' import { usePrepareAndSignDappTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignDappTransaction' import { useTransactionGasEstimation } from 'src/app/features/dappRequests/hooks/useTransactionGasEstimation' @@ -5,7 +6,6 @@ import { DappRequestStoreItemForSendCallsTxn } from 'src/app/features/dappReques import { UNISWAP_DELEGATION_ADDRESS } from 'uniswap/src/constants/addresses' import { useWalletEncode7702Query } from 'uniswap/src/data/apiClients/tradingApi/useWalletEncode7702Query' import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { EthTransaction } from 'uniswap/src/types/walletConnect' import { transformCallsToTransactionRequests } from 'wallet/src/features/batchedTransactions/utils' import { useLiveAccountDelegationDetails } from 'wallet/src/features/smartWallet/hooks/useLiveAccountDelegationDetails' diff --git a/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts index 808019e1e4c..0e687291d56 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.test.ts @@ -1,10 +1,10 @@ import { TransactionRequest } from '@ethersproject/providers' import { renderHook } from '@testing-library/react' +import { GasFeeResult } from '@universe/api' import { useTransactionGasEstimation } from 'src/app/features/dappRequests/hooks/useTransactionGasEstimation' import { PollingInterval } from 'uniswap/src/constants/misc' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { useTransactionGasFee } from 'uniswap/src/features/gas/hooks' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { logger } from 'utilities/src/logger/logger' // Mock dependencies diff --git a/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts index 7a08299a627..7e9ddae731b 100644 --- a/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts +++ b/apps/extension/src/app/features/dappRequests/hooks/useTransactionGasEstimation.ts @@ -1,9 +1,9 @@ import { TransactionRequest } from '@ethersproject/providers' +import { GasFeeResult } from '@universe/api' import { useEffect, useMemo } from 'react' import { PollingInterval } from 'uniswap/src/constants/misc' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { useTransactionGasFee } from 'uniswap/src/features/gas/hooks' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { logger } from 'utilities/src/logger/logger' interface UseTransactionGasEstimationParams { diff --git a/apps/extension/src/app/features/dappRequests/permissions.ts b/apps/extension/src/app/features/dappRequests/permissions.ts index 37f5d005c23..2ce93f16d5b 100644 --- a/apps/extension/src/app/features/dappRequests/permissions.ts +++ b/apps/extension/src/app/features/dappRequests/permissions.ts @@ -1,25 +1,27 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* oxlint-disable typescript/explicit-function-return-type */ import { rpcErrors, serializeError } from '@metamask/rpc-errors' import { removeDappConnection } from 'src/app/features/dapp/actions' -import { DappInfo } from 'src/app/features/dapp/store' +import { type DappInfo } from 'src/app/features/dapp/store' import { saveAccount } from 'src/app/features/dappRequests/accounts' import type { SenderTabInfo } from 'src/app/features/dappRequests/shared' import { - ErrorResponse, - GetPermissionsRequest, - GetPermissionsResponse, - RequestPermissionsRequest, - RequestPermissionsResponse, - RevokePermissionsRequest, - RevokePermissionsResponse, + type ErrorResponse, + type GetPermissionsRequest, + type GetPermissionsResponse, + type RequestPermissionsRequest, + type RequestPermissionsResponse, + type RevokePermissionsRequest, + type RevokePermissionsResponse, } from 'src/app/features/dappRequests/types/DappRequestTypes' import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' -import { Permission } from 'src/contentScript/WindowEthereumRequestTypes' +import { type Permission } from 'src/contentScript/WindowEthereumRequestTypes' import { call, put } from 'typed-redux-saga' import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' import { DappResponseType, EthMethod } from 'uniswap/src/features/dappRequests/types' import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { extractBaseUrl } from 'utilities/src/format/urls' import { logger } from 'utilities/src/logger/logger' @@ -91,6 +93,14 @@ export function* handleRequestPermissions(request: RequestPermissionsRequest, se accounts, } yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response) + + // Track that a connection was established + sendAnalyticsEvent(ExtensionEventName.DappConnect, { + dappUrl: accountInfo?.dappUrl ?? extractBaseUrl(senderTabInfo.url), + chainId: accountInfo?.chainId, + activeConnectedAddress: accountInfo?.activeAccount.address, + connectedAddresses: accountInfo?.connectedAddresses ?? [], + }) } else { logger.info('saga.ts', 'handleRequestPermissions', 'Unknown permissions requested', requestedPermissions) yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { 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 index 24c251dcb02..b4fd0dfccaf 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx @@ -1,4 +1,5 @@ import { BigNumber } from '@ethersproject/bignumber' +import { GasFeeResult } from '@universe/api' import { useTranslation } from 'react-i18next' import { useDappLastChainId } from 'src/app/features/dapp/hooks' import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' @@ -14,7 +15,6 @@ import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' import { uniswapUrls } from 'uniswap/src/constants/urls' import { DappRequestType } from 'uniswap/src/features/dappRequests/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx index 2c36e8b0657..1499b776528 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx @@ -1,4 +1,4 @@ -import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import type { GasFeeResult } from '@universe/api' import { useCallback } from 'react' import { useDappLastChainId } from 'src/app/features/dapp/hooks' import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' @@ -19,7 +19,6 @@ import { isWrapRequest, SendTransactionRequest, } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import { logger } from 'utilities/src/logger/logger' import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' @@ -32,7 +31,6 @@ export function EthSendRequestContent({ request }: EthSendRequestContentProps): const { dappRequest } = request const { dappUrl, currentAccount, onConfirm, onCancel } = useDappRequestQueueContext() const chainId = useDappLastChainId(dappUrl) - const blockaidTransactionScanning = useFeatureFlag(FeatureFlags.BlockaidTransactionScanning) const { gasFeeResult: transactionGasFeeResult, @@ -59,55 +57,12 @@ export function EthSendRequestContent({ request }: EthSendRequestContentProps): await onCancel(requestWithGasValues) }, [onCancel, requestWithGasValues]) - // If Blockaid transaction scanning is enabled, try to use it for ALL transaction types + // Use Blockaid transaction scanning for ALL transaction types // If the API fails, the ErrorBoundary will catch it and fallback to specialized UIs - if (blockaidTransactionScanning) { - return ( - - } - onError={(error) => { - if (error) { - logger.error(error, { - tags: { file: 'EthSend', function: 'ErrorBoundary-Blockaid' }, - extra: { - dappRequest, - useSimulationResultUI: true, - }, - }) - } - }} - > - - - ) - } - - // If feature flag is disabled, use specialized UIs - const content = ( - - ) - return ( { if (error) { logger.error(error, { - tags: { file: 'EthSend', function: 'ErrorBoundary-Specialized' }, + tags: { file: 'EthSend', function: 'ErrorBoundary-Blockaid' }, extra: { dappRequest, - useSimulationResultUI: false, + useSimulationResultUI: true, }, }) } }} > - {content} + ) } 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 index e959e61d694..ef55b047a5d 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx @@ -1,3 +1,4 @@ +import { GasFeeResult } from '@universe/api' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useDappLastChainId } from 'src/app/features/dapp/hooks' @@ -8,7 +9,6 @@ import { SendTransactionRequest } from 'src/app/features/dappRequests/types/Dapp import { Anchor, Flex, Text, TouchableArea } from 'ui/src' import { AnimatedCopySheets, ExternalLink } from 'ui/src/components/icons' import { ContentRow } from 'uniswap/src/components/transactions/requests/ContentRow' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking' import { ellipseMiddle, shortenAddress } from 'utilities/src/addresses' 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 index 483c487f086..af545233f5c 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx @@ -1,8 +1,8 @@ +import { GasFeeResult } from '@universe/api' 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 'uniswap/src/features/gas/types' interface LPRequestContentProps { transactionGasFeeResult: GasFeeResult diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx index ac63e20279a..ca1181604e0 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/ParsedTransaction/ParsedTransactionRequestContent.tsx @@ -1,10 +1,10 @@ +import type { GasFeeResult } from '@universe/api' import { useState } 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 { GasFeeResult } from 'uniswap/src/features/gas/types' import { useBooleanState } from 'utilities/src/react/useBooleanState' import { DappTransactionScanningContent } from 'wallet/src/components/dappRequests/DappTransactionScanningContent' import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx index 8b13fcd9450..18be818039c 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Permit2Approve/Permit2ApproveRequestContent.tsx @@ -1,8 +1,8 @@ +import { GasFeeResult } from '@universe/api' import { useTranslation } from 'react-i18next' import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' import { Permit2ApproveSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' import { Flex, Text } from 'ui/src' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' interface Permit2ApproveRequestContentProps { 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 index 18276972fbe..975e5de6abe 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapDisplay.tsx @@ -1,3 +1,4 @@ +import { GasFeeResult } from '@universe/api' import { useTranslation } from 'react-i18next' import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' import { Flex, Separator, Text } from 'ui/src' @@ -7,12 +8,12 @@ import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { SplitLogo } from 'uniswap/src/components/CurrencyLogo/SplitLogo' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' -import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPrice' +import { useUSDCValue } from 'uniswap/src/features/transactions/hooks/useUSDCPriceWrapper' import { NumberType } from 'utilities/src/format/types' +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here export function SwapDisplay({ inputAmount, outputAmount, 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 index 05578f8627c..5758e25cb32 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx @@ -1,3 +1,4 @@ +import { GasFeeResult } from '@universe/api' 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' @@ -7,7 +8,6 @@ import { DEFAULT_NATIVE_ADDRESS, DEFAULT_NATIVE_ADDRESS_LEGACY } from 'uniswap/s import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { useCurrencyInfo, useNativeCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' 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 index 04fae5445ee..acb0fde985f 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts @@ -1,5 +1,5 @@ -/* eslint-disable max-depth */ -/* eslint-disable complexity */ +/* oxlint-disable max-depth */ +/* oxlint-disable complexity */ import { BigNumber, BigNumberish } from '@ethersproject/bignumber' import { formatUnits as formatUnitsEthers } from 'ethers/lib/utils' import { useDappLastChainId } from 'src/app/features/dapp/hooks' @@ -221,7 +221,7 @@ function getTokenDetailsFromV4SwapCommands(command: UniversalRouterCommand): { } for (const p of parsed.data.value) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (p.name === 'swap') { const swap = p.value @@ -246,7 +246,7 @@ function getTokenDetailsFromV4SwapCommands(command: UniversalRouterCommand): { } for (const p of parsed.data.value) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (p.name === 'swap') { const swap = p.value @@ -271,7 +271,7 @@ function getTokenDetailsFromV4SwapCommands(command: UniversalRouterCommand): { } for (const p of parsed.data.value) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (p.name === 'swap') { const swap = p.value @@ -298,7 +298,7 @@ function getTokenDetailsFromV4SwapCommands(command: UniversalRouterCommand): { } for (const p of parsed.data.value) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition if (p.name === 'swap') { const swap = p.value @@ -337,6 +337,7 @@ function getFallbackOutputValue(allCommands?: UniversalRouterCommand[]): string const sweepAmountOutParam = sweepCommand?.params.find(isAmountMinParam) const unwrapWethAmountOutParam = unwrapWethCommand?.params.find(isAmountMinParam) + // oxlint-disable-next-line typescript/no-unsafe-return -- biome-parity: oxlint is stricter here return sweepAmountOutParam?.value || unwrapWethAmountOutParam?.value || '0' } @@ -345,6 +346,7 @@ function getFallbackInputValue(command: UniversalRouterCommand): string { const potentialSettleParam = command.params.find(isSettleParam) const settleParam = potentialSettleParam && isSettleParam(potentialSettleParam) ? potentialSettleParam : undefined const settleAmountValue = settleParam?.value.find((item) => item.name === 'amount') + // oxlint-disable-next-line typescript/no-unsafe-return -- biome-parity: oxlint is stricter here return settleAmountValue?.value || '0' } diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx index 872825576a8..64730c7b1ab 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Wrap/WrapRequestContent.tsx @@ -1,3 +1,4 @@ +import { GasFeeResult } from '@universe/api' 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' @@ -5,7 +6,6 @@ import { formatUnits } from 'src/app/features/dappRequests/requestContent/EthSen import { WrapSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' import { useNativeCurrencyInfo, useWrappedNativeCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' diff --git a/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx index fe179239aa6..ff09badc7b7 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx @@ -1,14 +1,10 @@ import { toUtf8String } from '@ethersproject/strings' -import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useEffect, useRef, useState } 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 { SignMessageRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { Flex, IconButton, Text, Tooltip } from 'ui/src' -import { AlertTriangleFilled, Code, StickyNoteTextSquare } from 'ui/src/components/icons' -import { zIndexes } from 'ui/src/theme' import { EthMethod } from 'uniswap/src/features/dappRequests/types' import { logger } from 'utilities/src/logger/logger' import { containsNonPrintableChars } from 'utilities/src/primitives/string' @@ -17,16 +13,17 @@ import { DappPersonalSignContent } from 'wallet/src/components/dappRequests/Dapp import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' -enum ViewEncoding { - UTF8 = 0, - HEX = 1, -} interface PersonalSignRequestProps { dappRequest: SignMessageRequest } export function PersonalSignRequestContent({ dappRequest }: PersonalSignRequestProps): JSX.Element | null { - const blockaidTransactionScanning = useFeatureFlag(FeatureFlags.BlockaidTransactionScanning) + const { t } = useTranslation() + const { dappUrl, currentAccount } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) + // Initialize with null to indicate scan hasn't completed yet + const [riskLevel, setRiskLevel] = useState(null) // Decode message to UTF-8 const hexMessage = dappRequest.messageHex @@ -42,31 +39,6 @@ export function PersonalSignRequestContent({ dappRequest }: PersonalSignRequestP } }, [hexMessage]) - if (blockaidTransactionScanning) { - return - } - - return -} - -/** - * Implementation with Blockaid scanning - */ -function PersonalSignRequestContentWithScanning({ - dappRequest, - utf8Message, -}: { - dappRequest: SignMessageRequest - utf8Message: string | undefined -}): JSX.Element { - const { t } = useTranslation() - const { dappUrl, currentAccount } = useDappRequestQueueContext() - const activeChain = useDappLastChainId(dappUrl) - const { value: confirmedRisk, setValue: setConfirmedRisk } = useBooleanState(false) - // Initialize with null to indicate scan hasn't completed yet - const [riskLevel, setRiskLevel] = useState(null) - - const hexMessage = dappRequest.messageHex const isDecoded = Boolean(utf8Message && !containsNonPrintableChars(utf8Message)) const message = (isDecoded ? utf8Message : hexMessage) || hexMessage const hasLoggedError = useRef(false) @@ -74,11 +46,11 @@ function PersonalSignRequestContentWithScanning({ if (!activeChain) { if (!hasLoggedError.current) { logger.error(new Error('No active chain found'), { - tags: { file: 'PersonalSignRequestContent', function: 'PersonalSignRequestContentWithScanning' }, + tags: { file: 'PersonalSignRequestContent', function: 'PersonalSignRequestContent' }, }) hasLoggedError.current = true } - return + return null } const disableConfirm = shouldDisableConfirm({ riskLevel, confirmedRisk }) @@ -105,119 +77,3 @@ function PersonalSignRequestContentWithScanning({ ) } - -/** - * Legacy implementation (existing behavior when feature flag is off) - */ -function PersonalSignRequestContentLegacy({ - dappRequest, - utf8Message, -}: { - dappRequest: SignMessageRequest - utf8Message: string | undefined -}): JSX.Element { - 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 containsUnrenderableCharacters = !utf8Message || containsNonPrintableChars(utf8Message) - - useEffect(() => { - if (!utf8Message) { - setViewEncoding(ViewEncoding.HEX) - } - }, [utf8Message]) - - const [isScrollable, setIsScrollable] = useState(false) - const messageRef = useRef(null) - // biome-ignore lint/correctness/useExhaustiveDependencies: viewEncoding and utf8Message affect rendered content which changes scroll height - 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) - }, [viewEncoding, utf8Message]) - - return ( - - - - {viewEncoding === ViewEncoding.UTF8 ? utf8Message : hexMessage} - - - - - - : } - size="xxsmall" - variant="default" - emphasis="secondary" - onPress={toggleViewEncoding} - /> - - - - - - {viewEncoding === ViewEncoding.UTF8 - ? t('dapp.request.signature.toggleDataView.raw') - : t('dapp.request.signature.toggleDataView.readable')} - - - - {containsUnrenderableCharacters && ( - - - - {t('dapp.request.signature.containsUnrenderableCharacters')} - - - )} - - ) -} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx index db74d62bf53..6fcd5ad86f2 100644 --- a/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx +++ b/apps/extension/src/app/features/dappRequests/requestContent/SendCalls/SendCallsRequestContent.tsx @@ -1,4 +1,4 @@ -import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { type GasFeeResult } from '@universe/api' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDappLastChainId } from 'src/app/features/dapp/hooks' @@ -6,20 +6,19 @@ import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestCon import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' import { usePrepareAndSignSendCallsTransaction } from 'src/app/features/dappRequests/hooks/usePrepareAndSignSendCallsTransaction' import { SwapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent' -import { DappRequestStoreItemForSendCallsTxn } from 'src/app/features/dappRequests/slice' +import { type DappRequestStoreItemForSendCallsTxn } from 'src/app/features/dappRequests/slice' import { EthSendTransactionRPCActions, isBatchedSwapRequest, - ParsedCall, - SendCallsRequest, + type ParsedCall, + type SendCallsRequest, } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { GasFeeResult } from 'uniswap/src/features/gas/types' -import { TransactionType, TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { type UniverseChainId } from 'uniswap/src/features/chains/types' +import { TransactionType, type TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import { useBooleanState } from 'utilities/src/react/useBooleanState' import { BatchedRequestDetailsContent } from 'wallet/src/components/BatchedTransactions/BatchedTransactionDetails' import { DappSendCallsScanningContent } from 'wallet/src/components/dappRequests/DappSendCallsScanningContent' -import { TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' +import { type TransactionRiskLevel } from 'wallet/src/features/dappRequests/types' import { shouldDisableConfirm } from 'wallet/src/features/dappRequests/utils/riskUtils' interface SendCallsRequestContentProps { @@ -81,9 +80,9 @@ function SendCallsRequestContentWithScanning({ } /** - * Legacy implementation (existing behavior when feature flag is off) + * Fallback for when chainId is not available (required for Blockaid scanning) */ -function SendCallsRequestContentLegacy({ +function SendCallsRequestContentFallback({ dappRequest, transactionGasFeeResult, showSmartWalletActivation, @@ -115,7 +114,6 @@ function SendCallsRequestContentLegacy({ export function SendCallsRequestHandler({ request }: { request: DappRequestStoreItemForSendCallsTxn }): JSX.Element { const { dappUrl, currentAccount, onConfirm, onCancel } = useDappRequestQueueContext() const chainId = useDappLastChainId(dappUrl) ?? request.dappInfo?.lastChainId - const blockaidTransactionScanning = useFeatureFlag(FeatureFlags.BlockaidTransactionScanning) const { dappRequest } = request @@ -152,25 +150,33 @@ export function SendCallsRequestHandler({ request }: { request: DappRequestStore await onCancel(request) }, [onCancel, request]) - return blockaidTransactionScanning && chainId ? ( - - ) : parsedSwapCalldata ? ( - - ) : ( - + ) + } + + if (parsedSwapCalldata) { + return ( + + ) + } + + return ( + (null) + + const parsedTypedData = JSON.parse(dappRequest.typedData) + const { chainId: domainChainId } = parsedTypedData.domain || {} + const chainId = toSupportedChainId(domainChainId) + + const hasMismatch = chainId ? getHasMismatch(chainId) : false + if (enablePermitMismatchUx && hasMismatch) { + return + } + + if (!chainId) { + // chainId is required for Blockaid scanning, fall back to basic typed data UI + return + } + + // Extension SignTypedData requests default to v4 method (modern standard) + const method = 'eth_signTypedData_v4' + + // For eth_signTypedData_v4, params are [account, typedData] + const params = [currentAccount.address, dappRequest.typedData] + + const disableConfirm = shouldDisableConfirm({ riskLevel, confirmedRisk }) + + return ( + + + + ) +} + +/** + * Fallback for when chainId is not available (required for Blockaid scanning) + */ +function SignTypedDataRequestContentFallback({ dappRequest }: SignTypedDataRequestProps): JSX.Element | null { const { t } = useTranslation() const enablePermitMismatchUx = useFeatureFlag(FeatureFlags.EnablePermitMismatchUX) const getHasMismatch = useHasAccountMismatchCallback() @@ -54,7 +114,7 @@ function SignTypedDataRequestContentInner({ dappRequest }: SignTypedDataRequestP return } - const { name, version, chainId: domainChainId, verifyingContract, salt } = parsedTypedData.domain || {} + const { chainId: domainChainId } = parsedTypedData.domain || {} const chainId = toSupportedChainId(domainChainId) const hasMismatch = chainId ? getHasMismatch(chainId) : false @@ -66,59 +126,13 @@ function SignTypedDataRequestContentInner({ dappRequest }: SignTypedDataRequestP return } - if (isPermit2(parsedTypedData)) { - return - } - - // todo(EXT-883): remove this when we start rejecting unsupported chain signTypedData requests - const renderMessageContent = ( - message: EIP712Message | EIP712Message[keyof EIP712Message], - i = 1, - ): Maybe => { - if (message === null || message === undefined) { - return ( - - {String(message)} - - ) - } - if (typeof message === 'string' && isEVMAddressWithChecksum(message) && chainId) { - const href = getExplorerLink({ chainId, data: message, type: ExplorerDataType.ADDRESS }) - return - } - if (typeof message === 'string' || typeof message === 'number' || typeof message === 'boolean') { - return ( - - {message.toString()} - - ) - } else if (Array.isArray(message)) { - return ( - - {JSON.stringify(message)} - - ) - } else if (typeof message === 'object') { - return Object.entries(message).map(([key, value], index) => ( - - - {key} - - - {renderMessageContent(value, i + 1)} - - - )) - } - - return undefined - } + const isPermit2Request = isPermit2(parsedTypedData) return ( - - {renderMessageContent(parsedTypedData.message)} + {isPermit2Request ? ( + + ) : ( + + )} ) diff --git a/apps/extension/src/app/features/dappRequests/saga.ts b/apps/extension/src/app/features/dappRequests/saga.ts index 1c2a07a605a..81e9727c38b 100644 --- a/apps/extension/src/app/features/dappRequests/saga.ts +++ b/apps/extension/src/app/features/dappRequests/saga.ts @@ -1,10 +1,10 @@ -/* eslint-disable max-lines */ -import { Provider } from '@ethersproject/providers' +/* oxlint-disable max-lines */ +import { type Provider } from '@ethersproject/providers' import { providerErrors, rpcErrors, serializeError } from '@metamask/rpc-errors' import { FeatureFlags, getFeatureFlag } from '@universe/gating' import { createSearchParams } from 'react-router' import { changeChain } from 'src/app/features/dapp/changeChain' -import { DappInfo, dappStore } from 'src/app/features/dapp/store' +import { type DappInfo, dappStore } from 'src/app/features/dapp/store' import { getActiveSignerConnectedAccount } from 'src/app/features/dapp/utils' import { addRequest, @@ -20,22 +20,22 @@ import type { } from 'src/app/features/dappRequests/shared' import { dappRequestActions, selectIsRequestConfirming } from 'src/app/features/dappRequests/slice' import { - BaseSendTransactionRequest, - ChangeChainRequest, - ErrorResponse, - GetCallsStatusRequest, - GetCallsStatusResponse, - GetCapabilitiesRequest, - ParsedCall, - SendCallsRequest, - SendCallsResponse, - SendTransactionResponse, - SignMessageRequest, - SignMessageResponse, - SignTypedDataRequest, - SignTypedDataResponse, - UniswapOpenSidebarRequest, - UniswapOpenSidebarResponse, + type BaseSendTransactionRequest, + type ChangeChainRequest, + type ErrorResponse, + type GetCallsStatusRequest, + type GetCallsStatusResponse, + type GetCapabilitiesRequest, + type ParsedCall, + type SendCallsRequest, + type SendCallsResponse, + type SendTransactionResponse, + type SignMessageRequest, + type SignMessageResponse, + type SignTypedDataRequest, + type SignTypedDataResponse, + type UniswapOpenSidebarRequest, + type UniswapOpenSidebarResponse, } from 'src/app/features/dappRequests/types/DappRequestTypes' import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' import { isWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' @@ -50,22 +50,24 @@ import { pushNotification } from 'uniswap/src/features/notifications/slice/slice import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' import { Platform } from 'uniswap/src/features/platforms/types/Platform' import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TransactionOriginType, TransactionType, - TransactionTypeInfo, + type TransactionTypeInfo, } from 'uniswap/src/features/transactions/types/transactionDetails' import { extractBaseUrl } from 'utilities/src/format/urls' import { logger } from 'utilities/src/logger/logger' import { getCallsStatusHelper } from 'wallet/src/features/batchedTransactions/eip5792Utils' import { addBatchedTransaction } from 'wallet/src/features/batchedTransactions/slice' import { generateBatchId, getCapabilitiesResponse } from 'wallet/src/features/batchedTransactions/utils' -import { Call } from 'wallet/src/features/dappRequests/types' +import { type Call } from 'wallet/src/features/dappRequests/types' import { - ExecuteTransactionParams, + type ExecuteTransactionParams, executeTransaction, } from 'wallet/src/features/transactions/executeTransaction/executeTransactionSaga' -import { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import { type SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' import { getProvider, getSignerManager } from 'wallet/src/features/wallet/context' import { selectActiveAccount, selectHasSmartWalletConsent } from 'wallet/src/features/wallet/selectors' import { signMessage, signTypedDataMessage } from 'wallet/src/features/wallet/signing/signing' @@ -103,7 +105,7 @@ const ACCOUNT_INFO_TYPES = [DappRequestType.GetChainId, DappRequestType.GetAccou * @param requestParams DappRequest and senderTabInfo (required for sending response) * i think remove all the checks from here and push to later. */ -// eslint-disable-next-line complexity +// oxlint-disable-next-line complexity function* handleRequest(requestParams: DappRequestNoDappInfo) { if ( requestParams.dappRequest.type === DappRequestType.SendCalls || @@ -149,12 +151,13 @@ function* handleRequest(requestParams: DappRequestNoDappInfo) { const dappInfo = yield* call(dappStore.getDappInfo, dappUrl) const isConnectedToDapp = dappInfo && dappInfo.connectedAccounts.length > 0 + const isAccountRequestRequest = ACCOUNT_REQUEST_TYPES.includes(requestParams.dappRequest.type) if (!isConnectedToDapp) { if (requestParams.dappRequest.type === DappRequestType.GetChainId) { // Allows for eth_chainId for unconnected dapps to advance connection steps yield* put(confirmRequestNoDappInfo(requestParams)) - } else if (!ACCOUNT_REQUEST_TYPES.includes(requestParams.dappRequest.type)) { + } else if (!isAccountRequestRequest) { // Otherwise, only allows for accounts requests to be handled until connection is confirmed // TODO(EXT-359): show a warning when the active account is different. const response: DappRequestRejectParams = { @@ -294,16 +297,31 @@ function* handleRequest(requestParams: DappRequestNoDappInfo) { } } + // Track connection requests when they arrive, before approval + const connectRequestAnalyticsProperties = { + dappUrl, + chainId: dappInfo?.lastChainId, + activeConnectedAddress: dappInfo?.activeConnectedAddress, + connectedAddresses: dappInfo?.connectedAccounts.map((account) => account.address) ?? [], + } + if (isAccountRequestRequest) { + sendAnalyticsEvent(ExtensionEventName.DappConnectRequest, connectRequestAnalyticsProperties) + } + const shouldAutoConfirmRequest = dappInfo && isConnectedToDapp && - (ACCOUNT_REQUEST_TYPES.includes(requestParams.dappRequest.type) || + (isAccountRequestRequest || ACCOUNT_INFO_TYPES.includes(requestParams.dappRequest.type) || requestParams.dappRequest.type === DappRequestType.RevokePermissions || requestParams.dappRequest.type === DappRequestType.GetCallsStatus || requestParams.dappRequest.type === DappRequestType.GetCapabilities) if (shouldAutoConfirmRequest) { + if (isAccountRequestRequest) { + // Track that a connection was established, even if it's auto-approved + sendAnalyticsEvent(ExtensionEventName.DappConnect, connectRequestAnalyticsProperties) + } yield* call(handleConfirmRequestWithDappInfo, { ...requestParams, dappInfo }) } else { yield* put( @@ -367,7 +385,7 @@ export function* handleSendTransaction({ ) // do not block on this function call since it should happen in parallel - // eslint-disable-next-line @typescript-eslint/no-floating-promises + // oxlint-disable-next-line typescript/no-floating-promises onTransactionSentToChain(transactionHash, provider) const response: SendTransactionResponse = { diff --git a/apps/extension/src/app/features/dappRequests/shared.ts b/apps/extension/src/app/features/dappRequests/shared.ts index d27785901e9..c6c8249a779 100644 --- a/apps/extension/src/app/features/dappRequests/shared.ts +++ b/apps/extension/src/app/features/dappRequests/shared.ts @@ -1,13 +1,11 @@ import type { DappInfo } from 'src/app/features/dapp/store' import type { DappRequest, ErrorResponse } from 'src/app/features/dappRequests/types/DappRequestTypes' +import type { DappRequestMessageSchema } from 'src/background/messagePassing/types/requests' import type { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' import type { SignedTransactionRequest } from 'wallet/src/features/transactions/executeTransaction/types' +import type { z } from 'zod' -export interface SenderTabInfo { - id: number - url: string - favIconUrl?: string -} +export type SenderTabInfo = z.infer['senderTabInfo'] export enum DappRequestStatus { Pending = 'pending', diff --git a/apps/extension/src/app/features/dappRequests/slice.ts b/apps/extension/src/app/features/dappRequests/slice.ts index f60722bfc87..3b598430d27 100644 --- a/apps/extension/src/app/features/dappRequests/slice.ts +++ b/apps/extension/src/app/features/dappRequests/slice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { confirmRequest, confirmRequestNoDappInfo } from 'src/app/features/dappRequests/actions' import type { DappRequestStoreItem } from 'src/app/features/dappRequests/shared' import { DappRequestStatus } from 'src/app/features/dappRequests/shared' @@ -11,7 +11,7 @@ import { } from 'src/app/features/dappRequests/types/DappRequestTypes' type RequestId = string -type WithMetadata = T & { +export type WithMetadata = T & { createdAt: number status: DappRequestStatus } @@ -26,22 +26,22 @@ const initialDappRequestState: DappRequestState = { } // Enforces that a request object in state is for an eth send txn request -export interface DappRequestStoreItemForEthSendTxn extends DappRequestStoreItem { - dappRequest: WithMetadata +export interface DappRequestStoreItemForEthSendTxn extends WithMetadata { + dappRequest: SendTransactionRequest } export function isDappRequestStoreItemForEthSendTxn( - request: DappRequestStoreItem, + request: WithMetadata, ): request is DappRequestStoreItemForEthSendTxn { return isSendTransactionRequest(request.dappRequest) } -export interface DappRequestStoreItemForSendCallsTxn extends DappRequestStoreItem { +export interface DappRequestStoreItemForSendCallsTxn extends WithMetadata { dappRequest: SendCallsRequest } export function isDappRequestStoreItemForSendCallsTxn( - request: DappRequestStoreItem, + request: WithMetadata, ): request is DappRequestStoreItemForSendCallsTxn { return isSendCallsRequest(request.dappRequest) } @@ -85,6 +85,7 @@ const slice = createSlice({ setMostRecent5792DappUrl: (state, action: PayloadAction) => { state.mostRecent5792DappUrl = action.payload }, + reset: () => initialDappRequestState, }, extraReducers: (builder) => { // update status of request to confirming @@ -92,7 +93,7 @@ const slice = createSlice({ builder.addMatcher( (action) => action.type === confirmRequest.type || action.type === confirmRequestNoDappInfo.type, (state, action) => { - const { dappRequest } = action.payload + const { dappRequest } = action['payload'] const request = state.requests[dappRequest.requestId] if (request) { request.status = DappRequestStatus.Confirming @@ -102,8 +103,9 @@ const slice = createSlice({ }, }) -export const selectAllDappRequests = (state: { dappRequests: DappRequestState }): DappRequestStoreItem[] => - selectDappRequestsArray(state.dappRequests) +export const selectAllDappRequests = (state: { + dappRequests: DappRequestState +}): WithMetadata[] => selectDappRequestsArray(state.dappRequests) export const selectIsRequestConfirming = (state: { dappRequests: DappRequestState }, requestId: string): boolean => state.dappRequests.requests[requestId]?.status === DappRequestStatus.Confirming diff --git a/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts b/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts index 57868086614..50edb9a9dce 100644 --- a/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts +++ b/apps/extension/src/app/features/dappRequests/types/DappRequestTypes.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/no-unused-modules */ +/* oxlint-disable import/no-unused-modules */ import { EthereumRpcErrorSchema } from 'src/app/features/dappRequests/types/ErrorTypes' import { EthersTransactionRequestSchema } from 'src/app/features/dappRequests/types/EthersTypes' import { NonfungiblePositionManagerCallSchema } from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes' diff --git a/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts b/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts index 37307d7e8af..0ca097b4a35 100644 --- a/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts +++ b/apps/extension/src/app/features/dappRequests/types/EthersTypes.ts @@ -6,7 +6,7 @@ import { z } from 'zod' * Ethers types copied from `ethers` package */ -// eslint-disable-next-line no-restricted-syntax +// oxlint-disable-next-line no-restricted-syntax export const BigNumberSchema = z.any() // TODO (EXT-831): Add schema once stable const AccessListEntrySchema = z.object({ @@ -32,7 +32,7 @@ const BytesLikeSchema = z.string().refine((data) => isHexString(data)) const AccessListishSchema = z.union([ AccessListSchema, z.array(z.tuple([z.string(), z.array(z.string())])), // Array of 2-element Arrays format - z.record(z.array(z.string())), // Object with addresses as keys and arrays of storage keys as values + z.record(z.string(), z.array(z.string())), // Object with addresses as keys and arrays of storage keys as values ]) export const EthersTransactionRequestSchema = z.object({ @@ -48,7 +48,7 @@ export const EthersTransactionRequestSchema = z.object({ accessList: AccessListishSchema.optional(), maxPriorityFeePerGas: BigNumberishSchema.optional(), maxFeePerGas: BigNumberishSchema.optional(), - // eslint-disable-next-line no-restricted-syntax - customData: z.record(z.any()).optional(), + // oxlint-disable-next-line no-restricted-syntax + customData: z.record(z.string(), z.any()).optional(), ccipReadEnabled: z.boolean().optional(), }) diff --git a/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts b/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts index 64c3deab098..59b79d1cf8c 100644 --- a/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts +++ b/apps/extension/src/app/features/dappRequests/types/NonfungiblePositionManager.ts @@ -13,7 +13,7 @@ function parseMulticallCommand(calldata: string): NFPMCommand { return NfpmCommandSchema.parse({ commandName: txDescription.name, - params: txDescription.args.params, + params: txDescription.args['params'], }) } diff --git a/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts b/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts index 5990962ebb1..91c12fb968e 100644 --- a/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts +++ b/apps/extension/src/app/features/dappRequests/types/UniversalRouterTypes.ts @@ -11,7 +11,7 @@ const CommandNameSchema = z.enum( // TODO: remove this fallback once params are fully typed or we are able to import them from the universal router sdk const FallbackParamSchema = z.object({ name: z.string(), - // eslint-disable-next-line no-restricted-syntax + // oxlint-disable-next-line no-restricted-syntax value: z.any(), }) diff --git a/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx b/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx index 9af5f91f513..03ece495ae0 100644 --- a/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx +++ b/apps/extension/src/app/features/dappRequests/types/utilityTypes.tsx @@ -8,6 +8,6 @@ export const HexadecimalNumberSchema = z.union([z.number(), z.string()]).transfo if (!isNaN(possibleNumber)) { return possibleNumber } - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Not a hexadecimal number' }) + ctx.addIssue({ code: 'custom', message: 'Not a hexadecimal number' }) return z.NEVER }) diff --git a/apps/extension/src/app/features/home/HomeScreen.tsx b/apps/extension/src/app/features/home/HomeScreen.tsx index 00145f31da7..ee87afb485a 100644 --- a/apps/extension/src/app/features/home/HomeScreen.tsx +++ b/apps/extension/src/app/features/home/HomeScreen.tsx @@ -1,14 +1,13 @@ import { useApolloClient } from '@apollo/client' import { SharedEventName } from '@uniswap/analytics-events' import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import { memo, useCallback, useEffect, useState } from 'react' +import { getIsNotificationServiceLocalOverrideEnabled } from '@universe/notifications' +import React, { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { ActivityTab } from 'src/app/components/tabs/ActivityTab' import { NftsTab } from 'src/app/components/tabs/NftsTab' import { useSmartWalletNudges } from 'src/app/context/SmartWalletNudgesContext' -import AppRatingModal from 'src/app/features/appRating/AppRatingModal' -import { useAppRating } from 'src/app/features/appRating/hooks/useAppRating' import { HomeIntroCardStack } from 'src/app/features/home/introCards/HomeIntroCardStack' import { PortfolioActionButtons } from 'src/app/features/home/PortfolioActionButtons' import { PortfolioHeader } from 'src/app/features/home/PortfolioHeader' @@ -19,9 +18,15 @@ import { PinReminder } from 'src/app/features/onboarding/PinReminder' import { useOptimizedSearchParams } from 'src/app/hooks/useOptimizedSearchParams' import { HomeQueryParams, HomeTabs } from 'src/app/navigation/constants' import { navigate } from 'src/app/navigation/state' +import { ExtensionNotificationServiceManager } from 'src/notification-service/ExtensionNotificationServiceManager' import { Flex, Loader, styled, Text, TouchableArea } from 'ui/src' import { SMART_WALLET_UPGRADE_VIDEO } from 'ui/src/assets' +import { buildWrappedUrl } from 'uniswap/src/components/banners/shared/utils' +import { UniswapWrapped2025Banner } from 'uniswap/src/components/banners/UniswapWrapped2025Banner/UniswapWrapped2025Banner' import { NFTS_TAB_DATA_DEPENDENCIES } from 'uniswap/src/components/nfts/constants' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { selectHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/selectors' +import { setHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/slice' import { useSelectAddressHasNotifications } from 'uniswap/src/features/notifications/slice/hooks' import { setNotificationStatus } from 'uniswap/src/features/notifications/slice/slice' import { PortfolioBalance } from 'uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance' @@ -56,7 +61,7 @@ const MemoizedVideo = memo(() => ( MemoizedVideo.displayName = 'MemoizedVideo' -export const HomeScreen = memo(function _HomeScreen(): JSX.Element { +export const HomeScreen = memo(function HomeScreenInner(): JSX.Element { const { t } = useTranslation() const activeAccount = useActiveAccountWithThrow() const [showTabs, setShowTabs] = useState(false) @@ -72,6 +77,30 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element { const [isSmartWalletEnabledModalOpen, setIsSmartWalletEnabledModalOpen] = useState(false) const dispatch = useDispatch() + // UniswapWrapped2025 banner state + const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025) + const hasDismissedWrappedBanner = useSelector(selectHasDismissedUniswapWrapped2025Banner) + const shouldShowWrappedBanner = isWrappedBannerEnabled && !hasDismissedWrappedBanner + + // Notification service feature flag + const isNotificationServiceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationService) + const isNotificationServiceEnabled = + getIsNotificationServiceLocalOverrideEnabled() || isNotificationServiceEnabledFlag + + const handleDismissWrappedBanner = useCallback(() => { + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + }, [dispatch]) + + const handlePressWrappedBanner = useCallback(() => { + try { + const url = buildWrappedUrl(UNISWAP_WEB_URL, address) + window.open(url, '_blank') + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + } catch (error) { + logger.error(error, { tags: { file: 'HomeScreen', function: 'handlePressWrappedBanner' } }) + } + }, [address, dispatch]) + useEffect(() => { if (selectedTab) { sendAnalyticsEvent(SharedEventName.PAGE_VIEWED, { @@ -158,8 +187,6 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element { } }, [apolloClient, shouldRefetchNfts]) - const { appRatingModalVisible, onAppRatingModalClose } = useAppRating() - return ( {address ? ( @@ -169,17 +196,39 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element { )} + {shouldShowWrappedBanner && ( + + + + + )} - + - + + + {!isNotificationServiceEnabled && } @@ -242,7 +291,6 @@ export const HomeScreen = memo(function _HomeScreen(): JSX.Element { {t('home.extension.error')} )} - {appRatingModalVisible && } {isSmartWalletEnabled && !activeModal && ( void children: React.ReactNode showPendingNotificationBadge?: boolean -}): JSX.Element => { +}): React.JSX.Element => { return ( diff --git a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx index 9a7a08625ce..cd722938849 100644 --- a/apps/extension/src/app/features/home/PortfolioActionButtons.tsx +++ b/apps/extension/src/app/features/home/PortfolioActionButtons.tsx @@ -62,11 +62,10 @@ function ActionButton({ label, Icon, onClick, url }: ActionButtonProps): JSX.Ele ) } -export const PortfolioActionButtons = memo(function _PortfolioActionButtons(): JSX.Element { +export const PortfolioActionButtons = memo(function PortfolioActionButtonsInner(): JSX.Element { const { t } = useTranslation() const media = useMedia() const { isTestnetModeEnabled } = useEnabledChains() - const isFiatOffRampEnabled = useFeatureFlag(FeatureFlags.FiatOffRamp) const onSendClick = (): void => { sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { @@ -118,11 +117,7 @@ export const PortfolioActionButtons = memo(function _PortfolioActionButtons(): J /> } label={t('home.label.swap')} onClick={onSwapClick} /> - } - label={isFiatOffRampEnabled ? t('home.label.for') : t('home.label.buy')} - onClick={onBuyClick} - /> + } label={t('home.label.for')} onClick={onBuyClick} /> } label={t('home.label.send')} onClick={onSendClick} /> diff --git a/apps/extension/src/app/features/home/PortfolioHeader.tsx b/apps/extension/src/app/features/home/PortfolioHeader.tsx index 1a13b6b6b9f..c765c8f9c24 100644 --- a/apps/extension/src/app/features/home/PortfolioHeader.tsx +++ b/apps/extension/src/app/features/home/PortfolioHeader.tsx @@ -23,8 +23,8 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { ExtensionScreens } from 'uniswap/src/types/screens/extension' import { sanitizeAddressText } from 'uniswap/src/utils/addresses' -import { setClipboard } from 'uniswap/src/utils/clipboard' import { shortenAddress } from 'utilities/src/addresses' +import { setClipboard } from 'utilities/src/clipboard/clipboard' import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' import { AnimatedUnitagDisplayName } from 'wallet/src/components/accounts/AnimatedUnitagDisplayName' import useIsFocused from 'wallet/src/features/focus/useIsFocused' @@ -55,6 +55,7 @@ const RotatingSettingsIcon = ({ onPressSettings }: { onPressSettings(): void }): }), ) } + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, [isScreenFocused]) const onBegin = (): void => { @@ -94,7 +95,7 @@ const RotatingSettingsIcon = ({ onPressSettings }: { onPressSettings(): void }): ) } -export const PortfolioHeader = memo(function _PortfolioHeader({ address }: PortfolioHeaderProps): JSX.Element { +export const PortfolioHeader = memo(function PortfolioHeaderInner({ address }: PortfolioHeaderProps): JSX.Element { const dispatch = useDispatch() const displayName = useDisplayName(address) @@ -152,12 +153,12 @@ export const PortfolioHeader = memo(function _PortfolioHeader({ address }: Portf - + - + diff --git a/apps/extension/src/app/features/home/SwitchNetworksModal.tsx b/apps/extension/src/app/features/home/SwitchNetworksModal.tsx index 7d3702c9df7..c13d2d62b8e 100644 --- a/apps/extension/src/app/features/home/SwitchNetworksModal.tsx +++ b/apps/extension/src/app/features/home/SwitchNetworksModal.tsx @@ -53,13 +53,7 @@ export function SwitchNetworksModal({ onPress }: SwitchNetworksModalProps): JSX. - + diff --git a/apps/extension/src/app/features/home/TokenBalanceList.tsx b/apps/extension/src/app/features/home/TokenBalanceList.tsx index ebbed006847..91c7473af51 100644 --- a/apps/extension/src/app/features/home/TokenBalanceList.tsx +++ b/apps/extension/src/app/features/home/TokenBalanceList.tsx @@ -10,7 +10,7 @@ import { useEvent } from 'utilities/src/react/hooks' import { useBooleanState } from 'utilities/src/react/useBooleanState' import { usePortfolioEmptyStateBackground } from 'wallet/src/components/portfolio/empty' -export const ExtensionTokenBalanceList = memo(function _ExtensionTokenBalanceList({ +export const ExtensionTokenBalanceList = memo(function ExtensionTokenBalanceListInner({ owner, }: { owner: Address diff --git a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx index 45723370f88..1f911c251cc 100644 --- a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx +++ b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx @@ -35,8 +35,6 @@ export function HomeIntroCardStack(): JSX.Element | null { onMonadAnnouncementPress: () => setIsMonadModalOpen(true), }) - // Don't show cards if there are none - // or if the account is view only (not yet available on extension, adding for safety) if (!cards.length || !isSignerAccount) { return null } diff --git a/apps/extension/src/app/features/lockScreen/Locked.tsx b/apps/extension/src/app/features/lockScreen/Locked.tsx index e5d6ad735df..a493da40b24 100644 --- a/apps/extension/src/app/features/lockScreen/Locked.tsx +++ b/apps/extension/src/app/features/lockScreen/Locked.tsx @@ -7,14 +7,15 @@ import { PasswordInputWithBiometrics } from 'src/app/components/PasswordInput' import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage' import { useUnlockWithBiometricCredentialMutation } from 'src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation' import { useUnlockWithPassword } from 'src/app/features/lockScreen/useUnlockWithPassword' -import { useSagaStatus } from 'src/app/hooks/useSagaStatus' import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' import { focusOrCreateOnboardingTab } from 'src/app/navigation/focusOrCreateOnboardingTab' +import { ExtensionState } from 'src/store/extensionReducer' import { Button, Flex, InputProps, Text } from 'ui/src' import { AlertTriangleFilled, Lock } from 'ui/src/components/icons' import { spacing, zIndexes } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { SagaStatus, useMonitoredSagaStatus } from 'uniswap/src/utils/saga' import { useEvent } from 'utilities/src/react/hooks' import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' import { authSagaName } from 'wallet/src/features/auth/saga' @@ -22,7 +23,6 @@ import { AuthSagaError } from 'wallet/src/features/auth/types' import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' -import { SagaStatus } from 'wallet/src/utils/saga' function usePasswordInput(defaultValue = ''): Pick & { value: string } { const [value, setValue] = useState(defaultValue) @@ -63,7 +63,7 @@ export function Locked(): JSX.Element { [onChangePasswordText], ) - const { status, error } = useSagaStatus({ sagaName: authSagaName, resetSagaOnSuccess: false }) + const { status, error } = useMonitoredSagaStatus(authSagaName) const unlockWithPassword = useUnlockWithPassword() const onPressUnlockWithPassword = useEvent(() => unlockWithPassword({ password: enteredPassword })) diff --git a/apps/extension/src/app/features/onboarding/Complete.tsx b/apps/extension/src/app/features/onboarding/Complete.tsx index 42517907be7..de68630fe6e 100644 --- a/apps/extension/src/app/features/onboarding/Complete.tsx +++ b/apps/extension/src/app/features/onboarding/Complete.tsx @@ -1,21 +1,16 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { OpenSidebarButton } from 'src/app/components/buttons/OpenSidebarButton' +import { useFinishExtensionOnboarding } from 'src/app/features/onboarding/hooks/useFinishExtensionOnboarding' +import { useOpenSidebar } from 'src/app/features/onboarding/hooks/useOpenSidebar' import { MainContentWrapper } from 'src/app/features/onboarding/intro/MainContentWrapper' import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' -import { useFinishExtensionOnboarding } from 'src/app/features/onboarding/useFinishExtensionOnboarding' 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 { 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 { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' export function Complete({ @@ -30,7 +25,7 @@ export function Complete({ const address = getOnboardingAccountAddress() const existingClaim = getUnitagClaim() const [unitagClaimAttempted, setUnitagClaimAttempted] = useState(false) - const [openedSideBar, setOpenedSideBar] = useState(false) + const { openedSideBar, handleOpenSidebar, handleOpenWebApp } = useOpenSidebar() useEffect(() => { if (!tryToClaimUnitag || !address || unitagClaimAttempted) { @@ -50,33 +45,6 @@ export function Complete({ skip: tryToClaimUnitag && !unitagClaimAttempted, }) - 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 ( @@ -97,18 +65,11 @@ export function Complete({ ))} - - - + diff --git a/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx index 580ef0e6433..c031b329b3a 100644 --- a/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx +++ b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx @@ -58,7 +58,7 @@ export function OnboardingStepsProvider({ const isOnboarded = useSelector(isOnboardedSelector) const wasAlreadyOnboardedWhenPageLoaded = useRef(isOnboarded) - // biome-ignore lint/correctness/useExhaustiveDependencies: we also want to run this effect if isOnboarded changes + // oxlint-disable-next-line react/exhaustive-deps -- we also want to run this effect if isOnboarded changes useEffect(() => { if (!isResetting && wasAlreadyOnboardedWhenPageLoaded.current && !disableRedirect) { // Redirect to the intro screen screen if user is already onboarded. @@ -116,7 +116,7 @@ export function OnboardingStepsProvider({ setState((prev) => ({ ...prev, step: nextStep })) }, []) - // biome-ignore lint/correctness/useExhaustiveDependencies: onboardingScreenKey is a helper function defined below that doesn't need to be a dependency + // oxlint-disable-next-line react/exhaustive-deps -- onboardingScreenKey is a helper function defined below that doesn't need to be a dependency const setOnboardingScreen = useCallback((next: OnboardingScreenProps) => { clearTimeout(clearScreenTimeout) setState((prev) => { @@ -135,7 +135,7 @@ export function OnboardingStepsProvider({ currentOnboardingScreen = next }, []) - // biome-ignore lint/correctness/useExhaustiveDependencies: onboardingScreenKey is a helper function defined below that doesn't need to be a dependency + // oxlint-disable-next-line react/exhaustive-deps -- onboardingScreenKey is a helper function defined below that doesn't need to be a dependency const clearOnboardingScreen = useCallback((next: OnboardingScreenProps) => { // delay clear so the next screen can beat clearing the old one to avoid flickering clearScreenTimeout = setTimeout(() => { @@ -229,7 +229,7 @@ export function OnboardingStepsProvider({ {onboardingScreen && ( <> {/* render actual screen contents "offscreen", we use context and put it on onboardingScreen */} - {/* biome-ignore lint/correctness/noRestrictedElements: probably we can replace it here */} + {/* oxlint-disable-next-line react/forbid-elements -- probably we can replace it here */}
{stepContents}
{ generateInitialAddresses().catch((error) => { logger.error(error, { tags: { file: 'PasswordImport.tsx', function: 'generateInitialAddresses' }, }) }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, []) const onSubmit = useCallback( 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 index f3932ce02f8..7539f9ca6bb 100644 --- a/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap +++ b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap @@ -10,7 +10,7 @@ exports[`KeyboardKey Component renders correctly with state Highlighted 1`] = ` class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _alignItems-center _backgroundColor-surface1 _borderTopColor-accent2Hove112785 _borderRightColor-accent2Hove112785 _borderBottomColor-accent2Hove112785 _borderLeftColor-accent2Hove112785 _borderTopLeftRadius-t-radius-ro291586424 _borderTopRightRadius-t-radius-ro291586424 _borderBottomRightRadius-t-radius-ro291586424 _borderBottomLeftRadius-t-radius-ro291586424 _borderTopWidth-t-space-spa94665587 _borderRightWidth-t-space-spa94665587 _borderBottomWidth-t-space-spa94665587 _borderLeftWidth-t-space-spa94665587 _height-70px _justifyContent-center _pr-t-space-spa1360334043 _pl-t-space-spa1360334043 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid _boxShadow-0px7px0pxva651413887" > Shift @@ -33,7 +33,7 @@ exports[`KeyboardKey Component renders correctly with state KeyDown 1`] = ` class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _alignItems-center _backgroundColor-surface1 _borderTopColor-accent2Hove112785 _borderRightColor-accent2Hove112785 _borderBottomColor-accent2Hove112785 _borderLeftColor-accent2Hove112785 _borderTopLeftRadius-t-radius-ro291586424 _borderTopRightRadius-t-radius-ro291586424 _borderBottomRightRadius-t-radius-ro291586424 _borderBottomLeftRadius-t-radius-ro291586424 _borderTopWidth-t-space-spa94665587 _borderRightWidth-t-space-spa94665587 _borderBottomWidth-t-space-spa94665587 _borderLeftWidth-t-space-spa94665587 _height-70px _justifyContent-center _pr-t-space-spa1360334043 _pl-t-space-spa1360334043 _top-7px _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid _boxShadow-0px0pxvar--100262595" > Shift @@ -56,7 +56,7 @@ exports[`KeyboardKey Component renders correctly with state KeyUp 1`] = ` class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _alignItems-center _backgroundColor-surface1 _borderTopColor-surface3 _borderRightColor-surface3 _borderBottomColor-surface3 _borderLeftColor-surface3 _borderTopLeftRadius-t-radius-ro291586424 _borderTopRightRadius-t-radius-ro291586424 _borderBottomRightRadius-t-radius-ro291586424 _borderBottomLeftRadius-t-radius-ro291586424 _borderTopWidth-t-space-spa94665587 _borderRightWidth-t-space-spa94665587 _borderBottomWidth-t-space-spa94665587 _borderLeftWidth-t-space-spa94665587 _height-70px _justifyContent-center _pr-t-space-spa1360334043 _pl-t-space-spa1360334043 _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid _boxShadow-0px7px0pxva612953881" > Shift diff --git a/apps/extension/src/app/features/onboarding/alerts/slice.ts b/apps/extension/src/app/features/onboarding/alerts/slice.ts index 59090dc3f01..7393681232f 100644 --- a/apps/extension/src/app/features/onboarding/alerts/slice.ts +++ b/apps/extension/src/app/features/onboarding/alerts/slice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' export enum AlertName { PinToToolbar = 'PinToToolbar', @@ -23,8 +23,9 @@ const slice = createSlice({ closeAlert: (state, action: PayloadAction) => { state[action.payload].isOpen = false }, + resetAlerts: () => initialState, }, }) -export const { closeAlert } = slice.actions +export const { closeAlert, resetAlerts } = slice.actions export const { reducer: alertsReducer } = slice diff --git a/apps/extension/src/app/features/onboarding/useFinishExtensionOnboarding.ts b/apps/extension/src/app/features/onboarding/hooks/useFinishExtensionOnboarding.ts similarity index 100% rename from apps/extension/src/app/features/onboarding/useFinishExtensionOnboarding.ts rename to apps/extension/src/app/features/onboarding/hooks/useFinishExtensionOnboarding.ts diff --git a/apps/extension/src/app/features/onboarding/hooks/useOpenSidebar.ts b/apps/extension/src/app/features/onboarding/hooks/useOpenSidebar.ts new file mode 100644 index 00000000000..ec389095235 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/hooks/useOpenSidebar.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +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 { uniswapUrls } from 'uniswap/src/constants/urls' +import { logger } from 'utilities/src/logger/logger' +import { useBooleanState } from 'utilities/src/react/useBooleanState' + +export function useOpenSidebar() { + const { value: openedSideBar, setTrue: openSideBar } = useBooleanState(false) + + useEffect(() => { + const onSidebarOpenedListener = onboardingMessageChannel.addMessageListener( + OnboardingMessageType.SidebarOpened, + () => { + openSideBar() + }, + ) + return () => { + onboardingMessageChannel.removeMessageListener(OnboardingMessageType.SidebarOpened, onSidebarOpenedListener) + } + }, [openSideBar]) + + const handleOpenSidebar = async (): Promise => { + try { + const { tabId, windowId } = await getCurrentTabAndWindowId() + await openSidePanel(tabId, windowId) + } catch (error) { + logger.error(error, { + tags: { file: 'useOpenSidebar.ts', function: 'handleOpenSidebar' }, + }) + } + } + + const handleOpenWebApp = async (): Promise => { + window.location.href = uniswapUrls.webInterfaceSwapUrl + } + + return { openedSideBar, handleOpenSidebar, handleOpenWebApp } +} diff --git a/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx index 2c89cc0cad8..3a3a34572d2 100644 --- a/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx +++ b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx @@ -2,10 +2,10 @@ import { wordlists } from '@ethersproject/wordlists' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { - NativeSyntheticEvent, - TextInputChangeEventData, - TextInputFocusEventData, - TextInputKeyPressEventData, + type NativeSyntheticEvent, + type TextInputChangeEventData, + type TextInputFocusEventData, + type TextInputKeyPressEventData, } from 'react-native' import { useDispatch } from 'react-redux' import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' @@ -117,7 +117,7 @@ export function ImportMnemonic(): JSX.Element { if (!word) { return } - const wordInList = wordlists.en?.getWordIndex(word) !== -1 + const wordInList = wordlists['en']?.getWordIndex(word) !== -1 setErrors({ ...errors, [index]: !wordInList }) }, [errors], @@ -160,6 +160,7 @@ export function ImportMnemonic(): JSX.Element { if (isResetting) { // Remove all accounts before importing mnemonic. + // oxlint-disable-next-line typescript/await-thenable -- biome-parity: oxlint is stricter here await dispatch( editAccountActions.trigger({ type: EditAccountAction.Remove, @@ -252,9 +253,7 @@ export function ImportMnemonic(): JSX.Element { + {isRunning && ( + + )} + +
+
+ + {/* Current Progress */} + + + {/* Results */} + {Object.entries(groupedResults).map(([difficulty, impls]) => ( + + ))} + + {/* Empty State */} + {results.length === 0 && !isRunning && ( + + + Select difficulty and implementation, then run a benchmark. + + + )} + + {/* Operation Log */} + +
+ + ) +} diff --git a/apps/extension/src/app/features/settings/SessionsDebugScreen.tsx b/apps/extension/src/app/features/settings/SessionsDebugScreen.tsx new file mode 100644 index 00000000000..dcd6a035d2e --- /dev/null +++ b/apps/extension/src/app/features/settings/SessionsDebugScreen.tsx @@ -0,0 +1,553 @@ +/* oxlint-disable max-lines */ +import { getEntryGatewayUrl, provideSessionService } from '@universe/api' +import { + ChallengeType, + createHashcashSolver, + createHashcashWorkerChannel, + type SessionService, +} from '@universe/sessions' +import { memo, useCallback, useEffect, useRef } from 'react' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { type LogEntry, useSessionsDebugStore } from 'src/app/features/settings/stores/sessionsDebugStore' +import { Button, Flex, ScrollView, Text, TouchableArea } from 'ui/src' +import { CopyAlt } from 'ui/src/components/icons' +import { setClipboard } from 'utilities/src/clipboard/clipboard' +import { logger } from 'utilities/src/logger/logger' +import { useShallow } from 'zustand/shallow' + +// Storage keys (must match session storage) +const SESSION_ID_KEY = 'UNISWAP_SESSION_ID' +const DEVICE_ID_KEY = 'UNISWAP_DEVICE_ID' +const UNISWAP_IDENTIFIER_KEY = 'UNISWAP_IDENTIFIER' + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function truncateId(id: string | null, length = 16): string { + if (!id) { + return 'None' + } + if (id.length <= length) { + return id + } + return `${id.slice(0, length)}...` +} + +// Memoized log entry component +const LogEntryRow = memo(function LogEntryRow({ log, index }: { log: LogEntry; index: number }): JSX.Element { + return ( + + {formatTime(log.timestamp)} - {log.message} + + ) +}) + +// Operation log section +const LogSection = memo(function LogSection(): JSX.Element | null { + const logs = useSessionsDebugStore((state) => state.logs) + const clearLogs = useSessionsDebugStore((state) => state.clearLogs) + + if (logs.length === 0) { + return null + } + + return ( + + + Operation Log + + + Clear + + + + + {logs.map((log, index) => ( + + ))} + + ) +}) + +// Hashcash progress section +const HashcashProgressSection = memo(function HashcashProgressSection(): JSX.Element | null { + const progress = useSessionsDebugStore( + useShallow((state) => ({ + isRunning: state.hashcashProgress.isRunning, + difficulty: state.hashcashProgress.difficulty, + estimatedAttempts: state.hashcashProgress.estimatedAttempts, + elapsedMs: state.hashcashProgress.elapsedMs, + actualResult: state.hashcashProgress.actualResult, + })), + ) + + if (!progress.isRunning && !progress.actualResult) { + return null + } + + return ( + + Hashcash Progress + + + + Status: + + + {progress.isRunning ? 'Solving...' : 'Complete'} + + + + + + Difficulty: + + + {progress.difficulty} + + + + + + Attempts: + + + {progress.actualResult + ? (progress.actualResult.iterationCount ?? 0).toLocaleString() + : `~${progress.estimatedAttempts.toLocaleString()}`} + + + + + + Time: + + + {progress.actualResult + ? `${(progress.actualResult.durationMs / 1000).toFixed(2)}s` + : `${(progress.elapsedMs / 1000).toFixed(2)}s`} + + + + {progress.actualResult && ( + + + Hash Rate: + + + {((progress.actualResult.iterationCount ?? 0) / (progress.actualResult.durationMs / 1000)).toLocaleString( + undefined, + { maximumFractionDigits: 0 }, + )}{' '} + h/s + + + )} + + ) +}) + +// Current operation display +const CurrentOperationSection = memo(function CurrentOperationSection(): JSX.Element | null { + const currentOperation = useSessionsDebugStore((state) => state.currentOperation) + + if (!currentOperation) { + return null + } + + return ( + + + {currentOperation} + + + ) +}) + +/** + * Sessions Debug Screen for testing session initialization flow. + * Access via Dev menu in development builds. + */ +export function SessionsDebugScreen(): JSX.Element { + // Individual selectors for minimal re-renders + const session = useSessionsDebugStore( + useShallow((state) => ({ + sessionId: state.session.sessionId, + deviceId: state.session.deviceId, + uniswapIdentifier: state.session.uniswapIdentifier, + })), + ) + const challenge = useSessionsDebugStore((state) => state.challenge) + const isLoading = useSessionsDebugStore((state) => state.isLoading) + const hashcashIsRunning = useSessionsDebugStore((state) => state.hashcashProgress.isRunning) + const hashcashStartTime = useSessionsDebugStore((state) => state.hashcashProgress.startTime) + + // Actions (stable references) + const setSession = useSessionsDebugStore((state) => state.setSession) + const setChallenge = useSessionsDebugStore((state) => state.setChallenge) + const startOperation = useSessionsDebugStore((state) => state.startOperation) + const endOperation = useSessionsDebugStore((state) => state.endOperation) + const addLog = useSessionsDebugStore((state) => state.addLog) + const startHashcash = useSessionsDebugStore((state) => state.startHashcash) + const updateHashcashProgress = useSessionsDebugStore((state) => state.updateHashcashProgress) + const completeHashcash = useSessionsDebugStore((state) => state.completeHashcash) + const stopHashcash = useSessionsDebugStore((state) => state.stopHashcash) + const reset = useSessionsDebugStore((state) => state.reset) + + const sessionServiceRef = useRef(null) + + const getSessionService = useCallback((): SessionService => { + if (!sessionServiceRef.current) { + sessionServiceRef.current = provideSessionService({ + getBaseUrl: getEntryGatewayUrl, + getIsSessionServiceEnabled: () => true, // Always enabled for debug + getLogger: () => logger, + }) + } + return sessionServiceRef.current + }, []) + + const refreshSessionState = useCallback(async (): Promise => { + const [sessionId, deviceId, uniswapIdentifier] = await Promise.all([ + localStorage.getItem(SESSION_ID_KEY), + localStorage.getItem(DEVICE_ID_KEY), + localStorage.getItem(UNISWAP_IDENTIFIER_KEY), + ]) + setSession({ + sessionId: sessionId || null, + deviceId: deviceId || null, + uniswapIdentifier: uniswapIdentifier || null, + }) + }, [setSession]) + + // Initial load + useEffect(() => { + const loadInitialState = async (): Promise => { + const [sessionId, deviceId, uniswapIdentifier] = await Promise.all([ + localStorage.getItem(SESSION_ID_KEY), + localStorage.getItem(DEVICE_ID_KEY), + localStorage.getItem(UNISWAP_IDENTIFIER_KEY), + ]) + setSession({ + sessionId: sessionId || null, + deviceId: deviceId || null, + uniswapIdentifier: uniswapIdentifier || null, + }) + } + // oxlint-disable-next-line typescript/no-floating-promises -- biome-parity: oxlint is stricter here + loadInitialState() + }, [setSession]) + + // Progress timer for hashcash + useEffect(() => { + if (hashcashIsRunning && hashcashStartTime !== null) { + const interval = setInterval(() => { + const elapsed = performance.now() - hashcashStartTime + // Estimate ~500k hashes/sec on web worker + const estimatedAttempts = Math.floor((elapsed / 1000) * 500000) + updateHashcashProgress(elapsed, estimatedAttempts) + }, 100) + + return (): void => { + clearInterval(interval) + } + } + return undefined + }, [hashcashIsRunning, hashcashStartTime, updateHashcashProgress]) + + const clearAllState = useCallback(async (): Promise => { + startOperation('Clearing all state...') + try { + localStorage.removeItem(SESSION_ID_KEY) + localStorage.removeItem(DEVICE_ID_KEY) + localStorage.removeItem(UNISWAP_IDENTIFIER_KEY) + sessionServiceRef.current = null + setChallenge(null) + reset() + addLog('Cleared all session state', 'success') + await refreshSessionState() + } catch (error) { + addLog(`Failed to clear state: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'clearAllState' } }) + } finally { + endOperation() + } + }, [startOperation, setChallenge, reset, addLog, refreshSessionState, endOperation]) + + const handleInitSession = useCallback(async (): Promise => { + startOperation('Initializing session...') + addLog('Init session started') + try { + const service = getSessionService() + const result = await service.initSession() + addLog(`Session initialized. needChallenge: ${result.needChallenge}`, 'success') + if (result.sessionId) { + addLog(`Session ID: ${truncateId(result.sessionId)}`) + } + await refreshSessionState() + } catch (error) { + addLog(`Init session failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleInitSession' } }) + } finally { + endOperation() + } + }, [startOperation, addLog, getSessionService, refreshSessionState, endOperation]) + + const handleRequestChallenge = useCallback(async (): Promise => { + startOperation('Requesting challenge...') + addLog('Request challenge started') + try { + const service = getSessionService() + const challengeResult = await service.requestChallenge() + setChallenge(challengeResult) + const challengeTypeName = ChallengeType[challengeResult.challengeType] || 'Unknown' + addLog(`Challenge received: ${challengeTypeName}`, 'success') + addLog(`Challenge ID: ${truncateId(challengeResult.challengeId)}`) + + if (challengeResult.challengeType === ChallengeType.HASHCASH && challengeResult.extra['challengeData']) { + try { + const challengeData = JSON.parse(challengeResult.extra['challengeData']) + addLog(`Difficulty: ${challengeData.difficulty}`) + } catch { + // Ignore parse errors + } + } + } catch (error) { + addLog(`Request challenge failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleRequestChallenge' } }) + } finally { + endOperation() + } + }, [startOperation, addLog, getSessionService, setChallenge, endOperation]) + + const handleSolveChallenge = useCallback(async (): Promise => { + const currentChallenge = useSessionsDebugStore.getState().challenge + if (!currentChallenge) { + addLog('No challenge to solve. Request a challenge first.', 'error') + return + } + + if (currentChallenge.challengeType !== ChallengeType.HASHCASH) { + addLog('Only Hashcash challenges are supported', 'error') + return + } + + startOperation('Solving hashcash challenge...') + addLog('Hashcash solve started') + + // Parse difficulty for progress display + let difficulty = 0 + if (currentChallenge.extra['challengeData']) { + try { + const challengeData = JSON.parse(currentChallenge.extra['challengeData']) + difficulty = challengeData.difficulty || 0 + } catch { + // Use default + } + } + + startHashcash(difficulty) + + try { + const solver = createHashcashSolver({ + performanceTracker: { + now: () => performance.now(), + }, + getWorkerChannel: () => + createHashcashWorkerChannel({ + getWorker: () => + new Worker( + new URL('@universe/sessions/src/challenge-solvers/hashcash/worker/hashcash.worker.ts', import.meta.url), + { type: 'module' }, + ), + }), + onSolveCompleted: (data) => { + completeHashcash(data) + }, + }) + + const solution = await solver.solve({ + challengeId: currentChallenge.challengeId, + challengeType: currentChallenge.challengeType, + extra: currentChallenge.extra, + }) + + addLog(`Challenge solved!`, 'success') + addLog(`Solution: ${truncateId(solution, 32)}`) + + // Verify with backend + startOperation('Verifying session...') + addLog('Verifying session with backend...') + const service = getSessionService() + const verifyResult = await service.verifySession({ + solution, + challengeId: currentChallenge.challengeId, + challengeType: currentChallenge.challengeType, + }) + + if (verifyResult.retry) { + addLog('Verification returned retry=true. May need another challenge.', 'info') + } else { + addLog('Session verified successfully!', 'success') + } + + setChallenge(null) + await refreshSessionState() + } catch (error) { + stopHashcash() + addLog(`Solve challenge failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleSolveChallenge' } }) + } finally { + endOperation() + } + }, [ + addLog, + startOperation, + startHashcash, + completeHashcash, + getSessionService, + setChallenge, + refreshSessionState, + stopHashcash, + endOperation, + ]) + + const copyToClipboard = useCallback( + async (value: string | null, label: string): Promise => { + if (!value) { + return + } + await setClipboard(value) + addLog(`Copied ${label} to clipboard`, 'info') + }, + [addLog], + ) + + const hasChallenge = challenge !== null + + return ( + + + + + + Test session initialization flow step by step. + + + {/* Session Status Section */} + + Session Status + + + + + Session ID: + + + + {truncateId(session.sessionId)} + + {session.sessionId && ( + copyToClipboard(session.sessionId, 'Session ID')}> + + + )} + + + + + + Device ID: + + + + {truncateId(session.deviceId)} + + {session.deviceId && ( + copyToClipboard(session.deviceId, 'Device ID')}> + + + )} + + + + + + Uniswap ID: + + + + {truncateId(session.uniswapIdentifier)} + + {session.uniswapIdentifier && ( + copyToClipboard(session.uniswapIdentifier, 'Uniswap ID')}> + + + )} + + + + + + Challenge Pending: + + + {hasChallenge ? 'Yes' : 'No'} + + + + + + {/* Action Buttons */} + + + + + + {/* Step-by-Step Testing */} + + Step-by-Step Testing + + + + + + + + {/* Current Operation */} + + + {/* Hashcash Progress */} + + + {/* Operation Log */} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsDropdown.tsx b/apps/extension/src/app/features/settings/SettingsDropdown.tsx index bcee8207b0f..9ac5caa304b 100644 --- a/apps/extension/src/app/features/settings/SettingsDropdown.tsx +++ b/apps/extension/src/app/features/settings/SettingsDropdown.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Flex, Popover, ScrollView, Text, TouchableArea } from 'ui/src' +import { animationPresets, Flex, Popover, Text, TouchableArea, useScrollbarStyles } from 'ui/src' import { Check, RotatableChevron } from 'ui/src/components/icons' import { iconSizes, zIndexes } from 'ui/src/theme' @@ -16,10 +16,11 @@ export type SettingsDropdownProps = { } const MAX_DROPDOWN_HEIGHT = 220 -const MAX_DROPDOWN_WIDTH = 200 +const MAX_DROPDOWN_WIDTH = 250 export function SettingsDropdown({ selected, items, disableDropdown, onSelect }: SettingsDropdownProps): JSX.Element { const [isOpen, setIsOpen] = useState(false) + const scrollbarStyles = useScrollbarStyles() return ( @@ -38,22 +39,25 @@ export function SettingsDropdown({ selected, items, disableDropdown, onSelect }: {selected} - + - + - - + + {items.map((item, index) => ( { onSelect(item.value) setIsOpen(false) @@ -90,7 +103,7 @@ export function SettingsDropdown({ selected, items, disableDropdown, onSelect }: ))} - + diff --git a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx index b8de53ef138..74980be6025 100644 --- a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx +++ b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx @@ -1,12 +1,13 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' +import { useSearchParams } from 'react-router' import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' import { removeAllDappConnectionsForAccount, removeDappConnection } from 'src/app/features/dapp/actions' -import { useAllDappConnectionsForActiveAccount } from 'src/app/features/dapp/hooks' +import { useAllDappConnectionsForAccount } from 'src/app/features/dapp/hooks' import { dappStore } from 'src/app/features/dapp/store' import { NoDappConnections } from 'src/app/features/settings/SettingsManageConnectionsScreen/internal/NoDappConnections' -import { Flex, Text, TouchableArea, UniversalImage, useSporeColors } from 'ui/src' +import { Flex, Text, TouchableArea, UniversalImage } from 'ui/src' import { MinusCircle } from 'ui/src/components/icons' import { borderRadii, breakpoints, fonts, gap, iconSizes } from 'ui/src/theme' import { DappIconPlaceholder } from 'uniswap/src/components/dapps/DappIconPlaceholder' @@ -16,6 +17,7 @@ import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import Trace from 'uniswap/src/features/telemetry/Trace' import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { isEVMAddress } from 'utilities/src/addresses/evm/evm' import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' import { extractUrlHost } from 'utilities/src/format/urls' import { DappEllipsisDropdown } from 'wallet/src/components/settings/DappEllipsisDropdown/DappEllipsisDropdown' @@ -32,12 +34,21 @@ const textGap: number = gap.gap4 const textAreaHeight = fonts[titleVariant].lineHeight + fonts[subtitleVariant].lineHeight + textGap export function SettingsManageConnectionsScreen(): JSX.Element { - const colors = useSporeColors() const dispatch = useDispatch() const { t } = useTranslation() + const [searchParams] = useSearchParams() const activeAccount = useActiveAccountWithThrow() - const dappUrls = useAllDappConnectionsForActiveAccount() + // Capture address on mount to prevent screen flash when URL clears during navigation exit + const [initialAddress] = useState(() => { + const param = searchParams.get('address') + return param && isEVMAddress(param) ? param : null + }) + + const targetAddress = initialAddress ?? activeAccount.address + const targetAccount = initialAddress ? { ...activeAccount, address: initialAddress } : activeAccount + + const dappUrls = useAllDappConnectionsForAccount(targetAddress) const getHandleRemoveConnection = useCallback( (dappUrl: string) => async () => { @@ -55,9 +66,10 @@ export function SettingsManageConnectionsScreen(): JSX.Element { activeConnectedAddress: dappInfo?.activeConnectedAddress, connectedAddresses: dappInfo?.connectedAccounts.map((account) => account.address) ?? [], }) - await removeDappConnection(dappUrl, activeAccount) + await removeDappConnection(dappUrl, targetAccount) }, - [dispatch, activeAccount], + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [dispatch, targetAccount], ) const DappTiles = useMemo( @@ -80,7 +92,7 @@ export function SettingsManageConnectionsScreen(): JSX.Element { top="$padding8" onPress={getHandleRemoveConnection(dappUrl)} > - +
) @@ -142,7 +154,7 @@ export function SettingsManageConnectionsScreen(): JSX.Element {
) }), - [dappUrls, getHandleRemoveConnection, colors.neutral3], + [dappUrls, getHandleRemoveConnection], ) const hasConnections = Boolean(DappTiles.length) diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx index f60a84d67a5..b348e91ae42 100644 --- a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx @@ -59,10 +59,12 @@ export function RemoveRecoveryPhraseVerify(): JSX.Element { await Keyring.removePassword() await removeAllDappConnectionsFromExtension() + /* oxlint-disable typescript/await-thenable -- biome-parity: oxlint is stricter here */ await dispatch(setIsTestnetModeEnabled(false)) await dispatch( editAccountActions.trigger({ + /* oxlint-enable typescript/await-thenable -- biome-parity: oxlint is stricter here */ type: EditAccountAction.Remove, accounts: accountsToRemove, }), diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx index b452724e723..76bddd0d140 100644 --- a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx @@ -102,13 +102,7 @@ function AssociatedAccountRow({ py="$spacing12" > - + diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx index d92beeb1737..2488bea6c65 100644 --- a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SeedPhraseDisplay.tsx @@ -6,7 +6,7 @@ import { Flex, Separator, Text } from 'ui/src' 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 { setClipboard } from 'utilities/src/clipboard/clipboard' import { logger } from 'utilities/src/logger/logger' import { mnemonicUnlockedQuery } from 'wallet/src/features/wallet/Keyring/queries' @@ -112,15 +112,6 @@ export function SeedPhraseDisplay({ mnemonicId }: { mnemonicId: string }): JSX.E useEffect(() => { sendAnalyticsEvent(WalletEventName.ViewRecoveryPhrase) - - // Clear clipboard when the component unmounts - return () => { - navigator.clipboard.writeText('').catch((error) => { - logger.error(error, { - tags: { file: 'SeedPhraseDisplay.tsx', function: 'navigator.clipboard.writeText' }, - }) - }) - } }, []) return ( diff --git a/apps/extension/src/app/features/settings/SettingsScreen.tsx b/apps/extension/src/app/features/settings/SettingsScreen.tsx index 48d2ca462ba..0e97a2afc5a 100644 --- a/apps/extension/src/app/features/settings/SettingsScreen.tsx +++ b/apps/extension/src/app/features/settings/SettingsScreen.tsx @@ -1,13 +1,14 @@ import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch } from 'react-redux' +import { useLocation } from 'react-router' import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' import { SettingsItem } from 'src/app/features/settings/components/SettingsItem' import { SettingsSection } from 'src/app/features/settings/components/SettingsSection' import { SettingsToggleRow } from 'src/app/features/settings/components/SettingsToggleRow' import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' -import ThemeToggle from 'src/app/features/settings/ThemeToggle' +import { ThemeToggleWithLabel } from 'src/app/features/settings/ThemeToggle' import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' import { useExtensionNavigation } from 'src/app/navigation/utils' import { getIsDefaultProviderFromStorage, setIsDefaultProviderToStorage } from 'src/app/utils/provider' @@ -17,9 +18,8 @@ import { Chart, Coins, FileListLock, - Global, HelpCenter, - Language, + Language as LanguageIcon, LineChartDots, Lock, Passkey, @@ -32,27 +32,29 @@ import { resetUniswapBehaviorHistory } from 'uniswap/src/features/behaviorHistor import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { FiatCurrency, ORDERED_CURRENCIES } from 'uniswap/src/features/fiatCurrency/constants' import { getFiatCurrencyName, useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' -import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' +import { Language, WALLET_SUPPORTED_LANGUAGES } from 'uniswap/src/features/language/constants' +import { getLanguageInfo, useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' import { PasskeyManagementModal } from 'uniswap/src/features/passkey/PasskeyManagementModal' -import { setCurrentFiatCurrency, setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' +import { + setCurrentFiatCurrency, + setCurrentLanguage, + setIsTestnetModeEnabled, +} from 'uniswap/src/features/settings/slice' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import Trace from 'uniswap/src/features/telemetry/Trace' -import { ConnectionCardLoggingName } from 'uniswap/src/features/telemetry/types' import { TestnetModeModal } from 'uniswap/src/features/testnets/TestnetModeModal' +import { changeLanguage } from 'uniswap/src/i18n' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { ExtensionScreens } from 'uniswap/src/types/screens/extension' import { isDevEnv } from 'utilities/src/environment/env' import { logger } from 'utilities/src/logger/logger' -import { noop } from 'utilities/src/react/noop' -import { CardType, IntroCard, IntroCardGraphicType } from 'wallet/src/components/introCards/IntroCard' -import { SettingsLanguageModal } from 'wallet/src/components/settings/language/SettingsLanguageModal' import { PermissionsModal } from 'wallet/src/components/settings/permissions/PermissionsModal' import { PortfolioBalanceModal } from 'wallet/src/components/settings/portfolioBalance/PortfolioBalanceModal' import { SmartWalletAdvancedSettingsModal } from 'wallet/src/components/smartWallet/modals/SmartWalletAdvancedSettingsModal' import { authActions } from 'wallet/src/features/auth/saga' import { AuthActionType } from 'wallet/src/features/auth/types' -import { selectHasViewedConnectionMigration } from 'wallet/src/features/behaviorHistory/selectors' -import { resetWalletBehaviorHistory, setHasViewedConnectionMigration } from 'wallet/src/features/behaviorHistory/slice' +import { resetWalletBehaviorHistory } from 'wallet/src/features/behaviorHistory/slice' import { BackupType } from 'wallet/src/features/wallet/accounts/types' import { hasBackup } from 'wallet/src/features/wallet/accounts/utils' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' @@ -62,17 +64,16 @@ const manifestVersion = chrome.runtime.getManifest().version export function SettingsScreen(): JSX.Element { const { t } = useTranslation() const dispatch = useDispatch() + const location = useLocation() const { navigateTo, navigateBack } = useExtensionNavigation() const currentLanguageInfo = useCurrentLanguageInfo() const appFiatCurrencyInfo = useAppFiatCurrencyInfo() - const hasViewedConnectionMigration = useSelector(selectHasViewedConnectionMigration) const isSmartWalletEnabled = useFeatureFlag(FeatureFlags.SmartWalletSettings) const signerAccount = useSignerAccounts()[0] const hasPasskeyBackup = hasBackup(BackupType.Passkey, signerAccount) - const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false) const [isPortfolioBalanceModalOpen, setIsPortfolioBalanceModalOpen] = useState(false) const [isTestnetModalOpen, setIsTestnetModalOpen] = useState(false) const [isAdvancedModalOpen, setIsAdvancedModalOpen] = useState(false) @@ -80,8 +81,17 @@ export function SettingsScreen(): JSX.Element { const [isPasskeyModalOpen, setIsPasskeyModalOpen] = useState(false) const [isDefaultProvider, setIsDefaultProvider] = useState(true) + // Auto-open advanced settings modal if navigating with openAdvancedSettings state + useEffect(() => { + const state = location.state as { openAdvancedSettings?: boolean } | undefined + if (state?.openAdvancedSettings) { + setIsAdvancedModalOpen(true) + } + }, [location.state]) + const onPressLockWallet = async (): Promise => { navigateBack() + // oxlint-disable-next-line typescript/await-thenable -- biome-parity: oxlint is stricter here await dispatch(authActions.trigger({ type: AuthActionType.Lock })) } @@ -98,6 +108,7 @@ export function SettingsScreen(): JSX.Element { // trigger before toggling on (ie disabling analytics) if (isChecked) { // doesn't fire on time without await and i have no idea why + // oxlint-disable-next-line typescript/await-thenable -- biome-parity: oxlint is stricter here await fireAnalytic() } @@ -119,6 +130,11 @@ export function SettingsScreen(): JSX.Element { setIsAdvancedModalOpen(false) }, [navigateTo]) + const handleStoragePress = useCallback(() => { + navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.Storage}`) + setIsAdvancedModalOpen(false) + }, [navigateTo]) + useEffect(() => { getIsDefaultProviderFromStorage() .then((newIsDefaultProvider) => setIsDefaultProvider(newIsDefaultProvider)) @@ -134,15 +150,8 @@ export function SettingsScreen(): JSX.Element { await setIsDefaultProviderToStorage(!!isChecked) } - const setConnectionMigrationAsViewed = (): void => { - dispatch(setHasViewedConnectionMigration(true)) - } - return ( - {isLanguageModalOpen ? ( - setIsLanguageModalOpen(false)} /> - ) : undefined} {isPortfolioBalanceModalOpen ? ( {hasPasskeyBackup && ( )} - + { @@ -215,15 +225,17 @@ export function SettingsScreen(): JSX.Element { }} /> { + return { value: language, label: getLanguageInfo(t, language).displayName } + })} selected={currentLanguageInfo.displayName} title={t('settings.setting.language.title')} - onDisabledDropdownPress={() => { - setIsLanguageModalOpen(true) + onSelect={async (value) => { + const language = value as Language + await changeLanguage(getLanguageInfo(t, language).locale) + dispatch(setCurrentLanguage(language)) }} - onSelect={noop} /> setIsAdvancedModalOpen(true)} /> @@ -245,29 +258,6 @@ export function SettingsScreen(): JSX.Element { /> )} - {!hasViewedConnectionMigration && ( - - { - setConnectionMigrationAsViewed() - }} - onClose={(): void => { - setConnectionMigrationAsViewed() - }} - /> - - )} appStateResetter.resetAccountHistory()) + const onPressClearUserSettings = useEvent(() => appStateResetter.resetUserSettings()) + const onPressClearCachedData = useEvent(() => appStateResetter.resetQueryCaches()) + const onPressClearAllData = useEvent(() => appStateResetter.resetAll()) + + return ( + + } /> + + + ) +} diff --git a/apps/extension/src/app/features/settings/ThemeToggle.tsx b/apps/extension/src/app/features/settings/ThemeToggle.tsx index bc9e8086f84..705ac710720 100644 --- a/apps/extension/src/app/features/settings/ThemeToggle.tsx +++ b/apps/extension/src/app/features/settings/ThemeToggle.tsx @@ -1,39 +1,11 @@ -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' -import { Flex, SegmentedControl, Text } from 'ui/src' -import { Contrast, Moon, Sun } from 'ui/src/components/icons' -import { useCurrentAppearanceSetting } from 'wallet/src/features/appearance/hooks' -import { AppearanceSettingType, setSelectedAppearanceSettings } from 'wallet/src/features/appearance/slice' +import { Flex, Text } from 'ui/src' +import { Contrast } from 'ui/src/components/icons' +import { ThemeToggle } from 'uniswap/src/components/appearance/ThemeToggle' -export default function ThemeToggle(): JSX.Element { - const dispatch = useDispatch() +export function ThemeToggleWithLabel(): JSX.Element { const { t } = useTranslation() - const currentAppearanceSetting = useCurrentAppearanceSetting() - - const defaultOptions = [ - { - value: AppearanceSettingType.System, - display: ( - - {t('settings.setting.appearance.option.auto')} - - ), - }, - { - value: AppearanceSettingType.Light, - display: , - }, - { - value: AppearanceSettingType.Dark, - display: , - }, - ] - const switchMode = useCallback( - (mode: AppearanceSettingType) => dispatch(setSelectedAppearanceSettings(mode)), - [dispatch], - ) return ( {t('settings.setting.appearance.title')} - +
) diff --git a/apps/extension/src/app/features/settings/components/SettingsItem.tsx b/apps/extension/src/app/features/settings/components/SettingsItem.tsx index be41154c66d..65e850ee36c 100644 --- a/apps/extension/src/app/features/settings/components/SettingsItem.tsx +++ b/apps/extension/src/app/features/settings/components/SettingsItem.tsx @@ -1,7 +1,6 @@ import { Link } from 'react-router' import { ColorTokens, Flex, GeneratedIcon, Text, TouchableArea, useSporeColors } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons' -import { iconSizes } from 'ui/src/theme' export function SettingsItem({ Icon, @@ -13,6 +12,7 @@ export function SettingsItem({ count, hideChevron = false, RightIcon, + testID, }: { Icon: GeneratedIcon title: string @@ -24,6 +24,7 @@ export function SettingsItem({ themeProps?: { color?: string; hoverColor?: string } url?: string count?: number + testID?: string }): JSX.Element { const colors = useSporeColors() const hoverColor = themeProps?.hoverColor ?? colors.surface2.val @@ -41,6 +42,7 @@ export function SettingsItem({ justifyContent="space-between" px="$spacing12" py="$spacing8" + testID={testID} onPress={onPress} > @@ -64,9 +66,7 @@ export function SettingsItem({ {RightIcon ? ( ) : ( - !hideChevron && ( - - ) + !hideChevron && )} ) diff --git a/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap b/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap index 8dc96964d8e..2c5a5aea32f 100644 --- a/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap +++ b/apps/extension/src/app/features/settings/password/__snapshots__/ChangePasswordForm.test.tsx.snap @@ -63,11 +63,9 @@ exports[`ChangePasswordForm renders without error 1`] = ` />
@@ -93,11 +91,10 @@ exports[`ChangePasswordForm renders without error 1`] = ` >
@@ -204,11 +199,10 @@ exports[`ChangePasswordForm renders without error 1`] = ` > + diff --git a/apps/mobile/src/app/modals/LazyModalRenderer.tsx b/apps/mobile/src/app/modals/LazyModalRenderer.tsx index f9fab34f1ea..b788790935b 100644 --- a/apps/mobile/src/app/modals/LazyModalRenderer.tsx +++ b/apps/mobile/src/app/modals/LazyModalRenderer.tsx @@ -1,6 +1,6 @@ import { useDispatch, useSelector } from 'react-redux' -import { ModalsState } from 'src/features/modals/ModalsState' import { closeModal } from 'src/features/modals/modalSlice' +import { ModalsState } from 'src/features/modals/ModalsState' import { selectModalState } from 'src/features/modals/selectModalState' import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' diff --git a/apps/mobile/src/app/modals/SwapModal.test.tsx b/apps/mobile/src/app/modals/SwapModal.test.tsx index 34dbcdf1daa..b35f86a69e3 100644 --- a/apps/mobile/src/app/modals/SwapModal.test.tsx +++ b/apps/mobile/src/app/modals/SwapModal.test.tsx @@ -5,7 +5,6 @@ import { AppStackScreenProp } from 'src/app/navigation/types' import { persistedReducer } from 'src/app/store' import { preloadedMobileState } from 'src/test/fixtures' import { renderWithProviders } from 'src/test/render' -import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' import { ModalName } from 'uniswap/src/features/telemetry/constants' // Mock required modules with simpler implementation @@ -42,7 +41,7 @@ describe('SwapModal', () => { middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, // Disable serialization check for tests - }).concat(fiatOnRampAggregatorApi.middleware), + }), }) const tree = renderWithProviders(, { diff --git a/apps/mobile/src/app/modals/SwapModal.tsx b/apps/mobile/src/app/modals/SwapModal.tsx index 30ac6b5d8e1..5f8c19debf5 100644 --- a/apps/mobile/src/app/modals/SwapModal.tsx +++ b/apps/mobile/src/app/modals/SwapModal.tsx @@ -8,8 +8,8 @@ import { useBiometricAppSettings } from 'src/features/biometrics/useBiometricApp import { useOsBiometricAuthEnabled } from 'src/features/biometrics/useOsBiometricAuthEnabled' import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' import { useWalletRestore } from 'src/features/wallet/useWalletRestore' -import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import { clearNotificationQueue } from 'uniswap/src/features/notifications/slice/slice' +import { clearNotificationsByType } from 'uniswap/src/features/notifications/slice/slice' +import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { updateSwapStartTimestamp } from 'uniswap/src/features/timing/slice' @@ -17,22 +17,21 @@ import { useSwapPrefilledState } from 'uniswap/src/features/transactions/swap/fo import { logger } from 'utilities/src/logger/logger' import { WalletSwapFlow } from 'wallet/src/features/transactions/swap/WalletSwapFlow' import { invalidateAndRefetchWalletDelegationQueries } from 'wallet/src/features/transactions/watcher/transactionFinalizationSaga' -import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' export function SwapModal({ route }: AppStackScreenProp): JSX.Element { const appDispatch = useDispatch() const initialState = route.params const { hapticFeedback } = useHapticFeedback() - const signerMnemonicAccounts = useSignerAccounts() - const chains = useEnabledChains() - const accountAddresses = signerMnemonicAccounts.map((account) => account.address) - const { onClose: onCloseModal } = useReactNavigationModal() - // Clear all notification toasts when the swap modal closes + // Clear network change notification toasts when the swap modal closes const onClose = useCallback(() => { - appDispatch(clearNotificationQueue()) + appDispatch( + clearNotificationsByType({ + types: [AppNotificationType.NetworkChanged, AppNotificationType.NetworkChangedBridge], + }), + ) onCloseModal() }, [appDispatch, onCloseModal]) @@ -40,10 +39,10 @@ export function SwapModal({ route }: AppStackScreenProp): useEffect(() => { const timestamp = Date.now() appDispatch(updateSwapStartTimestamp({ timestamp })) - invalidateAndRefetchWalletDelegationQueries({ accountAddresses, chainIds: chains.chains }).catch((error) => + invalidateAndRefetchWalletDelegationQueries().catch((error) => logger.debug('SwapModal', 'useEffect', 'Failed to invalidate and refetch wallet delegation queries', error), ) - }, [appDispatch, accountAddresses, chains.chains]) + }, [appDispatch]) const { openWalletRestoreModal, walletRestoreType } = useWalletRestore() 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 3f52fc0bfe0..f21d8327dce 100644 --- a/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap +++ b/apps/mobile/src/app/modals/__snapshots__/AccountSwitcherModal.test.tsx.snap @@ -23,7 +23,6 @@ exports[`AccountSwitcher renders correctly 1`] = ` } > - - + - - + + + + - + @@ -163,7 +144,7 @@ exports[`AccountSwitcher renders correctly 1`] = ` style={ { "flexDirection": "column", - "flexGrow": 1, + "flexShrink": 1, "gap": 0, } } @@ -171,10 +152,8 @@ exports[`AccountSwitcher renders correctly 1`] = ` @@ -183,6 +162,7 @@ exports[`AccountSwitcher renders correctly 1`] = ` { "alignItems": "center", "flexDirection": "row", + "flexGrow": 1, "flexShrink": 1, "gap": 4, "justifyContent": "center", @@ -203,6 +183,7 @@ exports[`AccountSwitcher renders correctly 1`] = ` "fontSize": 19, "fontWeight": "400", "lineHeight": 24, + "textAlign": "center", } } suppressHighlighting={true} @@ -211,230 +192,240 @@ exports[`AccountSwitcher renders correctly 1`] = ` - - 0x​82D56A...373Fa6 - + } + jestInlineStyle={ + [ + { + "backgroundColor": "transparent", + "borderBottomLeftRadius": 12, + "borderBottomRightRadius": 12, + "borderTopLeftRadius": 12, + "borderTopRightRadius": 12, + "flexDirection": "column", + "opacity": 1, + "transform": [ + { + "scale": 1, + }, + ], + }, + ] + } + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onLayout={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + role="button" + style={ + [ + { + "backgroundColor": "transparent", + "borderBottomLeftRadius": 12, + "borderBottomRightRadius": 12, + "borderTopLeftRadius": 12, + "borderTopRightRadius": 12, + "flexDirection": "column", + "opacity": 1, + "transform": [ + { + "scale": 1, + }, + ], + }, + {}, + ] + } + testID="copy" + > + + 0x​82D56A...373Fa6 + + - - - - - + strokeWidth={8} + > + + + + diff --git a/apps/mobile/src/app/monitoredSagas.ts b/apps/mobile/src/app/monitoredSagas.ts index edfcf2d17d9..3d049544249 100644 --- a/apps/mobile/src/app/monitoredSagas.ts +++ b/apps/mobile/src/app/monitoredSagas.ts @@ -1,3 +1,4 @@ +import { getMonitoredSagaReducers, type MonitoredSaga } from 'uniswap/src/utils/saga' import { removeDelegationActions, removeDelegationReducer, @@ -5,6 +6,10 @@ import { removeDelegationSagaName, } from 'wallet/src/features/smartWallet/sagas/removeDelegationSaga' import { + executePlanActions, + executePlanReducer, + executePlanSaga, + executePlanSagaName, executeSwapActions, executeSwapReducer, executeSwapSaga, @@ -26,7 +31,6 @@ import { createAccountsSaga, createAccountsSagaName, } from 'wallet/src/features/wallet/create/createAccountsSaga' -import { getMonitoredSagaReducers, MonitoredSaga } from 'wallet/src/state/saga' // All monitored sagas must be included here export const monitoredSagas: Record = { @@ -54,6 +58,12 @@ export const monitoredSagas: Record = { reducer: executeSwapReducer, actions: executeSwapActions, }, + [executePlanSagaName]: { + name: executePlanSagaName, + wrappedSaga: executePlanSaga, + reducer: executePlanReducer, + actions: executePlanActions, + }, [removeDelegationSagaName]: { name: removeDelegationSagaName, wrappedSaga: removeDelegationSaga, diff --git a/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx b/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx index cb04801094d..f6bcc947174 100644 --- a/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx +++ b/apps/mobile/src/app/navigation/ExploreStackNavigator.tsx @@ -8,9 +8,7 @@ import { ExploreStackParamList } from 'src/app/navigation/types' import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' import { ExploreScreen } from 'src/screens/ExploreScreen' import { ExternalProfileScreen } from 'src/screens/ExternalProfileScreen' -import { NFTCollectionScreen } from 'src/screens/NFTCollectionScreen' -import { NFTItemScreen } from 'src/screens/NFTItemScreen' -import { TokenDetailsScreen } from 'src/screens/TokenDetailsScreen' +import { TokenDetailsScreen } from 'src/screens/TokenDetailsScreen/TokenDetailsScreen' import { useSporeColors } from 'ui/src' import { MobileScreens } from 'uniswap/src/types/screens/mobile' @@ -52,10 +50,6 @@ export function ExploreStackNavigator({ {(props): JSX.Element => } - - {(props): JSX.Element => } - - diff --git a/apps/mobile/src/app/navigation/NavBar.tsx b/apps/mobile/src/app/navigation/NavBar.tsx index f0101480883..0e61a86a972 100644 --- a/apps/mobile/src/app/navigation/NavBar.tsx +++ b/apps/mobile/src/app/navigation/NavBar.tsx @@ -57,7 +57,7 @@ export function NavBar(): JSX.Element { const colors = useSporeColors() const isDarkMode = useIsDarkMode() - // biome-ignore lint/correctness/useExhaustiveDependencies: we want to ignore isNarrow because of unknown reason + // oxlint-disable-next-line react/exhaustive-deps -- we want to ignore isNarrow because of unknown reason useEffect(() => { if (isNarrow || !exploreButtonLayout?.width || !swapButtonLayout?.width) { return @@ -66,6 +66,7 @@ export function NavBar(): JSX.Element { // When the 2 buttons overflow, we set `isNarrow` to true and adjust the design accordingly. // To test this, you can use an iPhone Mini set to Spanish. setIsNarrow(exploreButtonLayout.width + swapButtonLayout.width + NAV_BAR_GAP + NAV_BAR_MARGIN_SIDES > screenWidth) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, [exploreButtonLayout?.width, swapButtonLayout?.width, screenWidth]) const onExploreLayout = useCallback((e: LayoutChangeEvent) => setExploreButtonLayout(e.nativeEvent.layout), []) @@ -121,7 +122,7 @@ type SwapTabBarButtonProps = { onSwapLayout: (event: LayoutChangeEvent) => void } -const SwapFAB = memo(function _SwapFAB({ activeScale = 0.96, onSwapLayout }: SwapTabBarButtonProps) { +const SwapFAB = memo(function SwapFABInner({ activeScale = 0.96, onSwapLayout }: SwapTabBarButtonProps) { const { t } = useTranslation() const { defaultChainId } = useEnabledChains() const { hapticFeedback } = useHapticFeedback() diff --git a/apps/mobile/src/app/navigation/components.tsx b/apps/mobile/src/app/navigation/components.tsx index 37d7c9a488a..ba872ab85c2 100644 --- a/apps/mobile/src/app/navigation/components.tsx +++ b/apps/mobile/src/app/navigation/components.tsx @@ -2,18 +2,15 @@ import { useTranslation } from 'react-i18next' import { BackButton } from 'src/components/buttons/BackButton' import { Text, TouchableArea } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons' -import { iconSizes } from 'ui/src/theme' import { ElementName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' import { TestID } from 'uniswap/src/test/fixtures/testIDs' export const renderHeaderBackButton = (): JSX.Element => ( - + ) -export const renderHeaderBackImage = (): JSX.Element => ( - -) +export const renderHeaderBackImage = (): JSX.Element => export const HeaderSkipButton = ({ onPress }: { onPress: () => void }): JSX.Element => { const { t } = useTranslation() diff --git a/apps/mobile/src/app/navigation/hooks.ts b/apps/mobile/src/app/navigation/hooks.ts index e58ecff8179..e49c8fbcc4a 100644 --- a/apps/mobile/src/app/navigation/hooks.ts +++ b/apps/mobile/src/app/navigation/hooks.ts @@ -62,6 +62,7 @@ export function useEagerExternalProfileRootNavigation(): EagerExternalProfileRoo ) const navigate = useEvent(async (address: string, callback?: () => void) => { + // oxlint-disable-next-line typescript/await-thenable -- biome-parity: oxlint is stricter here await rootNavigate(MobileScreens.ExternalProfile, { address }) callback?.() }) diff --git a/apps/mobile/src/app/navigation/navStackOptions.ts b/apps/mobile/src/app/navigation/navStackOptions.ts index 6b1aa993a07..55ca1cab932 100644 --- a/apps/mobile/src/app/navigation/navStackOptions.ts +++ b/apps/mobile/src/app/navigation/navStackOptions.ts @@ -1,7 +1,7 @@ import { NativeStackNavigationOptions } from '@react-navigation/native-stack' import { StackNavigationOptions } from '@react-navigation/stack' -export const navNativeStackOptions: Record = { +export const navNativeStackOptions = { noHeader: { headerShown: false }, presentationModal: { presentation: 'modal' }, presentationBottomSheet: { @@ -16,8 +16,8 @@ export const navNativeStackOptions: Record headerShown: false, animation: 'slide_from_right', }, -} +} as const satisfies Record -export const navStackOptions: Record = { +export const navStackOptions = { noHeader: { headerShown: false }, -} +} as const satisfies Record diff --git a/apps/mobile/src/app/navigation/navigation.tsx b/apps/mobile/src/app/navigation/navigation.tsx index 889fbf8ea67..0bb90c370bf 100644 --- a/apps/mobile/src/app/navigation/navigation.tsx +++ b/apps/mobile/src/app/navigation/navigation.tsx @@ -24,22 +24,23 @@ import { navNativeStackOptions, navStackOptions } from 'src/app/navigation/navSt import { TabsNavigator } from 'src/app/navigation/tabs/TabsNavigator' import { startTracking, stopTracking } from 'src/app/navigation/trackingHelpers' import { - AppStackParamList, - FiatOnRampStackParamList, - OnboardingStackParamList, - SettingsStackParamList, + type AppStackParamList, + type FiatOnRampStackParamList, + type OnboardingStackParamList, + type SettingsStackParamList, useAppStackNavigation, } from 'src/app/navigation/types' +import { FiatOnRampActionModal } from 'src/components/home/FiatOnRampActionModal' import { FundWalletModal } from 'src/components/home/introCards/FundWalletModal' import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' import { AdvancedSettingsModal } from 'src/components/modals/ReactNavigationModals/AdvancedSettingsModal' import { BridgedAssetModalScreen } from 'src/components/modals/ReactNavigationModals/BridgedAssetModal' import { HiddenTokenInfoModalScreen } from 'src/components/modals/ReactNavigationModals/HiddenTokenInfoModalScreen' -import { LanguageSettingsScreen } from 'src/components/modals/ReactNavigationModals/LanguageSettingsScreen' import { PasskeyHelpModalScreen } from 'src/components/modals/ReactNavigationModals/PasskeyHelpModalScreen' import { PasskeyManagementModalScreen } from 'src/components/modals/ReactNavigationModals/PasskeyManagementModalScreen' import { PermissionsSettingsScreen } from 'src/components/modals/ReactNavigationModals/PermissionsSettingsScreen' import { PortfolioBalanceSettingsScreen } from 'src/components/modals/ReactNavigationModals/PortfolioBalanceSettingsScreen' +import { ReportPortfolioDataModalScreen } from 'src/components/modals/ReactNavigationModals/ReportPortfolioDataModalScreen' import { ReportTokenDataModalScreen } from 'src/components/modals/ReactNavigationModals/ReportTokenDataModalScreen' import { ReportTokenIssueModalScreen } from 'src/components/modals/ReactNavigationModals/ReportTokenIssueModalScreen' import { SmartWalletEnabledModalScreen } from 'src/components/modals/ReactNavigationModals/SmartWalletEnabledModalScreen' @@ -67,6 +68,7 @@ import { EditUnitagProfileScreen } from 'src/features/unitags/EditUnitagProfileS import { UnitagChooseProfilePicScreen } from 'src/features/unitags/UnitagChooseProfilePicScreen' import { UnitagConfirmationScreen } from 'src/features/unitags/UnitagConfirmationScreen' import { AppLoadingScreen } from 'src/screens/AppLoadingScreen' +import { DebugScreensScreen } from 'src/screens/DebugScreensScreen' import { DevScreen } from 'src/screens/DevScreen' import { EducationScreen } from 'src/screens/EducationScreen' import { ExternalProfileScreen } from 'src/screens/ExternalProfileScreen' @@ -85,8 +87,6 @@ import { RestoreMethodScreen } from 'src/screens/Import/RestoreMethodScreen' import { SeedPhraseInputScreen } from 'src/screens/Import/SeedPhraseInputScreen/SeedPhraseInputScreen' import { SelectWalletScreen } from 'src/screens/Import/SelectWalletScreen' import { WatchWalletScreen } from 'src/screens/Import/WatchWalletScreen' -import { NFTCollectionScreen } from 'src/screens/NFTCollectionScreen' -import { NFTItemScreen } from 'src/screens/NFTItemScreen' import { BackupScreen } from 'src/screens/Onboarding/BackupScreen' import { CloudBackupPasswordConfirmScreen } from 'src/screens/Onboarding/CloudBackupPasswordConfirmScreen' import { CloudBackupPasswordCreateScreen } from 'src/screens/Onboarding/CloudBackupPasswordCreateScreen' @@ -102,13 +102,15 @@ import { SettingsCloudBackupPasswordCreateScreen } from 'src/screens/SettingsClo import { SettingsCloudBackupProcessingScreen } from 'src/screens/SettingsCloudBackupProcessingScreen' import { SettingsCloudBackupStatus } from 'src/screens/SettingsCloudBackupStatus' import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal' +import { SettingsLanguageModal } from 'src/screens/SettingsLanguageModal' import { SettingsNotificationsScreen } from 'src/screens/SettingsNotificationsScreen' import { SettingsPrivacyScreen } from 'src/screens/SettingsPrivacyScreen' import { SettingsScreen } from 'src/screens/SettingsScreen' import { SettingsSmartWalletScreen } from 'src/screens/SettingsSmartWalletScreen' +import { SettingsStorageScreen } from 'src/screens/SettingsStorageScreen' import { SettingsViewSeedPhraseScreen } from 'src/screens/SettingsViewSeedPhraseScreen' import { SettingsWalletManageConnection } from 'src/screens/SettingsWalletManageConnection' -import { TokenDetailsScreen } from 'src/screens/TokenDetailsScreen' +import { TokenDetailsScreen } from 'src/screens/TokenDetailsScreen/TokenDetailsScreen' import { ViewPrivateKeysScreen } from 'src/screens/ViewPrivateKeys/ViewPrivateKeysScreen' import { WebViewScreen } from 'src/screens/WebViewScreen' import { useSporeColors } from 'ui/src' @@ -121,7 +123,7 @@ import { MobileScreens, OnboardingScreens, UnitagScreens, - UnitagStackParamList, + type UnitagStackParamList, } from 'uniswap/src/types/screens/mobile' import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext' import { selectFinishedOnboarding } from 'wallet/src/features/wallet/selectors' @@ -159,6 +161,7 @@ function SettingsStackGroup(): JSX.Element { /> + + @@ -220,8 +224,6 @@ export function FiatOnRampStackNavigator(): JSX.Element { function OnboardingStackNavigator(): JSX.Element { const colors = useSporeColors() - const isOnboardingKeyringEnabled = useFeatureFlag(FeatureFlags.OnboardingKeyring) - return ( @@ -236,13 +238,11 @@ function OnboardingStackNavigator(): JSX.Element { animation: 'slide_from_right', }} > - {isOnboardingKeyringEnabled && ( - - )} + - - @@ -402,6 +400,7 @@ export function AppStackNavigator(): JSX.Element { + @@ -422,12 +421,14 @@ export function AppStackNavigator(): JSX.Element { + + @@ -438,7 +439,6 @@ export function AppStackNavigator(): JSX.Element { - @@ -451,7 +451,15 @@ export function AppStackNavigator(): JSX.Element { {__DEV__ && ((): JSX.Element => { const StorybookUIRoot = require('src/../.storybook').default - return + const { HashcashBenchmarkScreen } = require('src/screens/HashcashBenchmarkScreen') + const { SessionsDebugScreen } = require('src/screens/SessionsDebugScreen') + return ( + <> + + + + + ) })()} ) diff --git a/apps/mobile/src/app/navigation/rootNavigation.ts b/apps/mobile/src/app/navigation/rootNavigation.ts index 2463211be84..de54be4a1a6 100644 --- a/apps/mobile/src/app/navigation/rootNavigation.ts +++ b/apps/mobile/src/app/navigation/rootNavigation.ts @@ -25,7 +25,7 @@ export function navigate(...args: RootNav // Type assignment to `any` is a workaround until we figure out how to // type `createNavigationContainerRef` in a way that's compatible - // biome-ignore lint/suspicious/noExplicitAny: Navigation refs need flexible typing + // oxlint-disable-next-line typescript/no-explicit-any -- Navigation refs need flexible typing navigationRef.navigate(routeName as any, params as never) } diff --git a/apps/mobile/src/app/navigation/types.ts b/apps/mobile/src/app/navigation/types.ts index c0ed40ff6cd..39715fdf221 100644 --- a/apps/mobile/src/app/navigation/types.ts +++ b/apps/mobile/src/app/navigation/types.ts @@ -21,11 +21,11 @@ import { ReceiveCryptoModalState } from 'src/screens/ReceiveCryptoModalState' import { ViewPrivateKeysScreenState } from 'src/screens/ViewPrivateKeys/ViewPrivateKeysScreenState' import { BridgedAssetModalProps } from 'uniswap/src/components/BridgedAsset/BridgedAssetModal' import { WormholeModalProps } from 'uniswap/src/components/BridgedAsset/WormholeModal' +import { ReportPortfolioDataModalProps } from 'uniswap/src/components/reporting/ReportPortfolioDataModal' import { ReportTokenDataModalProps } from 'uniswap/src/components/reporting/ReportTokenDataModal' import { ReportTokenModalProps } from 'uniswap/src/components/reporting/ReportTokenIssueModal' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' -import { NFTItem } from 'uniswap/src/features/nfts/types' import { PasskeyManagementModalState } from 'uniswap/src/features/passkey/PasskeyManagementModal' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { TestnetModeModalState } from 'uniswap/src/features/testnets/TestnetModeModal' @@ -43,14 +43,6 @@ import { SmartWalletEnabledModalState } from 'wallet/src/components/smartWallet/ import { SmartWalletNudgeState } from 'wallet/src/components/smartWallet/modals/SmartWalletNudge' import { ExploreOrderBy } from 'wallet/src/features/wallet/types' -type NFTItemScreenParams = { - owner?: Address - address: string - tokenId: string - isSpam?: boolean - fallbackData?: NFTItem -} - export type ExploreScreenParams = { showFavorites?: boolean orderByMetric?: ExploreOrderBy @@ -75,8 +67,6 @@ export type ExploreStackParamList = { [MobileScreens.ExternalProfile]: { address: string } - [MobileScreens.NFTItem]: NFTItemScreenParams - [MobileScreens.NFTCollection]: { collectionAddress: string } [MobileScreens.TokenDetails]: { currencyId: string } @@ -95,6 +85,7 @@ export type FiatOnRampStackParamList = { } export type SettingsStackParamList = { + [MobileScreens.DebugScreens]: undefined [MobileScreens.Dev]: undefined [MobileScreens.Settings]: undefined [MobileScreens.SettingsCloudBackupPasswordConfirm]: CloudBackupFormParams @@ -106,7 +97,8 @@ export type SettingsStackParamList = { [MobileScreens.SettingsNotifications]: undefined [MobileScreens.SettingsPrivacy]: undefined [MobileScreens.SettingsSmartWallet]: undefined - [MobileScreens.SettingsViewSeedPhrase]: { address: Address; walletNeedsRestore?: boolean } + [MobileScreens.SettingsStorage]: undefined + [MobileScreens.SettingsViewSeedPhrase]: { address?: Address; walletNeedsRestore?: boolean } | undefined [MobileScreens.SettingsWallet]: { address: Address } [MobileScreens.SettingsWalletEdit]: { address: Address } [MobileScreens.SettingsWalletManageConnection]: { address: Address } @@ -162,6 +154,8 @@ export type OnboardingStackParamList = { export type AppStackParamList = { [MobileScreens.Activity]: undefined + [MobileScreens.HashcashBenchmark]: undefined + [MobileScreens.SessionsDebug]: undefined [MobileScreens.Education]: { type: EducationContentType } & OnboardingStackBaseParams @@ -172,8 +166,6 @@ export type AppStackParamList = { [MobileScreens.TokenDetails]: { currencyId: string } - [MobileScreens.NFTItem]: NFTItemScreenParams - [MobileScreens.NFTCollection]: { collectionAddress: string } [MobileScreens.ExternalProfile]: { address: string } @@ -183,6 +175,7 @@ export type AppStackParamList = { [ModalName.Swap]: TransactionState | undefined [ModalName.Explore]: ExploreModalState | undefined [ModalName.NotificationsOSSettings]: undefined + [ModalName.FiatOnRampAction]: { entry: 'onramp' | 'offramp' } [ModalName.FundWallet]: undefined [ModalName.KoreaCexTransferInfoModal]: undefined [ModalName.ExchangeTransferModal]: { initialState: { serviceProvider: FORServiceProvider } } @@ -224,6 +217,7 @@ export type AppStackParamList = { [ModalName.BridgedAsset]: BridgedAssetModalProps [ModalName.Wormhole]: WormholeModalProps [ModalName.ReportTokenIssue]: ReportTokenModalProps + [ModalName.ReportPortfolioData]: ReportPortfolioDataModalProps [ModalName.ReportTokenData]: ReportTokenDataModalProps } diff --git a/apps/mobile/src/app/saga.ts b/apps/mobile/src/app/saga.ts index 90f3709a189..2e71b2b5ff6 100644 --- a/apps/mobile/src/app/saga.ts +++ b/apps/mobile/src/app/saga.ts @@ -15,7 +15,6 @@ import { signWcRequestSaga } from 'src/features/walletConnect/signWcRequestSaga' import { call, fork, join, spawn } from 'typed-redux-saga' import { waitForRehydration } from 'uniswap/src/utils/saga' import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient' -import { deviceLocaleWatcher } from 'wallet/src/features/i18n/deviceLocaleWatcherSaga' import { transactionWatcher } from 'wallet/src/features/transactions/watcher/transactionWatcherSaga' // These sagas are not persisted, so we can run them before rehydration @@ -32,7 +31,6 @@ const sagas = [ signWcRequestSaga, telemetrySaga, walletConnectSaga, - deviceLocaleWatcher, ] export function* rootMobileSaga(): SagaIterator { @@ -60,6 +58,6 @@ export function* rootMobileSaga(): SagaIterator { // Start monitored sagas for (const m of Object.values(monitoredSagas)) { - yield* spawn(m.wrappedSaga) + yield* spawn(m['wrappedSaga']) } } diff --git a/apps/mobile/src/app/schema.ts b/apps/mobile/src/app/schema.ts index 8f8a78eecc1..198d48b96c7 100644 --- a/apps/mobile/src/app/schema.ts +++ b/apps/mobile/src/app/schema.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines */ +/* oxlint-disable max-lines */ import { RankingType } from '@universe/api' import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' import { Language } from 'uniswap/src/features/language/constants' @@ -115,7 +115,7 @@ export const v4Schema = { ...v3Schema, } -// biome-ignore lint/correctness/noUnusedVariables: Destructuring for schema migration +// oxlint-disable-next-line no-unused-vars -- Destructuring for schema migration const { balances, ...restV4Schema } = v4Schema delete restV4Schema.favorites.followedAddresses @@ -207,7 +207,7 @@ export const v29Schema = { ...v28Schema } const v30Schema = { ...v29Schema } -// biome-ignore lint/correctness/noUnusedVariables: Destructuring for schema migration +// oxlint-disable-next-line no-unused-vars -- Destructuring for schema migration const { tokenLists, ...v31SchemaIntermediate } = { ...v30Schema } export const v31Schema = v31SchemaIntermediate @@ -267,7 +267,7 @@ delete v38SchemaIntermediate.experiments export const v39Schema = { ...v38SchemaIntermediate } -// biome-ignore lint/correctness/noUnusedVariables: walletConnect removed in schema migration +// oxlint-disable-next-line no-unused-vars -- walletConnect removed in schema migration const { walletConnect, ...v39SchemaIntermediate } = { ...v39Schema } export const v40Schema = { ...v39SchemaIntermediate } @@ -689,7 +689,7 @@ const v88SchemaIntermediate = { }, userSettings: { ...v87Schema.userSettings, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition hapticsEnabled: v87Schema.appearanceSettings.hapticsEnabled ?? true, }, } @@ -731,8 +731,10 @@ export const v95Schema = { }, } -const v96Schema = v95Schema +export const v96Schema = v95Schema + +const v97Schema = v96Schema // TODO: [MOB-201] use function with typed output when API reducers are removed from rootReducer // export const getSchema = (): RootState => v0Schema -export const getSchema = (): typeof v96Schema => v96Schema +export const getSchema = (): typeof v97Schema => v97Schema diff --git a/apps/mobile/src/app/store.ts b/apps/mobile/src/app/store.ts index ab4541ee681..30398fd7146 100644 --- a/apps/mobile/src/app/store.ts +++ b/apps/mobile/src/app/store.ts @@ -4,7 +4,6 @@ import { persistReducer, persistStore, Storage } from 'redux-persist' import { MOBILE_STATE_VERSION, migrations } from 'src/app/migrations' import { MobileState, mobilePersistedStateList, mobileReducer } from 'src/app/mobileReducer' import { rootMobileSaga } from 'src/app/saga' -import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' import { delegationListenerMiddleware } from 'uniswap/src/features/smartWallet/delegation/slice' import { isNonTestDev } from 'utilities/src/environment/constants' import { createDatadogReduxEnhancer } from 'utilities/src/logger/datadog/Datadog' @@ -53,11 +52,11 @@ if (isNonTestDev) { enhancers.push(reactotron.createEnhancer()) } -const middlewares: Middleware[] = [fiatOnRampAggregatorApi.middleware, delegationListenerMiddleware.middleware] +const middlewares: Middleware[] = [delegationListenerMiddleware.middleware] const setupStore = ( preloadedState?: PreloadedState, - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + // oxlint-disable-next-line typescript/explicit-function-return-type ) => { return createStore({ reducer: persistedReducer, diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx index 9517ca0e609..170bb39e159 100644 --- a/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorer.tsx @@ -14,6 +14,7 @@ import { PriceNumberOfDigits, TokenSpotData, useTokenPriceHistory } from 'src/co import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' import { useIsScreenNavigationReady } from 'src/utils/useIsScreenNavigationReady' import { Flex, SegmentedControl, Text } from 'ui/src' +import { useLayoutAnimationOnChange } from 'ui/src/animations' import GraphCurve from 'ui/src/assets/backgrounds/graph-curve.svg' import { spacing } from 'ui/src/theme' import { isLowVarianceRange } from 'uniswap/src/components/charts/utils' @@ -32,7 +33,7 @@ const LOW_VARIANCE_Y_PADDING = 100 type PriceTextProps = { loading: boolean - relativeChange?: SharedValue + relativeChange?: SharedValue numberOfDigits: PriceNumberOfDigits spotPrice?: SharedValue startingPrice?: number @@ -42,6 +43,7 @@ type PriceTextProps = { const PriceTextSection = memo(function PriceTextSection({ loading, numberOfDigits, + relativeChange, spotPrice, startingPrice, shouldTreatAsStablecoin, @@ -68,6 +70,7 @@ const PriceTextSection = memo(function PriceTextSection({ */} @@ -88,7 +91,7 @@ function TimeRangeTraceWrapper({ ) } -export const PriceExplorer = memo(function _PriceExplorer(): JSX.Element { +export const PriceExplorer = memo(function PriceExplorerInner(): JSX.Element { const { isTestnetModeEnabled } = useEnabledChains() const { chartHeight, chartWidth } = useChartDimensions() @@ -96,10 +99,10 @@ export const PriceExplorer = memo(function _PriceExplorer(): JSX.Element { return } - return + return }) -const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { +const PriceExplorerContent = memo(function PriceExplorerContentInner(): JSX.Element { const { currencyId, tokenColor, navigation } = useTokenDetailsContext() const isScreenNavigationReady = useIsScreenNavigationReady({ navigation }) @@ -144,6 +147,8 @@ const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { return { lastPricePoint: priceHistory.length - 1, convertedPriceHistory: priceHistory } }, [data, conversionRate]) + useLayoutAnimationOnChange(convertedPriceHistory.length) + const convertedSpotValue = useDerivedValue(() => conversionRate * (data?.spot?.value.value ?? 0)) const convertedSpot = useMemo((): TokenSpotData | undefined => { return ( @@ -152,6 +157,7 @@ const PriceExplorerInner = memo(function _PriceExplorerInner(): JSX.Element { value: convertedSpotValue, } ) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, [data]) // Zoom out y-axis for low variance assets diff --git a/apps/mobile/src/components/PriceExplorer/Text.test.tsx b/apps/mobile/src/components/PriceExplorer/Text.test.tsx index c9bb1f43e76..f9f6ced4714 100644 --- a/apps/mobile/src/components/PriceExplorer/Text.test.tsx +++ b/apps/mobile/src/components/PriceExplorer/Text.test.tsx @@ -48,8 +48,8 @@ describe(PriceText, () => { const wholePart = await within(animatedText).findByTestId('wholePart') const decimalPart = await within(animatedText).findByTestId('decimalPart') - expect(wholePart.props.text).toBe(`$${amounts.sm().value}`) - expect(decimalPart.props.text).toBe(`.00`) + expect(wholePart.props['text']).toBe(`$${amounts.sm().value}`) + expect(decimalPart.props['text']).toBe(`.00`) }) }) @@ -88,7 +88,7 @@ describe(RelativeChangeText, () => { const tree = render() const text = await tree.findByTestId('relative-change-text') - expect(text.props.value).toBe(`10.00%`) + expect(text.props['text']).toBe(`10.00%`) }) }) @@ -100,6 +100,7 @@ describe(DatetimeText, () => { }) const tree = render() + expect(tree.toJSON()).toHaveStyle({ opacity: 1 }) expect(tree).toMatchSnapshot() }) @@ -110,6 +111,6 @@ describe(DatetimeText, () => { }) const tree = render() - expect(tree).toMatchSnapshot() + expect(tree.toJSON()).toHaveStyle({ opacity: 0 }) }) }) diff --git a/apps/mobile/src/components/PriceExplorer/Text.tsx b/apps/mobile/src/components/PriceExplorer/Text.tsx index 8cddaffd8e7..b218fd804f8 100644 --- a/apps/mobile/src/components/PriceExplorer/Text.tsx +++ b/apps/mobile/src/components/PriceExplorer/Text.tsx @@ -1,10 +1,18 @@ -import React from 'react' -import { useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' -import { useLineChartDatetime } from 'react-native-wagmi-charts' +import React, { useEffect } from 'react' +import Animated, { + cancelAnimation, + SharedValue, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import { useLineChart, useLineChartDatetime } from 'react-native-wagmi-charts' import { AnimatedDecimalNumber } from 'src/components/PriceExplorer/AnimatedDecimalNumber' import { useLineChartFiatDelta } from 'src/components/PriceExplorer/useFiatDelta' import { useLineChartPrice, useLineChartRelativeChange } from 'src/components/PriceExplorer/usePrice' import { AnimatedText } from 'src/components/text/AnimatedText' +import { numberToPercentWorklet } from 'src/utils/reanimated' import { Flex, Text, useSporeColors } from 'ui/src' import { AnimatedCaretChange } from 'ui/src/components/icons' import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' @@ -42,43 +50,90 @@ export function PriceText({ maxWidth }: { loading: boolean; maxWidth?: number }) export function RelativeChangeText({ loading, + spotRelativeChange, startingPrice, shouldTreatAsStablecoin = false, }: { loading: boolean + /** Price change for selected duration (used when not scrubbing chart) */ + spotRelativeChange?: SharedValue startingPrice?: number shouldTreatAsStablecoin?: boolean }): JSX.Element { const colors = useSporeColors() + const { isActive } = useLineChart() + + // Calculate relative change from chart data (used when scrubbing) + const calculatedRelativeChange = useLineChartRelativeChange() - const relativeChange = useLineChartRelativeChange() const fiatDelta = useLineChartFiatDelta({ startingPrice, shouldTreatAsStablecoin, }) + // Decide which source to use: API's 24hr when idle, chart's when scrubbing + // This ensures the color shows immediately with correct API data + const hasSpotData = !!spotRelativeChange + const shouldUseSpotData = useDerivedValue(() => !isActive.value && hasSpotData) + + const relativeChange = useDerivedValue(() => { + return shouldUseSpotData.value + ? (spotRelativeChange?.value ?? calculatedRelativeChange.value.value) + : calculatedRelativeChange.value.value + }) + + const relativeChangeFormatted = useDerivedValue(() => { + if (shouldUseSpotData.value) { + return spotRelativeChange?.value + ? numberToPercentWorklet(spotRelativeChange.value, { precision: 2, absolute: true }) + : calculatedRelativeChange.formatted.value + } + return calculatedRelativeChange.formatted.value + }) + + // Shared value for fade-in animation; always start hidden since + // the component always mounts with loading=true + const contentOpacity = useSharedValue(0) + + useEffect(() => { + if (!loading) { + contentOpacity.value = withTiming(1, { duration: 200 }) + } else { + cancelAnimation(contentOpacity) + contentOpacity.value = 0 + } + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + }, [loading]) + + const animatedContentStyle = useAnimatedStyle(() => ({ + opacity: contentOpacity.value, + })) + const changeColor = useDerivedValue(() => { - if (relativeChange.value.value === 0) { + // Round the range to 2 decimal places to check if is equal to 0 + const absRelativeChange = Math.round(Math.abs(relativeChange.value) * 100) + if (absRelativeChange === 0) { return colors.neutral3.val } - return relativeChange.value.value > 0 ? colors.statusSuccess.val : colors.statusCritical.val + return relativeChange.value > 0 ? colors.statusSuccess.val : colors.statusCritical.val }) - const styles = useAnimatedStyle(() => ({ - color: changeColor.value, - })) const caretStyle = useAnimatedStyle(() => ({ color: changeColor.value, - transform: [{ rotate: relativeChange.value.value >= 0 ? '180deg' : '0deg' }], + transform: [ + { rotate: relativeChange.value >= 0 ? '180deg' : '0deg' }, + // fix vertical centering + { translateY: relativeChange.value >= 0 ? -1 : 1 }, + ], })) // Combine fiat delta and percentage in a derived value const combinedText = useDerivedValue(() => { const delta = fiatDelta.formatted.value if (delta) { - return `${delta} (${relativeChange.formatted.value})` + return `${delta} (${relativeChangeFormatted.value})` } - return relativeChange.formatted.value + return relativeChangeFormatted.value }) return ( @@ -89,40 +144,30 @@ export function RelativeChangeText({ mt={isAndroid ? '$none' : '$spacing2'} testID={TestID.RelativePriceChange} > - {loading ? ( + {loading && ( // We use `no-shimmer` here to speed up the first render and so that this skeleton renders // at the exact same time as the animated number skeleton. // TODO(WALL-5215): we can remove `no-shimmer` once we have a better Skeleton component. - ) : ( - <> - = 0 ? -1 : 1 }, - ]} - /> - - )} + {/* Must always mount this component to avoid stale values on initial render */} + + + + + + ) } -export function DatetimeText({ loading }: { loading: boolean }): JSX.Element | null { +export function DatetimeText({ loading }: { loading: boolean }): JSX.Element { const locale = useCurrentLocale() // `datetime` when scrubbing the chart const datetime = useLineChartDatetime({ locale }) - if (loading) { - return null - } - return ( - + ) 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 4b14e906407..2c9cdfe65d1 100644 --- a/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap +++ b/apps/mobile/src/components/PriceExplorer/__snapshots__/Text.test.tsx.snap @@ -1,7 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DatetimeText renders loading state 1`] = `null`; - exports[`DatetimeText renders without error 1`] = ` `; @@ -56,7 +78,16 @@ exports[`PriceText renders loading state 1`] = ` `; @@ -95,7 +152,16 @@ exports[`PriceText renders without error 1`] = ` `; @@ -162,7 +285,16 @@ exports[`PriceText renders without error less than a dollar 1`] = ` `; @@ -256,12 +445,7 @@ exports[`RelativeChangeText renders loading state 1`] = ` maxFontSizeMultiplier={1.4} style={ { - "color": { - "dynamic": { - "dark": "transparent", - "light": "transparent", - }, - }, + "color": "transparent", "fontFamily": "Basel Grotesk", "fontSize": 19, "fontWeight": "400", @@ -277,12 +461,7 @@ exports[`RelativeChangeText renders loading state 1`] = ` + + + + + + + + + + `; @@ -313,88 +641,154 @@ exports[`RelativeChangeText renders without error 1`] = ` } testID="relative-price-change" > - - - + + + + + - - - + + `; diff --git a/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx b/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx index ff0024319e8..68098b73578 100644 --- a/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx +++ b/apps/mobile/src/components/PriceExplorer/useFiatDelta.tsx @@ -80,6 +80,7 @@ export function useLineChartFiatDelta({ (index: number) => { scrubbingDeltaSharedValue.value = calculateCurrentDelta(index) }, + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here [calculateCurrentDelta], ) @@ -98,6 +99,7 @@ export function useLineChartFiatDelta({ ) // Create a derived value that decides which delta to show + /* oxlint-disable react/exhaustive-deps -- isActive and scrubbingDeltaSharedValue are Reanimated shared values tracked automatically */ const formatted = useDerivedValue(() => { if (!data || data.length === 0) { return '' @@ -110,7 +112,8 @@ export function useLineChartFiatDelta({ // When not scrubbing, use the pre-calculated last point delta return lastPointDelta - }) + }, [lastPointDelta, data]) + /* oxlint-enable react/exhaustive-deps */ return { formatted } } diff --git a/apps/mobile/src/components/PriceExplorer/usePrice.tsx b/apps/mobile/src/components/PriceExplorer/usePrice.tsx index 0e23b3d110b..5d1385eca35 100644 --- a/apps/mobile/src/components/PriceExplorer/usePrice.tsx +++ b/apps/mobile/src/components/PriceExplorer/usePrice.tsx @@ -68,6 +68,7 @@ export function useLineChartPrice(currentSpot?: SharedValue): ValueAndFo formatted: priceFormatted, shouldAnimate, }), + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here [], ) } diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts index 1c1c44a6e83..1937b53c64f 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts @@ -97,9 +97,9 @@ describe(useTokenPriceHistory, () => { await waitFor(() => { expect(result.current.data?.spot).toEqual({ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition value: expect.objectContaining({ value: market.price?.value }), - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // oxlint-disable-next-line typescript/no-unnecessary-condition relativeChange: expect.objectContaining({ value: market.pricePercentChange?.value }), }) }) @@ -248,9 +248,9 @@ describe(useTokenPriceHistory, () => { const yearTokenProject = createUsdcTokenProjectWithMatchingPriceHistory(yearPriceHistory) const { resolvers } = queryResolvers({ - // eslint-disable-next-line max-params + // oxlint-disable-next-line max-params tokenProjects: (parent, args, context, info) => { - switch (info.variableValues.duration) { + switch (info.variableValues['duration']) { case GraphQLApi.HistoryDuration.Day: return [dayTokenProject] case GraphQLApi.HistoryDuration.Week: diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts index 62775d74aee..2ac29e07c20 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts @@ -4,12 +4,15 @@ import { type Dispatch, type SetStateAction, useCallback, useMemo, useRef, useSt import { type SharedValue, useDerivedValue } from 'react-native-reanimated' import { type TLineChartData } from 'react-native-wagmi-charts' import { PollingInterval } from 'uniswap/src/constants/misc' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { toGraphQLChain } from 'uniswap/src/features/chains/utils' import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { currencyIdToChain } from 'uniswap/src/utils/currencyId' export type TokenSpotData = { value: SharedValue - relativeChange: SharedValue + relativeChange: SharedValue } export type PriceNumberOfDigits = { @@ -20,6 +23,7 @@ export type PriceNumberOfDigits = { /** * @returns Token price history for requested duration */ +// oxlint-disable-next-line complexity -- biome-parity: oxlint is stricter here export function useTokenPriceHistory({ currencyId, initialDuration = GraphQLApi.HistoryDuration.Day, @@ -63,18 +67,48 @@ export function useTokenPriceHistory({ skip, }) + // Data source strategy for multi-chain tokens: + // - Use PER-CHAIN data (token.market) for price and price history to show the correct chain-specific view + // - Fallback to AGGREGATED data (project.markets) when per-chain history is unavailable + // - Continue using aggregated 24hr change for consistency across platforms + // Note: TokenProjectMarket is aggregated across chains, TokenMarket is per-chain const offChainData = priceData?.tokenProjects?.[0]?.markets?.[0] - const onChainData = priceData?.tokenProjects?.[0]?.tokens[0]?.market - const price = offChainData?.price?.value ?? onChainData?.price?.value ?? lastPrice.current + // We need to find the specific token for the chain we're viewing + const currentChain = toGraphQLChain(currencyIdToChain(currencyId) ?? UniverseChainId.Mainnet) + const currentChainToken = priceData?.tokenProjects?.[0]?.tokens.find((token) => token.chain === currentChain) + const onChainData = currentChainToken?.market + + // Use per-chain price to ensure correct price on each chain (e.g., USDC on Ethereum vs Polygon) + const price = onChainData?.price?.value ?? offChainData?.price?.value ?? lastPrice.current lastPrice.current = price - const priceHistory = offChainData?.priceHistory ?? onChainData?.priceHistory + + // Prefer per-chain price history so multi-chain tokens render the correct chart for the selected chain + const priceHistory = onChainData?.priceHistory ?? offChainData?.priceHistory + const pricePercentChange24h = offChainData?.pricePercentChange24h?.value ?? onChainData?.pricePercentChange24h?.value ?? 0 + // Calculate percentage change from price history for the selected duration + const calculatedPriceChange = useMemo(() => { + if (!priceHistory || priceHistory.length === 0) { + return undefined + } + const openPrice = priceHistory[0]?.value + const closePrice = priceHistory[priceHistory.length - 1]?.value + if (openPrice === undefined || closePrice === undefined || openPrice === 0) { + return undefined + } + return ((closePrice - openPrice) / openPrice) * 100 + }, [priceHistory]) + + // Use API's 24hr change for 1d, calculated change for other durations + const priceChange = duration === GraphQLApi.HistoryDuration.Day ? pricePercentChange24h : calculatedPriceChange + const spotValue = useDerivedValue(() => price ?? 0) - const spotRelativeChange = useDerivedValue(() => pricePercentChange24h) + const spotRelativeChange = useDerivedValue(() => priceChange) + // oxlint-disable-next-line react/exhaustive-deps -- ensure spot updates when price changes const spot = useMemo( () => price !== undefined @@ -83,7 +117,8 @@ export function useTokenPriceHistory({ relativeChange: spotRelativeChange, } : undefined, - [price], + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here + [price, priceChange, spotValue, spotRelativeChange], ) const formattedPriceHistory = useMemo(() => { diff --git a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx index a0680858145..df8134dba9e 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx @@ -1,7 +1,7 @@ +import 'react-native-reanimated' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' -import 'react-native-reanimated' import { QRCodeScanner } from 'src/components/QRCodeScanner/QRCodeScanner' import { getSupportedURI, URIType } from 'src/components/Requests/ScanSheet/util' import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src' @@ -92,11 +92,11 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E > {currentScreenState === ScannerModalState.ScanQr ? ( - + ) : ( - + )} - + {currentScreenState === ScannerModalState.ScanQr ? t('qrScanner.recipient.action.show') : t('qrScanner.recipient.action.scan')} diff --git a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx index f629e20bb4c..bbed8ea6984 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx @@ -1,14 +1,14 @@ import React, { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { TextInput } from 'react-native' -import { FadeIn, FadeOut } from 'react-native-reanimated' +import { KeyboardAvoidingView } from 'react-native-keyboard-controller' import { RecipientScanModal } from 'src/components/RecipientSelect/RecipientScanModal' -import { Flex, Loader, Text, TouchableArea } from 'ui/src' -import { Scan } from 'ui/src/components/icons' -import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { Flex, flexStyles, Loader, Text, TouchableArea } from 'ui/src' +import { Scan, UserSearch } from 'ui/src/components/icons' import { UniverseChainId } from 'uniswap/src/features/chains/types' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard' +import { isIOS } from 'utilities/src/platform' import { useFilteredRecipientSections } from 'wallet/src/components/RecipientSearch/hooks' import { RecipientList } from 'wallet/src/components/RecipientSearch/RecipientList' import { RecipientSelectSpeedBumps } from 'wallet/src/components/RecipientSearch/RecipientSelectSpeedBumps' @@ -32,7 +32,7 @@ function QRScannerIconButton({ onPress }: { onPress: () => void }): JSX.Element ) } -function _RecipientSelect({ +function RecipientSelectInner({ onSelectRecipient, onHideRecipientSelector, recipient, @@ -80,37 +80,68 @@ function _RecipientSelect({ return ( <> - - {!renderedInModal && ( - - - {t('send.recipient.header')} - - - )} - } - hideBackButton={hideBackButton} - placeholder={t('send.recipient.input.placeholder')} - value={pattern} - onBack={recipient ? onHideRecipientSelector : undefined} - onChangeText={setPattern} - /> - {loading ? ( - - ) : !sections.length ? ( - - {t('send.recipient.results.empty')} - - {t('send.recipient.results.error')} - - - ) : ( - - )} - + + + {!renderedInModal && ( + + + {t('send.recipient.header')} + + + )} + } + hideBackButton={hideBackButton} + placeholder={t('send.recipient.input.placeholder')} + value={pattern} + onBack={recipient ? onHideRecipientSelector : undefined} + onChangeText={setPattern} + /> + {loading ? ( + + ) : !pattern && sections.length === 0 ? ( + + + + + {t('send.recipientSelect.search.empty')} + + + + + {t('qrScanner.recipient.action.scan')} + + + + ) : !sections.length ? ( + + {t('send.recipient.results.empty')} + + {t('send.recipient.results.error')} + + + ) : ( + + )} + + {showQRScanner && } [0]>> -function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element { - const { fullHeight } = useDeviceDimensions() +function AssociatedAccountsListInner({ accounts }: { accounts: Account[] }): JSX.Element { const addresses = useMemo(() => accounts.map((account) => account.address), [accounts]) const { data, loading } = useAccountListData({ addresses, @@ -36,11 +32,6 @@ function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Ele })) .sort((a, b) => b.balance - a.balance) - const accountsScrollViewHeight = - Math.floor((fullHeight * 0.3) / ADDRESS_ROW_HEIGHT) * ADDRESS_ROW_HEIGHT + - ADDRESS_ROW_HEIGHT / 2 + - spacing.spacing12 // 12 is the ScrollView vertical padding - const renderItem = ({ item, index }: { item: SortedAddressData; index: number }): JSX.Element => { return ( @@ -74,7 +65,7 @@ function _AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Ele ) } -export const AssociatedAccountsList = React.memo(_AssociatedAccountsList) +export const AssociatedAccountsList = React.memo(AssociatedAccountsListInner) function AssociatedAccountRow({ index, diff --git a/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx b/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx index f7678c80e2f..d8a320a012b 100644 --- a/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx +++ b/apps/mobile/src/components/RemoveWallet/RemoveLastMnemonicWalletFooter.tsx @@ -33,6 +33,7 @@ export function RemoveLastMnemonicWalletFooter({ + {isRunning && ( + + )} + + + + + {/* Current Progress */} + + + {/* Results */} + {Object.entries(groupedResults).map(([difficulty, impls]) => ( + + ))} + + {/* Empty State */} + {results.length === 0 && !isRunning && ( + + + Select difficulty and implementation, then run a benchmark. + + + )} + + {/* Operation Log */} + + + + + ) +} diff --git a/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx index e9d87ab7df3..d20af5c62a1 100644 --- a/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen/HomeScreen.tsx @@ -1,15 +1,15 @@ -/* eslint-disable max-lines */ +/* oxlint-disable max-lines */ import { useApolloClient } from '@apollo/client' import { useIsFocused, useScrollToTop } from '@react-navigation/native' import { SharedQueryClient } from '@universe/api' import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getIsNotificationServiceLocalOverrideEnabled } from '@universe/notifications' +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Freeze } from 'react-freeze' import { useTranslation } from 'react-i18next' import { StyleProp, View, ViewProps, ViewStyle } from 'react-native' import Animated, { FadeIn, interpolateColor, useAnimatedStyle, useDerivedValue } from 'react-native-reanimated' import { SceneRendererProps, TabBar } from 'react-native-tab-view' -import { Video } from 'react-native-video' import { useDispatch, useSelector } from 'react-redux' import { useHomeScreenCustomAndroidBackButton } from 'src/app/navigation/hooks' import { NavBar, SWAP_BUTTON_HEIGHT } from 'src/app/navigation/NavBar' @@ -20,6 +20,7 @@ import { ActivityContent } from 'src/components/activity/ActivityContent' import { HomeExploreTab } from 'src/components/home/HomeExploreTab' import { OnboardingIntroCardStack } from 'src/components/home/introCards/OnboardingIntroCardStack' import { NftsTab } from 'src/components/home/NftsTab' +import { PortfolioOverview } from 'src/components/home/PortfolioChart/PortfolioOverview' import { TokensTab } from 'src/components/home/TokensTab' import { Screen } from 'src/components/layout/Screen' import { @@ -39,38 +40,38 @@ import { useBiometricPrompt } from 'src/features/biometricsSettings/hooks' import { selectSomeModalOpen } from 'src/features/modals/selectSomeModalOpen' import { useHideSplashScreen } from 'src/features/splashScreen/useHideSplashScreen' import { useWalletRestore } from 'src/features/wallet/useWalletRestore' +import { MobileNotificationServiceManager } from 'src/notification-service/MobileNotificationServiceManager' import { HomeScreenQuickActions } from 'src/screens/HomeScreen/HomeScreenQuickActions' import { HomeScreenTabIndex } from 'src/screens/HomeScreen/HomeScreenTabIndex' +import { SmartWalletModals } from 'src/screens/HomeScreen/SmartWalletModals' import { useHomeScreenState } from 'src/screens/HomeScreen/useHomeScreenState' import { useHomeScrollRefs } from 'src/screens/HomeScreen/useHomeScrollRefs' -import { useOpenBackupReminderModal } from 'src/utils/useOpenBackupReminderModal' -import { Flex, Image, Text, TouchableArea, useMedia, useSporeColors } from 'ui/src' -import { SMART_WALLET_UPGRADE_FALLBACK, SMART_WALLET_UPGRADE_VIDEO } from 'ui/src/assets' +import { Flex, Text, TouchableArea, useMedia, useSporeColors } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { spacing } from 'ui/src/theme' +import { buildWrappedUrl } from 'uniswap/src/components/banners/shared/utils' +import { UniswapWrapped2025Banner } from 'uniswap/src/components/banners/UniswapWrapped2025Banner/UniswapWrapped2025Banner' import { NFTS_TAB_DATA_DEPENDENCIES } from 'uniswap/src/components/nfts/constants' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' import { getPortfolioQuery } from 'uniswap/src/data/rest/getPortfolio' import { getListTransactionsQuery } from 'uniswap/src/data/rest/listTransactions' import { AccountType } from 'uniswap/src/features/accounts/types' +import { selectHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/selectors' +import { setHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/slice' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { useSelectAddressHasNotifications } from 'uniswap/src/features/notifications/slice/hooks' import { setNotificationStatus } from 'uniswap/src/features/notifications/slice/slice' -import { PortfolioBalance } from 'uniswap/src/features/portfolio/PortfolioBalance/PortfolioBalance' import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' import { useEvent } from 'utilities/src/react/hooks' -import { SmartWalletCreatedModal } from 'wallet/src/components/smartWallet/modals/SmartWalletCreatedModal' -import { SmartWalletUpgradeModals } from 'wallet/src/components/smartWallet/modals/SmartWalletUpgradeModal' import { useOpenSmartWalletNudgeOnCompletedSwap } from 'wallet/src/components/smartWallet/smartAccounts/hooks' -import { selectHasSeenCreatedSmartWalletModal } from 'wallet/src/features/behaviorHistory/selectors' -import { - setHasSeenSmartWalletCreatedWalletModal, - setIncrementNumPostSwapNudge, -} from 'wallet/src/features/behaviorHistory/slice' -import { useAccountCountChanged, useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' +import { setIncrementNumPostSwapNudge } from 'wallet/src/features/behaviorHistory/slice' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' import { setSmartWalletConsent } from 'wallet/src/features/wallet/slice' type HomeRoute = { @@ -86,7 +87,20 @@ const CONTENT_HEADER_HEIGHT_ESTIMATE = 270 */ export function WrappedHomeScreen(props: AppStackScreenProp): JSX.Element { const activeAccount = useActiveAccountWithThrow() - return + + const [isLayoutReady, setIsLayoutReady] = useState(false) + + return ( + <> + + + + ) } /** @@ -94,10 +108,18 @@ export function WrappedHomeScreen(props: AppStackScreenProp) * Manages TokensTabs and NftsTab scroll offsets when header is collapsed * Borrowed from: https://stormotion.io/blog/how-to-create-collapsing-tab-header-using-react-native/ */ -function HomeScreen(props?: AppStackScreenProp): JSX.Element { +function HomeScreen({ + isLayoutReady, + setIsLayoutReady, + ...props +}: AppStackScreenProp & { + isLayoutReady: boolean + setIsLayoutReady: Dispatch> +}): JSX.Element { const activeAccount = useActiveAccountWithThrow() const { t } = useTranslation() const colors = useSporeColors() + const darkColors = useSporeColors('dark') const media = useMedia() const insets = useAppInsets() const dimensions = useDeviceDimensions() @@ -106,20 +128,28 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const isModalOpen = useSelector(selectSomeModalOpen) const isHomeScreenBlur = !isFocused || isModalOpen const hideSplashScreen = useHideSplashScreen() - const isSmartWalletEnabled = useFeatureFlag(FeatureFlags.SmartWallet) - const SmartWalletDisableVideo = useFeatureFlag(FeatureFlags.SmartWalletDisableVideo) const { requiredForTransactions: requiresBiometrics } = useBiometricAppSettings() const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + const isPnLEnabled = useFeatureFlag(FeatureFlags.ProfitLoss) + const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025) + const isNotificationServiceEnabledFlag = useFeatureFlag(FeatureFlags.NotificationService) + const isNotificationServiceEnabled = + getIsNotificationServiceLocalOverrideEnabled() || isNotificationServiceEnabledFlag + + const hasDismissedWrappedBanner = useSelector(selectHasDismissedUniswapWrapped2025Banner) + const shouldShowWrappedBanner = isWrappedBannerEnabled && !hasDismissedWrappedBanner const { showEmptyWalletState, isTabsDataLoaded } = useHomeScreenState() + const [hasIntroCards, setHasIntroCards] = useState(false) + const { chains } = useEnabledChains() // opens the wallet restore modal if recovery phrase is missing after the app is opened useWalletRestore({ openModalImmediately: true }) const { trigger } = useBiometricPrompt() - const [routeTabIndex, setRouteTabIndex] = useState(props?.route.params?.tab ?? HomeScreenTabIndex.Tokens) + const [routeTabIndex, setRouteTabIndex] = useState(props.route.params?.tab ?? HomeScreenTabIndex.Tokens) // Ensures that tabIndex has the proper value between the empty state and non-empty state const tabIndex = showEmptyWalletState ? HomeScreenTabIndex.Tokens : routeTabIndex @@ -144,7 +174,9 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const tabs: Array = [ { key: SectionName.HomeTokensTab, title: tokensTitle }, { key: SectionName.HomeNFTsTab, title: nftsTitle }, - ...(!isBottomTabsEnabled ? [{ key: SectionName.HomeActivityTab, title: activityTitle }] : []), + ...(!isBottomTabsEnabled + ? [{ key: SectionName.HomeActivityTab, title: activityTitle, enableNotificationBadge: true }] + : []), ] return tabs @@ -152,17 +184,15 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element useEffect( function syncTabIndex() { - const newTabIndex = props?.route.params?.tab + const newTabIndex = props.route.params?.tab if (newTabIndex === undefined) { return } setRouteTabIndex(newTabIndex) }, - [props?.route.params?.tab], + [props.route.params?.tab], ) - const [isLayoutReady, setIsLayoutReady] = useState(false) - const [headerHeight, setHeaderHeight] = useState(CONTENT_HEADER_HEIGHT_ESTIMATE) const headerConfig = useMemo( () => ({ @@ -174,10 +204,13 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element const { heightCollapsed, heightExpanded } = headerConfig const headerHeightDiff = heightExpanded - heightCollapsed - const handleHeaderLayout = useCallback>((event) => { - setHeaderHeight(event.nativeEvent.layout.height) - setIsLayoutReady(true) - }, []) + const handleHeaderLayout = useCallback>( + (event) => { + setHeaderHeight(event.nativeEvent.layout.height) + setIsLayoutReady(true) + }, + [setIsLayoutReady], + ) const { tokensTabScrollValue, @@ -238,7 +271,7 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element }, [dispatch, activeAccount.address, tabIndex, hasNotifications, isBottomTabsEnabled]) // If accounts are switched, we want to scroll to top and show full header - // biome-ignore lint/correctness/useExhaustiveDependencies: we want to trigger this effect also when activeAccount changes + // oxlint-disable-next-line react/exhaustive-deps -- we want to trigger this effect also when activeAccount changes useEffect(() => { resetScrollState() }, [activeAccount, resetScrollState]) @@ -279,27 +312,71 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element // Hide actions when active account isn't a signer account. const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic - // This hooks handles the logic for when to open the BackupReminderModal - useOpenBackupReminderModal(activeAccount) + // Sets isLayoutReady to false when switching a wallet + useEffect(() => { + return () => setIsLayoutReady(false) + }, [setIsLayoutReady]) const viewOnlyLabel = t('home.warning.viewOnly') + const handleDismissWrappedBanner = useCallback(() => { + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + }, [dispatch]) + + const handlePressWrappedBanner = useCallback(async () => { + try { + const url = buildWrappedUrl(UNISWAP_WEB_URL, activeAccount.address) + await openUri({ uri: url, openExternalBrowser: true }) + dispatch(setHasDismissedUniswapWrapped2025Banner(true)) + } catch (error) { + logger.error(error, { tags: { file: 'HomeScreen', function: 'handlePressWrappedBanner' } }) + } + }, [activeAccount.address, dispatch]) + + const handleIntroCardsChange = useCallback((hasCards: boolean) => { + setHasIntroCards(hasCards) + }, []) + const promoBanner = useMemo( - () => , - [showEmptyWalletState, isTabsDataLoaded], + () => + isNotificationServiceEnabled ? ( + + ) : ( + + ), + [showEmptyWalletState, isTabsDataLoaded, isNotificationServiceEnabled, handleIntroCardsChange], ) const contentHeader = useMemo(() => { return ( + {shouldShowWrappedBanner && ( + + + + + )} - - - + {isSignerAccount ? ( ) : ( @@ -315,8 +392,14 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element ) }, [ + hasIntroCards, showEmptyWalletState, isBottomTabsEnabled, + isPnLEnabled, + chains, + shouldShowWrappedBanner, + handleDismissWrappedBanner, + handlePressWrappedBanner, activeAccount.address, isSignerAccount, onPressViewOnlyLabel, @@ -324,34 +407,6 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element promoBanner, ]) - const [hasVideoError, setVideoHasError] = useState(false) - - const MemoizedVideo = useMemo(() => { - if (hasVideoError) { - return ( - - - - ) - } - - return ( - - - ) - }, [hasVideoError]) - const paddingTop = headerHeight + TAB_BAR_HEIGHT + (showEmptyWalletState ? 0 : TAB_STYLES.tabListInner.paddingTop) const paddingBottom = insets.bottom + SWAP_BUTTON_HEIGHT + TAB_STYLES.tabListInner.paddingBottom + spacing.spacing12 @@ -393,11 +448,9 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element ) const statusBarStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor( - currentScrollValue.value, - [0, headerHeightDiff], - [colors.surface1.val, colors.surface1.val], - ), + backgroundColor: shouldShowWrappedBanner + ? darkColors.surface1.val + : interpolateColor(currentScrollValue.value, [0, headerHeightDiff], [colors.surface1.val, colors.surface1.val]), })) const apolloClient = useApolloClient() @@ -573,45 +626,6 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element ], ) - const handleSmartWalletEnable = useCallback( - async (onComplete?: () => void): Promise => { - const successAction = (): void => { - dispatch(setSmartWalletConsent({ address: activeAccount.address, smartWalletConsent: true })) - onComplete?.() - navigate(ModalName.SmartWalletEnabledModal, { - showReconnectDappPrompt: false, - }) - } - - if (requiresBiometrics) { - await trigger({ successCallback: successAction }) - } else { - successAction() - } - }, - [dispatch, activeAccount.address, requiresBiometrics, trigger], - ) - - const hasSeenCreatedSmartWalletModal = useSelector(selectHasSeenCreatedSmartWalletModal) - const [shouldShowCreatedModal, setShouldShowCreatedModal] = useState(false) - - // Setup listener for account creation events to show the SmartWalletCreatedModal - useAccountCountChanged( - useEvent(() => { - if (hasSeenCreatedSmartWalletModal) { - return - } - setShouldShowCreatedModal(true) - }), - ) - - const shouldOpenSmartWalletCreatedModal = - isSmartWalletEnabled && - isTabsDataLoaded && - isLayoutReady && - shouldShowCreatedModal && - !hasSeenCreatedSmartWalletModal - useOpenSmartWalletNudgeOnCompletedSwap( useEvent(() => { if (!activeAccount.address || activeAccount.type !== AccountType.SignerMnemonic) { @@ -641,12 +655,14 @@ function HomeScreen(props?: AppStackScreenProp): JSX.Element return ( - + {contentHeader} {isTabsDataLoaded && isLayoutReady && ( ): JSX.Element width="100%" zIndex="$sticky" /> - - {isSmartWalletEnabled && ( - - )} - - { - setShouldShowCreatedModal(false) - dispatch(setHasSeenSmartWalletCreatedWalletModal()) - }} - /> ) } diff --git a/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx b/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx index 44b5ac900e0..d41e8c308c1 100644 --- a/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx +++ b/apps/mobile/src/screens/HomeScreen/HomeScreenQuickActions.tsx @@ -1,3 +1,4 @@ +// oxlint-disable typescript/no-duplicate-type-constituents import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -7,13 +8,14 @@ import { navigate } from 'src/app/navigation/rootNavigation' import { useOpenReceiveModal } from 'src/features/modals/hooks/useOpenReceiveModal' import { openModal } from 'src/features/modals/modalSlice' import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' -import { ArrowDownCircle, Bank, SendAction, SwapDotted } from 'ui/src/components/icons' +import { ArrowDownCircle, Bank, MinusCircle, PlusCircle, SendAction, SwapDotted } from 'ui/src/components/icons' import { iconSizes, spacing } from 'ui/src/theme' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { useHighestBalanceNativeCurrencyId } from 'uniswap/src/features/dataApi/balances/balances' import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' import { ElementName, MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' import { Trace } from 'uniswap/src/features/telemetry/Trace' +import { useIsPortfolioZero } from 'uniswap/src/features/transactions/swap/components/SwapFormButton/hooks/useIsPortfolioZero' import { selectFilteredChainIds } from 'uniswap/src/features/transactions/swap/state/selectors' import { prepareSwapFormState } from 'uniswap/src/features/transactions/types/transactionState' import { CurrencyField } from 'uniswap/src/types/currency' @@ -21,7 +23,13 @@ import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hoo const MIN_BUTTON_WIDTH = 102 -type IconComponent = typeof SwapDotted | typeof Bank | typeof SendAction | typeof ArrowDownCircle +type IconComponent = + | typeof SwapDotted + | typeof Bank + | typeof PlusCircle + | typeof MinusCircle + | typeof SendAction + | typeof ArrowDownCircle type ActionItem = { Icon: IconComponent label: string @@ -52,6 +60,8 @@ export function HomeScreenQuickActions(): JSX.Element { const { isTestnetModeEnabled, defaultChainId } = useEnabledChains() const disableForKorea = useFeatureFlag(FeatureFlags.DisableFiatOnRampKorea) const isBottomTabsEnabled = useFeatureFlag(FeatureFlags.BottomTabs) + const multichainTokenUxEnabled = useFeatureFlag(FeatureFlags.MultichainTokenUx) + const isPortfolioZero = useIsPortfolioZero() const activeAccountAddress = useActiveAccountAddressWithThrow() const persistedFilteredChainIds = useSelector(selectFilteredChainIds) @@ -84,29 +94,44 @@ export function HomeScreenQuickActions(): JSX.Element { await triggerHaptics() }, [openReceiveModal, triggerHaptics]) - const onPressBuy = useCallback(async (): Promise => { - await triggerHaptics() - if (isTestnetModeEnabled) { - navigate(ModalName.TestnetMode, { - unsupported: true, - descriptionCopy: t('tdp.noTestnetSupportDescription'), - }) - return - } - disableForKorea - ? navigate(ModalName.KoreaCexTransferInfoModal) - : dispatch( - openModal({ - name: ModalName.FiatOnRampAggregator, - }), - ) - }, [triggerHaptics, isTestnetModeEnabled, disableForKorea, dispatch, t]) + const onPressFORAction = useCallback( + async (entry: 'onramp' | 'offramp'): Promise => { + await triggerHaptics() + if (isTestnetModeEnabled) { + navigate(ModalName.TestnetMode, { + unsupported: true, + descriptionCopy: t('tdp.noTestnetSupportDescription'), + }) + return + } + // When multichain UX is enabled, show the interstitial sheet unless + // the user has zero balance (in which case go straight to FOR). + // Korea check is handled inside the modal and below for the direct path. + if (multichainTokenUxEnabled && !isPortfolioZero) { + navigate(ModalName.FiatOnRampAction, { entry }) + return + } + if (disableForKorea) { + navigate(ModalName.KoreaCexTransferInfoModal) + return + } + dispatch( + openModal({ + name: ModalName.FiatOnRampAggregator, + ...(entry === 'offramp' && { initialState: { isOfframp: true } }), + }), + ) + }, + [triggerHaptics, isTestnetModeEnabled, disableForKorea, multichainTokenUxEnabled, isPortfolioZero, dispatch, t], + ) // PR #4621 Necessary to declare these as direct dependencies due to race // condition with initializing react-i18next and useMemo const forLabel = t('home.label.for') const sendLabel = t('home.label.send') const receiveLabel = t('home.label.receive') + const buyLabel = t('common.buy.label') + const sellLabel = t('common.sell.label') const actions = useMemo( () => [ ...(isBottomTabsEnabled @@ -120,11 +145,11 @@ export function HomeScreenQuickActions(): JSX.Element { ] : []), { - Icon: Bank, + Icon: multichainTokenUxEnabled ? PlusCircle : Bank, eventName: MobileEventName.FiatOnRampQuickActionButtonPressed, - label: forLabel, + label: multichainTokenUxEnabled ? buyLabel : forLabel, name: ElementName.Buy, - onPress: onPressBuy, + onPress: () => onPressFORAction('onramp'), }, { Icon: SendAction, @@ -138,11 +163,34 @@ export function HomeScreenQuickActions(): JSX.Element { name: ElementName.Receive, onPress: onPressReceive, }, + ...(multichainTokenUxEnabled + ? [ + { + Icon: MinusCircle, + eventName: MobileEventName.FiatOnRampQuickActionButtonPressed, + label: sellLabel, + name: ElementName.Sell, + onPress: () => onPressFORAction('offramp'), + }, + ] + : []), + ], + [ + isBottomTabsEnabled, + onPressSwap, + multichainTokenUxEnabled, + buyLabel, + forLabel, + onPressFORAction, + sendLabel, + onPressSend, + receiveLabel, + onPressReceive, + sellLabel, ], - [isBottomTabsEnabled, onPressSwap, forLabel, onPressBuy, sendLabel, onPressSend, receiveLabel, onPressReceive], ) - // biome-ignore lint/correctness/useExhaustiveDependencies: +activeScale + // oxlint-disable-next-line react/exhaustive-deps -- +activeScale const renderItem = useCallback( ({ item: { eventName, name, label, Icon, onPress } }: ListRenderItemInfo) => ( @@ -151,6 +199,7 @@ export function HomeScreenQuickActions(): JSX.Element { mr="$spacing8" scaleTo={activeScale} dd-action-name={name} + testID={name} onPress={onPress} > {actions.map(({ eventName, name, label, Icon, onPress }) => ( - + { + if (hasSeenCreatedSmartWalletModal) { + return + } + setShouldShowCreatedModal(true) + }), + ) + + const MemoizedVideo = useMemo(() => { + if (hasVideoError) { + return ( + + + + ) + } + + return ( + + + ) + }, [hasVideoError]) + + const handleSmartWalletEnable = useCallback( + async (onComplete?: () => void): Promise => { + const successAction = (): void => { + dispatch(setSmartWalletConsent({ address: activeAccount.address, smartWalletConsent: true })) + onComplete?.() + navigate(ModalName.SmartWalletEnabledModal, { + showReconnectDappPrompt: false, + }) + } + + if (requiresBiometrics) { + await trigger({ successCallback: successAction }) + } else { + successAction() + } + }, + [dispatch, activeAccount.address, requiresBiometrics, trigger], + ) + + return ( + <> + {isSmartWalletEnabled && ( + + )} + + { + setShouldShowCreatedModal(false) + dispatch(setHasSeenSmartWalletCreatedWalletModal()) + }} + /> + + ) +} diff --git a/apps/mobile/src/screens/HomeScreen/useHomeScreenState.tsx b/apps/mobile/src/screens/HomeScreen/useHomeScreenState.tsx index ce81a1cdf43..40001820383 100644 --- a/apps/mobile/src/screens/HomeScreen/useHomeScreenState.tsx +++ b/apps/mobile/src/screens/HomeScreen/useHomeScreenState.tsx @@ -12,11 +12,11 @@ import { setHasBalanceOrActivity } from 'wallet/src/features/wallet/slice' import { WalletState } from 'wallet/src/state/walletReducer' /** - * This is the interval at which the NFTs tab will poll for new NFTs - * when the wallet is empty. Both activity and balances are updated - * in other parts of the app so we don't need to poll. + * Poll interval for checking wallet state (balances, NFTs) when the wallet + * is empty. Used to detect when the user first receives tokens so the + * empty-wallet UI can transition to the funded-wallet UI. */ -const EMPTY_WALLET_NFT_POLL_INTERVAL = 15 * ONE_SECOND_MS +const EMPTY_WALLET_POLL_INTERVAL = 15 * ONE_SECOND_MS /** * Helper hook used to determine the state of the home screen such as whether the wallet should fetch @@ -47,6 +47,7 @@ export function useHomeScreenState(): { const { data: balancesById, loading: areBalancesLoading } = usePortfolioBalances({ evmAddress: address, + pollInterval: !hasUsedWalletFromCache ? EMPTY_WALLET_POLL_INTERVAL : undefined, skip: hasUsedWalletFromCache, }) const { data: nftData, loading: areNFTsLoading } = GraphQLApi.useNftsTabQuery({ @@ -56,7 +57,7 @@ export function useHomeScreenState(): { filter: { filterSpam: true }, chains: gqlChains, }, - pollInterval: EMPTY_WALLET_NFT_POLL_INTERVAL, + pollInterval: EMPTY_WALLET_POLL_INTERVAL, notifyOnNetworkStatusChange: true, // Used to trigger network state / loading on refetch or fetchMore errorPolicy: 'all', // Suppress non-null image.url fields from backend skip: hasUsedWalletFromCache, @@ -91,7 +92,7 @@ export function useHomeScreenState(): { } return { - showEmptyWalletState: !hasUsedWallet, + showEmptyWalletState: !hasUsedWallet && isTabsDataLoaded, isTabsDataLoaded: dataLoadedRef.current || isTabsDataLoaded, } } diff --git a/apps/mobile/src/screens/HomeScreen/useHomeScrollRefs.ts b/apps/mobile/src/screens/HomeScreen/useHomeScrollRefs.ts index 23e53520d2d..746d3e45a05 100644 --- a/apps/mobile/src/screens/HomeScreen/useHomeScrollRefs.ts +++ b/apps/mobile/src/screens/HomeScreen/useHomeScrollRefs.ts @@ -4,9 +4,9 @@ import { FlatList } from 'react-native' import { useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated' import { TokenBalanceListRow } from 'uniswap/src/features/portfolio/types' -// biome-ignore lint/suspicious/noExplicitAny: Generic type needed for scroll ref +// oxlint-disable-next-line typescript/no-explicit-any -- Generic type needed for scroll ref type FlashListAnyType = FlashList -// biome-ignore lint/suspicious/noExplicitAny: Generic type needed for scroll ref +// oxlint-disable-next-line typescript/no-explicit-any -- Generic type needed for scroll ref type FlatListAnyType = FlatList type ScrollRefType = FlashListAnyType | FlatListAnyType diff --git a/apps/mobile/src/screens/Import/ImportMethodScreen.tsx b/apps/mobile/src/screens/Import/ImportMethodScreen.tsx index fe00d212b5d..f99ac3ad5f4 100644 --- a/apps/mobile/src/screens/Import/ImportMethodScreen.tsx +++ b/apps/mobile/src/screens/Import/ImportMethodScreen.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { navigate } from 'src/app/navigation/rootNavigation' import { OnboardingStackParamList } from 'src/app/navigation/types' import { checkCloudBackupOrShowAlert } from 'src/components/mnemonic/cloudImportUtils' +import { useRegionalizedLineHeight } from 'src/components/text/useRegionalizedLineHeight' import { OnboardingScreen } from 'src/features/onboarding/OnboardingScreen' import { OptionCard } from 'src/features/onboarding/OptionCard' import { @@ -109,6 +110,8 @@ export function ImportMethodScreen({ navigation, route: { params } }: Props): JS importOptions = importOptions.filter((option) => option.name !== ElementName.OnboardingPasskey) } + const regionalizedLineHeight = useRegionalizedLineHeight() + return ( => handleOnPress(OnboardingScreens.WatchWallet, ImportType.Watch)} > {t('account.wallet.button.watch')} diff --git a/apps/mobile/src/screens/Import/OnDeviceRecoveryWalletCard.tsx b/apps/mobile/src/screens/Import/OnDeviceRecoveryWalletCard.tsx index 2d50f2708f0..f4450ec858f 100644 --- a/apps/mobile/src/screens/Import/OnDeviceRecoveryWalletCard.tsx +++ b/apps/mobile/src/screens/Import/OnDeviceRecoveryWalletCard.tsx @@ -41,11 +41,12 @@ export function OnDeviceRecoveryWalletCard({ const firstWalletInfo = targetWalletInfos[0] const remainingWalletCount = targetWalletInfos.length - 1 - // biome-ignore lint/correctness/useExhaustiveDependencies: we want to recalculate this only when loading, screenLoading changes + // oxlint-disable-next-line react/exhaustive-deps -- we want to recalculate this only when loading, screenLoading changes useEffect(() => { if (!loading && screenLoading) { onLoadComplete(significantRecoveryWalletInfos.length) } + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, [loading, screenLoading]) if (screenLoading || !firstWalletInfo) { diff --git a/apps/mobile/src/screens/Import/PasskeyImportScreen.tsx b/apps/mobile/src/screens/Import/PasskeyImportScreen.tsx index 00e845449fe..77d967ddf98 100644 --- a/apps/mobile/src/screens/Import/PasskeyImportScreen.tsx +++ b/apps/mobile/src/screens/Import/PasskeyImportScreen.tsx @@ -30,7 +30,7 @@ export function PasskeyImportScreen({ navigation, route: { params } }: Props): J }) }) - // biome-ignore lint/correctness/useExhaustiveDependencies: We want to import the mnemonic only once + // oxlint-disable-next-line react/exhaustive-deps -- We want to import the mnemonic only once useEffect(() => { const importAndGenerateAccount = async (): Promise => { const mnemonic = await fetchSeedPhrase(params.passkeyCredential) @@ -54,6 +54,7 @@ export function PasskeyImportScreen({ navigation, route: { params } }: Props): J navigation.goBack() navigate(ModalName.PasskeysHelp) }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, []) return ( diff --git a/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx b/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx index 43f0df98856..6fb1cd8960f 100644 --- a/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx +++ b/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx @@ -83,7 +83,7 @@ const BackupListItem = ({ {displayName?.name} {isUnitag && ( - + )} @@ -93,7 +93,7 @@ const BackupListItem = ({ - + diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/SeedPhraseInput.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/SeedPhraseInput.tsx index 5de1cac2780..89bec180dac 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/SeedPhraseInput.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/SeedPhraseInput.tsx @@ -24,7 +24,7 @@ type SeedPhraseInputProps = NativeSeedPhraseInputProps & { navigation: NativeStackNavigationProp } -export const SeedPhraseInput = forwardRef(function _SeedPhraseInput( +export const SeedPhraseInput = forwardRef(function SeedPhraseInputInner( { navigation, ...rest }, ref, ) { diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/types.ts b/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/types.ts index f991098ce45..0cb1c995785 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/types.ts +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreen/SeedPhraseInput/types.ts @@ -1,5 +1,4 @@ import { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native' - import { TestIDType } from 'uniswap/src/test/fixtures/testIDs' export enum StringKey { diff --git a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx index 9a8fdff2d59..8b1ef0267d8 100644 --- a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx +++ b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx @@ -163,7 +163,7 @@ export function WatchWalletScreen({ navigation, route: { params } }: Props): JSX setValue(text?.trim()) } - // biome-ignore lint/correctness/useExhaustiveDependencies: Only want to reset timer on value change + // oxlint-disable-next-line react/exhaustive-deps -- Only want to reset timer on value change useEffect(() => { const delayFn = setTimeout(() => { setShowLiveCheck(true) diff --git a/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupScreen.test.tsx.snap b/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupScreen.test.tsx.snap index 2a3495b58f8..f9e81f5f873 100644 --- a/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupScreen.test.tsx.snap +++ b/apps/mobile/src/screens/Import/__snapshots__/RestoreCloudBackupScreen.test.tsx.snap @@ -341,45 +341,17 @@ exports[`RestoreCloudBackupScreen renders correctly 1`] = ` "borderTopRightRadius": 999999, "borderTopWidth": 0, "flexDirection": "column", - "height": 36, "position": "relative", - "width": 36, } } testID="account-icon" > unitagState.data?.username).join('') const unitagLoading = unitagStates.some((unitagState) => unitagState.isLoading) - // biome-ignore lint/correctness/useExhaustiveDependencies: we want to recalculate this when unitagsCombined or balancesLoading changes + // oxlint-disable-next-line react/exhaustive-deps -- we want to recalculate this when unitagsCombined or balancesLoading changes const recoveryWalletInfos = useMemo((): RecoveryWalletInfo[] => { return addressesWithIndex.map((addressWithIndex, index): RecoveryWalletInfo => { const { address, derivationIndex } = addressWithIndex @@ -140,6 +140,7 @@ export function useOnDeviceRecoveryData(mnemonicId: string | undefined): { unitag: unitagStates[derivationIndex]?.data?.username, } }) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, [addressesWithIndex, balances, balancesLoading, ensMap, unitagsCombined]) const significantRecoveryWalletInfos = useMemo( diff --git a/apps/mobile/src/screens/NFTCollectionScreen.tsx b/apps/mobile/src/screens/NFTCollectionScreen.tsx deleted file mode 100644 index 7932839ac0c..00000000000 --- a/apps/mobile/src/screens/NFTCollectionScreen.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { NetworkStatus } from '@apollo/client' -import { useScrollToTop } from '@react-navigation/native' -import { GraphQLApi, isError } from '@universe/api' -import React, { type ReactElement, useCallback, useMemo, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { type ListRenderItemInfo } from 'react-native' -import { useAnimatedScrollHandler, useSharedValue, withTiming } from 'react-native-reanimated' -import { type AppStackScreenProp, useAppStackNavigation } from 'src/app/navigation/types' -import { Screen } from 'src/components/layout/Screen' -import { ScrollHeader } from 'src/components/layout/screens/ScrollHeader' -import { Loader } from 'src/components/loading/loaders' -import { ListPriceBadge } from 'src/features/nfts/collection/ListPriceCard' -import { NFTCollectionContextMenu } from 'src/features/nfts/collection/NFTCollectionContextMenu' -import { NFT_BANNER_HEIGHT, NFTCollectionHeader } from 'src/features/nfts/collection/NFTCollectionHeader' -import { ExploreModalAwareView } from 'src/screens/ModalAwareView' -import { Flex, Text, TouchableArea } from 'ui/src' -import { AnimatedBottomSheetFlashList, AnimatedFlashList } from 'ui/src/components/AnimatedFlashList/AnimatedFlashList' -import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' -import { iconSizes, spacing } from 'ui/src/theme' -import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { NFTViewer } from 'uniswap/src/components/nfts/images/NFTViewer' -import { type NFTItem } from 'uniswap/src/features/nfts/types' -import { getNFTAssetKey } from 'uniswap/src/features/nfts/utils' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' -import { MobileScreens } from 'uniswap/src/types/screens/mobile' -import { isIOS } from 'utilities/src/platform' - -const PREFETCH_ITEMS_THRESHOLD = 0.5 -const ASSET_FETCH_PAGE_SIZE = 30 -const ESTIMATED_ITEM_SIZE = 104 // heuristic provided by FlashList - -const LOADING_ITEM = 'loading' -const LOADING_BUFFER_AMOUNT = 9 -const LOADING_ITEMS_ARRAY: NFTItem[] = Array(LOADING_BUFFER_AMOUNT).fill(LOADING_ITEM) - -const keyExtractor = (item: NFTItem | string, index: number): string => - typeof item === 'string' ? `${LOADING_ITEM}-${index}` : getNFTAssetKey(item.contractAddress ?? '', item.tokenId ?? '') - -function gqlNFTAssetToNFTItem(data: GraphQLApi.NftCollectionScreenQuery | undefined): NFTItem[] | undefined { - const items = data?.nftAssets?.edges.flatMap((item) => item.node) - if (!items) { - return undefined - } - - return items.map((item): NFTItem => { - return { - name: item.name ?? undefined, - contractAddress: item.nftContract?.address ?? undefined, - tokenId: item.tokenId, - imageUrl: item.image?.url ?? undefined, - collectionName: item.collection?.name ?? undefined, - ownerAddress: item.ownerAddress ?? undefined, - imageDimensions: - item.image?.dimensions?.height && item.image.dimensions.width - ? { width: item.image.dimensions.width, height: item.image.dimensions.height } - : undefined, - listPrice: item.listings?.edges[0]?.node.price ?? undefined, - } - }) -} - -type NFTCollectionScreenProps = AppStackScreenProp & { - renderedInModal?: boolean -} - -export function NFTCollectionScreen({ - route: { - params: { collectionAddress }, - }, - renderedInModal = false, -}: NFTCollectionScreenProps): ReactElement { - const { t } = useTranslation() - const insets = useAppInsets() - const dimensions = useDeviceDimensions() - const navigation = useAppStackNavigation() - - // Collection overview data and paginated grid items - const { data, networkStatus, fetchMore, refetch } = GraphQLApi.useNftCollectionScreenQuery({ - variables: { contractAddress: collectionAddress, first: ASSET_FETCH_PAGE_SIZE }, - notifyOnNetworkStatusChange: true, - fetchPolicy: 'cache-and-network', - }) - - // Parse response for overview data and collection grid data - const collectionData = data?.nftCollections?.edges[0]?.node - const collectionItems = useMemo(() => gqlNFTAssetToNFTItem(data), [data]) - - // Fill in grid with loading boxes if we have incomplete data and are loading more - const extraLoadingItemAmount = - networkStatus === NetworkStatus.fetchMore || networkStatus === NetworkStatus.loading - ? LOADING_BUFFER_AMOUNT + (3 - ((collectionItems ?? []).length % 3)) - : undefined - - const onListEndReached = useCallback(async () => { - if (!data?.nftAssets?.pageInfo.hasNextPage) { - return - } - await fetchMore({ - variables: { - first: ASSET_FETCH_PAGE_SIZE, - after: data.nftAssets.pageInfo.endCursor, - }, - }) - }, [data?.nftAssets?.pageInfo.endCursor, data?.nftAssets?.pageInfo.hasNextPage, fetchMore]) - - // Scroll behavior for fixed scroll header - // biome-ignore lint/suspicious/noExplicitAny: FlashList ref type is complex and any is acceptable here - const listRef = useRef(null) - useScrollToTop(listRef) - const scrollY = useSharedValue(0) - const scrollHandler = useAnimatedScrollHandler( - { - onScroll: (event) => { - scrollY.value = event.contentOffset.y - }, - onEndDrag: (event) => { - scrollY.value = withTiming(event.contentOffset.y > 0 ? NFT_BANNER_HEIGHT : 0) - }, - }, - [scrollY], - ) - - const onPressItem = (asset: NFTItem): void => { - navigation.navigate(MobileScreens.NFTItem, { - address: asset.contractAddress ?? '', - tokenId: asset.tokenId ?? '', - isSpam: asset.isSpam ?? false, - fallbackData: asset, - }) - } - - /** - * @TODO: @ianlapham We can remove these styles when FLashList supports - * columnWrapperStyle prop (from FlatList). Until then, do this to preserve full width header, - * but padded list. - */ - const renderItem = ({ item, index }: ListRenderItemInfo): JSX.Element => { - const first = index % 3 === 0 - const last = index % 3 === 2 - const middle = !first && !last - const containerStyle = { - marginLeft: middle ? spacing.spacing8 : first ? spacing.spacing16 : 0, - marginRight: middle ? spacing.spacing8 : last ? spacing.spacing16 : 0, - marginBottom: spacing.spacing8, - } - const priceColor = isIOS ? '$white' : '$neutral1' - - return ( - - {typeof item === 'string' ? ( - - ) : ( - onPressItem(item)}> - - {item.listPrice && ( - - )} - - )} - - ) - } - - // Only show loading UI if no data and first request, otherwise render cached data - const headerDataLoading = networkStatus === NetworkStatus.loading && !collectionData - const gridDataLoading = networkStatus === NetworkStatus.loading && !collectionItems - - const gridDataWithLoadingElements = useMemo(() => { - if (gridDataLoading) { - return LOADING_ITEMS_ARRAY - } - - const extraLoadingItems: NFTItem[] = extraLoadingItemAmount ? Array(extraLoadingItemAmount).fill(LOADING_ITEM) : [] - - return [...(collectionItems ?? []), ...extraLoadingItems] - }, [collectionItems, extraLoadingItemAmount, gridDataLoading]) - - const traceProperties = useMemo( - () => (collectionData?.name ? { collectionAddress, collectionName: collectionData.name } : undefined), - [collectionAddress, collectionData?.name], - ) - - if (isError(networkStatus, !!data)) { - return ( - - - - - - - ) - } - - const List = renderedInModal ? AnimatedBottomSheetFlashList : AnimatedFlashList - - return ( - - - - {collectionData.name} : undefined} - listRef={listRef} - rightElement={} - scrollY={scrollY} - showHeaderScrollYDistance={NFT_BANNER_HEIGHT} - /> - - } - ListHeaderComponent={} - contentContainerStyle={{ paddingBottom: insets.bottom }} - data={gridDataWithLoadingElements} - estimatedItemSize={ESTIMATED_ITEM_SIZE} - estimatedListSize={{ - width: dimensions.fullWidth, - height: dimensions.fullHeight, - }} - keyExtractor={keyExtractor} - numColumns={3} - renderItem={renderItem} - showsVerticalScrollIndicator={false} - onEndReached={onListEndReached} - onEndReachedThreshold={PREFETCH_ITEMS_THRESHOLD} - onScroll={scrollHandler} - /> - - - - ) -} diff --git a/apps/mobile/src/screens/NFTItemScreen.tsx b/apps/mobile/src/screens/NFTItemScreen.tsx deleted file mode 100644 index 2af9b1d4be6..00000000000 --- a/apps/mobile/src/screens/NFTItemScreen.tsx +++ /dev/null @@ -1,515 +0,0 @@ -/* eslint-disable complexity, max-lines */ -import { ApolloQueryResult } from '@apollo/client' -import { GraphQLApi } from '@universe/api' -import { isAddress } from 'ethers/lib/utils' -import React, { useCallback, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { GestureResponderEvent, StatusBar, StyleSheet } from 'react-native' -import { useDispatch } 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/loaders' -import { useIsInModal } from 'src/components/modals/useIsInModal' -import { LongMarkdownText } from 'src/components/text/LongMarkdownText' -import { PriceAmount } from 'src/features/nfts/collection/ListPriceCard' -import { BlurredImageBackground } from 'src/features/nfts/item/BlurredImageBackground' -import { CollectionPreviewCard } from 'src/features/nfts/item/CollectionPreviewCard' -import { NFTTraitList } from 'src/features/nfts/item/traits' -import { ExploreModalAwareView } from 'src/screens/ModalAwareView' -import { - Flex, - getTokenValue, - MIN_COLOR_CONTRAST_THRESHOLD, - passesContrast, - Text, - Theme, - TouchableArea, - useSporeColors, -} from 'ui/src' -import { CopyAlt, Ellipsis } from 'ui/src/components/icons' -import { colorsDark, fonts, iconSizes } from 'ui/src/theme' -import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' -import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { NetworkLogo } from 'uniswap/src/components/CurrencyLogo/NetworkLogo' -import { ContextMenu } from 'uniswap/src/components/menus/ContextMenuV2' -import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' -import { NFTViewer } from 'uniswap/src/components/nfts/images/NFTViewer' -import { PollingInterval } from 'uniswap/src/constants/misc' -import { UniverseChainId } from 'uniswap/src/features/chains/types' -import { fromGraphQLChain, getChainLabel } from 'uniswap/src/features/chains/utils' -import { useNFTContextMenuItems } from 'uniswap/src/features/nfts/hooks/useNftContextMenuItems' -import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' -import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' -import { Platform } from 'uniswap/src/features/platforms/types/Platform' -import { chainIdToPlatform } from 'uniswap/src/features/platforms/utils/chains' -import { ModalName } from 'uniswap/src/features/telemetry/constants' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { MobileScreens } from 'uniswap/src/types/screens/mobile' -import { areAddressesEqual } from 'uniswap/src/utils/addresses' -import { setClipboard, setClipboardImage } from 'uniswap/src/utils/clipboard' -import { useNearestThemeColorFromImageUri } from 'uniswap/src/utils/colors' -import { shortenAddress } from 'utilities/src/addresses' -import { isAndroid, isIOS } from 'utilities/src/platform' -import { useBooleanState } from 'utilities/src/react/useBooleanState' -import { useAccounts, useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' - -const MAX_NFT_IMAGE_HEIGHT = 375 - -type NFTItemScreenProps = AppStackScreenProp - -export function NFTItemScreen(props: NFTItemScreenProps): JSX.Element { - return isAndroid ? ( - // display screen with theme dependent colors on Android - - ) : ( - // put Theme above the Contents so our useSporeColors() gets the right colors - - - - ) -} - -function NFTItemScreenContents({ - route: { - // ownerFromProps needed here, when nftBalances GQL query returns a user NFT, - // but nftAssets query for this NFT returns ownerAddress === null, - params: { owner: ownerFromProps, address, tokenId, isSpam, fallbackData }, - }, -}: NFTItemScreenProps): JSX.Element { - const { t } = useTranslation() - const activeAccountAddress = useActiveAccountAddressWithThrow() - const dispatch = useDispatch() - const colors = useSporeColors() - const navigation = useAppStackNavigation() - - const { - data, - loading: nftLoading, - refetch, - } = GraphQLApi.useNftItemScreenQuery({ - variables: { - contractAddress: address, - filter: { tokenIds: [tokenId] }, - activityFilter: { - address, - tokenId, - activityTypes: [GraphQLApi.NftActivityType.Sale], - }, - }, - pollInterval: PollingInterval.Slow, - }) - const asset = data?.nftAssets?.edges[0]?.node - const owner = (ownerFromProps || asset?.ownerAddress) ?? undefined - const chainId = fromGraphQLChain(fallbackData?.chain) ?? undefined - const contractAddress = address || asset?.nftContract?.address || fallbackData?.contractAddress - - const lastSaleData = data?.nftActivity?.edges[0]?.node - const listingPrice = asset?.listings?.edges[0]?.node.price - - const name = useMemo(() => asset?.name ?? fallbackData?.name, [asset?.name, fallbackData?.name]) - const description = useMemo( - () => asset?.description ?? fallbackData?.description, - [asset?.description, fallbackData?.description], - ) - const imageUrl = useMemo( - () => asset?.image?.url ?? fallbackData?.imageUrl, - [asset?.image?.url, fallbackData?.imageUrl], - ) - const imageHeight = asset?.image?.dimensions?.height - const imageWidth = asset?.image?.dimensions?.width - const imageDimensionsExist = imageHeight && imageWidth - const imageDimensions = imageDimensionsExist ? { height: imageHeight, width: imageWidth } : undefined - const imageAspectRatio = imageDimensions ? imageDimensions.width / imageDimensions.height : 1 - const onPressCollection = (): void => { - if (contractAddress) { - navigation.navigate(MobileScreens.NFTCollection, { collectionAddress: contractAddress }) - } - } - - // Disable navigation to profile if user owns NFT or invalid owner - const platform = chainId ? chainIdToPlatform(chainId) : Platform.EVM - const disableProfileNavigation = Boolean( - owner && - (areAddressesEqual({ - addressInput1: { address: owner, platform }, - addressInput2: { address: activeAccountAddress, platform }, - }) || - !isAddress(owner)), - ) - - const onPressOwner = (): void => { - if (owner) { - navigation.navigate(MobileScreens.ExternalProfile, { - address: owner, - }) - } - } - - const inModal = useIsInModal(ModalName.Explore) - - const traceProperties: Record> = useMemo(() => { - const baseProps = { - owner, - address, - tokenId, - } - - if (asset?.collection?.name) { - return { - ...baseProps, - collectionName: asset.collection.name, - isMissingData: false, - } - } - - if (fallbackData) { - return { - ...baseProps, - collectionName: fallbackData.collectionName, - isMissingData: true, - } - } - - return { ...baseProps, isMissingData: true } - }, [address, asset?.collection?.name, fallbackData, owner, tokenId]) - - const { collectionName } = traceProperties - - const displayCollectionName = name || collectionName - - const { colorLight, colorDark } = useNearestThemeColorFromImageUri(imageUrl) - // check if colorLight passes contrast against card bg color, if not use fallback - const accentTextColor = useMemo(() => { - if ( - colorLight && - passesContrast({ - color: colorLight, - backgroundColor: colors.surface1.val, - contrastThreshold: MIN_COLOR_CONTRAST_THRESHOLD, - }) - ) { - return colorLight - } - return colors.neutral2.val - }, [colorLight, colors.neutral2, colors.surface1]) - - const onLongPressNFTImage = async (): Promise => { - await setClipboardImage(imageUrl) - - dispatch( - pushNotification({ - type: AppNotificationType.Copied, - copyType: CopyNotificationType.Image, - }), - ) - } - - const rightElement = useMemo( - () => ( - - ), - [chainId, contractAddress, isSpam, owner, tokenId], - ) - - const onPressCopyAddress = useCallback( - async (_: GestureResponderEvent) => { - if (contractAddress) { - await setClipboard(contractAddress) - dispatch( - pushNotification({ - type: AppNotificationType.Copied, - copyType: CopyNotificationType.Address, - }), - ) - } - }, - [contractAddress, dispatch], - ) - - return ( - <> - {isIOS ? : null} - - - <> - {isIOS ? ( - - ) : ( - - )} - - - - ) : displayCollectionName ? ( - - {displayCollectionName} - - ) : undefined - } - renderedInModal={inModal} - rightElement={rightElement} - > - {/* Content wrapper */} - - - - {nftLoading ? ( - - - - ) : imageUrl ? ( - - - - ) : ( - - {displayCollectionName ? ( - - {displayCollectionName} - - ) : ( - > => refetch()} - /> - )} - - )} - - - {nftLoading ? ( - - ) : displayCollectionName ? ( - - {displayCollectionName} - - ) : null} - - - - {/* Description */} - - {nftLoading ? ( - - - - ) : description ? ( - - ) : null} - - - {/* Metadata */} - - {listingPrice?.value ? ( - - } - /> - ) : null} - {chainId && ( - - - {getChainLabel(chainId)} - - - - } - /> - )} - {lastSaleData?.price?.value ? ( - - } - /> - ) : null} - - {contractAddress ? ( - - {shortenAddress({ address: contractAddress })} - - - } - /> - ) : null} - - {owner && ( - - - - } - /> - )} - - - {/* Traits */} - {asset?.traits && asset.traits.length > 0 ? ( - - - {t('tokens.nfts.details.traits')} - - - - ) : null} - - - - - - - ) -} - -function AssetMetadata({ - title, - valueComponent, - color, -}: { - title: string - valueComponent: JSX.Element - color: string -}): JSX.Element { - return ( - - - - {title} - - - {valueComponent} - - ) -} - -function RightElement({ - chainId, - contractAddress, - tokenId, - owner, - isSpam, -}: { - chainId?: UniverseChainId - contractAddress?: string - tokenId?: string - owner?: string - isSpam?: boolean -}): JSX.Element { - const accounts = useAccounts() - - const { value: contextMenuIsOpen, setFalse: closeContextMenu, setTrue: openContextMenu } = useBooleanState(false) - - const menuItems = useNFTContextMenuItems({ - contractAddress, - tokenId, - owner, - walletAddresses: Object.keys(accounts), - showNotification: true, - isSpam, - chainId, - }) - - return ( - - {menuItems.length > 0 && ( - - - - - - )} - - ) -} diff --git a/apps/mobile/src/screens/Onboarding/LandingScreen.tsx b/apps/mobile/src/screens/Onboarding/LandingScreen.tsx index d4871955da3..5c8de6bb751 100644 --- a/apps/mobile/src/screens/Onboarding/LandingScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/LandingScreen.tsx @@ -39,6 +39,7 @@ export function LandingScreen({ navigation }: Props): JSX.Element { useEffect(() => { // disables looping animation during e2e tests which was preventing js thread from idle actionButtonsOpacity.value = withDelay(LANDING_ANIMATION_DURATION, withTiming(1, { duration: ONE_SECOND_MS })) + // oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here }, []) // Disables testnet mode on mount if enabled (eg upon removing a wallet) diff --git a/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx b/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx index e24c5e7baec..15553acf052 100644 --- a/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/WelcomeWalletScreen.tsx @@ -9,8 +9,8 @@ import { Button, Flex, Loader, Text, useMedia, useSporeColors } from 'ui/src' import { Arrow } from 'ui/src/components/arrow/Arrow' import { Lock } from 'ui/src/components/icons' import { fonts, iconSizes, opacify } from 'ui/src/theme' -import AnimatedNumber from 'uniswap/src/components/AnimatedNumber/AnimatedNumber' import { DisplayNameText } from 'uniswap/src/components/accounts/DisplayNameText' +import AnimatedNumber from 'uniswap/src/components/AnimatedNumber/AnimatedNumber' import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon' import { DisplayNameType } from 'uniswap/src/features/accounts/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' diff --git a/apps/mobile/src/screens/ReceiveCryptoModal.tsx b/apps/mobile/src/screens/ReceiveCryptoModal.tsx index eeb7572c4b7..dda794ade91 100644 --- a/apps/mobile/src/screens/ReceiveCryptoModal.tsx +++ b/apps/mobile/src/screens/ReceiveCryptoModal.tsx @@ -14,7 +14,8 @@ import { pushNotification } from 'uniswap/src/features/notifications/slice/slice import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types' 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 { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { setClipboard } from 'utilities/src/clipboard/clipboard' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' const ACCOUNT_IMAGE_SIZE = 52 @@ -45,7 +46,7 @@ function AccountCardItem({ onClose }: { onClose: () => void }): JSX.Element { } return ( - + ({ + sessionId: state.session.sessionId, + deviceId: state.session.deviceId, + uniswapIdentifier: state.session.uniswapIdentifier, + })), + ) + const challenge = useSessionsDebugStore((state) => state.challenge) + const isLoading = useSessionsDebugStore((state) => state.isLoading) + const hashcashIsRunning = useSessionsDebugStore((state) => state.hashcashProgress.isRunning) + const hashcashStartTime = useSessionsDebugStore((state) => state.hashcashProgress.startTime) + + // Actions (stable references) + const setSession = useSessionsDebugStore((state) => state.setSession) + const setChallenge = useSessionsDebugStore((state) => state.setChallenge) + const startOperation = useSessionsDebugStore((state) => state.startOperation) + const endOperation = useSessionsDebugStore((state) => state.endOperation) + const addLog = useSessionsDebugStore((state) => state.addLog) + const startHashcash = useSessionsDebugStore((state) => state.startHashcash) + const updateHashcashProgress = useSessionsDebugStore((state) => state.updateHashcashProgress) + const completeHashcash = useSessionsDebugStore((state) => state.completeHashcash) + const stopHashcash = useSessionsDebugStore((state) => state.stopHashcash) + const reset = useSessionsDebugStore((state) => state.reset) + + const sessionServiceRef = useRef(null) + + const getSessionService = useCallback((): SessionService => { + if (!sessionServiceRef.current) { + sessionServiceRef.current = provideSessionService({ + getBaseUrl: getEntryGatewayUrl, + getIsSessionServiceEnabled: () => true, // Always enabled for debug + getLogger: () => logger, + }) + } + return sessionServiceRef.current + }, []) + + const refreshSessionState = useCallback(async (): Promise => { + const driver = getStorageDriver() + const [sessionId, deviceId, uniswapIdentifier] = await Promise.all([ + driver.get(SESSION_ID_KEY), + driver.get(DEVICE_ID_KEY), + driver.get(UNISWAP_IDENTIFIER_KEY), + ]) + setSession({ + sessionId: sessionId || null, + deviceId: deviceId || null, + uniswapIdentifier: uniswapIdentifier || null, + }) + }, [setSession]) + + // Initial load - only run once on mount + useEffect(() => { + const loadInitialState = async (): Promise => { + const driver = getStorageDriver() + const [sessionId, deviceId, uniswapIdentifier] = await Promise.all([ + driver.get(SESSION_ID_KEY), + driver.get(DEVICE_ID_KEY), + driver.get(UNISWAP_IDENTIFIER_KEY), + ]) + setSession({ + sessionId: sessionId || null, + deviceId: deviceId || null, + uniswapIdentifier: uniswapIdentifier || null, + }) + } + loadInitialState().catch(() => undefined) + }, [setSession]) + + // Progress timer for hashcash + useEffect(() => { + if (hashcashIsRunning && hashcashStartTime !== null) { + const interval = setInterval(() => { + const elapsed = performance.now() - hashcashStartTime + // Estimate ~900k hashes/sec on native + const estimatedAttempts = Math.floor((elapsed / 1000) * 900000) + updateHashcashProgress(elapsed, estimatedAttempts) + }, 100) + + return (): void => { + clearInterval(interval) + } + } + return undefined + }, [hashcashIsRunning, hashcashStartTime, updateHashcashProgress]) + + const clearAllState = useCallback(async (): Promise => { + startOperation('Clearing all state...') + try { + const driver = getStorageDriver() + await Promise.all([ + driver.remove(SESSION_ID_KEY), + driver.remove(DEVICE_ID_KEY), + driver.remove(UNISWAP_IDENTIFIER_KEY), + ]) + // Reset the session service ref so a fresh one is created next time + sessionServiceRef.current = null + setChallenge(null) + reset() + addLog('Cleared all session state', 'success') + await refreshSessionState() + } catch (error) { + addLog(`Failed to clear state: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'clearAllState' } }) + } finally { + endOperation() + } + }, [startOperation, setChallenge, reset, addLog, refreshSessionState, endOperation]) + + const handleInitSession = useCallback(async (): Promise => { + startOperation('Initializing session...') + addLog('Init session started') + try { + const service = getSessionService() + const result = await service.initSession() + addLog(`Session initialized. needChallenge: ${result.needChallenge}`, 'success') + if (result.sessionId) { + addLog(`Session ID: ${truncateId(result.sessionId)}`) + } + await refreshSessionState() + } catch (error) { + addLog(`Init session failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleInitSession' } }) + } finally { + endOperation() + } + }, [startOperation, addLog, getSessionService, refreshSessionState, endOperation]) + + const handleRequestChallenge = useCallback(async (): Promise => { + startOperation('Requesting challenge...') + addLog('Request challenge started') + try { + const service = getSessionService() + const challengeResult = await service.requestChallenge() + setChallenge(challengeResult) + const challengeTypeName = ChallengeType[challengeResult.challengeType] || 'Unknown' + addLog(`Challenge received: ${challengeTypeName}`, 'success') + addLog(`Challenge ID: ${truncateId(challengeResult.challengeId)}`) + + // Parse difficulty from extra if hashcash + if (challengeResult.challengeType === ChallengeType.HASHCASH && challengeResult.extra['challengeData']) { + try { + const challengeData = JSON.parse(challengeResult.extra['challengeData']) + addLog(`Difficulty: ${challengeData.difficulty}`) + } catch { + // Ignore parse errors + } + } + } catch (error) { + addLog(`Request challenge failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleRequestChallenge' } }) + } finally { + endOperation() + } + }, [startOperation, addLog, getSessionService, setChallenge, endOperation]) + + const handleSolveChallenge = useCallback(async (): Promise => { + const currentChallenge = useSessionsDebugStore.getState().challenge + if (!currentChallenge) { + addLog('No challenge to solve. Request a challenge first.', 'error') + return + } + + if (currentChallenge.challengeType !== ChallengeType.HASHCASH) { + addLog('Only Hashcash challenges are supported on mobile', 'error') + return + } + + startOperation('Solving hashcash challenge...') + addLog('Hashcash solve started') + + // Parse difficulty for progress display + let difficulty = 0 + if (currentChallenge.extra['challengeData']) { + try { + const challengeData = JSON.parse(currentChallenge.extra['challengeData']) + difficulty = challengeData.difficulty || 0 + } catch { + // Use default + } + } + + // Start progress tracking + startHashcash(difficulty) + + try { + const solver = createHashcashSolver({ + performanceTracker: { + now: () => performance.now(), + }, + getWorkerChannel: () => createHashcashWorkerChannel(), + onSolveCompleted: (data) => { + completeHashcash(data) + }, + }) + + const solution = await solver.solve({ + challengeId: currentChallenge.challengeId, + challengeType: currentChallenge.challengeType, + extra: currentChallenge.extra, + }) + + addLog(`Challenge solved!`, 'success') + addLog(`Solution: ${truncateId(solution, 32)}`) + + // Verify with backend + startOperation('Verifying session...') + addLog('Verifying session with backend...') + const service = getSessionService() + const verifyResult = await service.verifySession({ + solution, + challengeId: currentChallenge.challengeId, + challengeType: currentChallenge.challengeType, + }) + + if (verifyResult.retry) { + addLog('Verification returned retry=true. May need another challenge.', 'info') + } else { + addLog('Session verified successfully!', 'success') + } + + setChallenge(null) + await refreshSessionState() + } catch (error) { + stopHashcash() + addLog(`Solve challenge failed: ${error}`, 'error') + logger.error(error, { tags: { file: 'SessionsDebugScreen', function: 'handleSolveChallenge' } }) + } finally { + endOperation() + } + }, [ + addLog, + startOperation, + startHashcash, + completeHashcash, + getSessionService, + setChallenge, + refreshSessionState, + stopHashcash, + endOperation, + ]) + + const copyToClipboard = useCallback( + async (value: string | null, label: string): Promise => { + if (!value) { + return + } + await setClipboard(value) + addLog(`Copied ${label} to clipboard`, 'info') + }, + [addLog], + ) + + const hasChallenge = challenge !== null + + return ( + + + + + + + Sessions Debug + + Test session initialization flow step by step. + + + {/* Session Status Section */} + + Session Status + + + + + Session ID: + + + + {truncateId(session.sessionId)} + + {session.sessionId && ( + copyToClipboard(session.sessionId, 'Session ID')}> + + + )} + + + + + + Device ID: + + + + {truncateId(session.deviceId)} + + {session.deviceId && ( + copyToClipboard(session.deviceId, 'Device ID')}> + + + )} + + + + + + Uniswap ID: + + + + {truncateId(session.uniswapIdentifier)} + + {session.uniswapIdentifier && ( + copyToClipboard(session.uniswapIdentifier, 'Uniswap ID')}> + + + )} + + + + + + Challenge Pending: + + + {hasChallenge ? 'Yes' : 'No'} + + + + + + {/* Action Buttons */} + + + + + + {/* Step-by-Step Testing */} + + Step-by-Step Testing + + + + + + + + {/* Current Operation */} + + + {/* Hashcash Progress */} + + + {/* Operation Log */} + + + + + ) +} diff --git a/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx b/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx index 569b08df363..4ebfeb00276 100644 --- a/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx +++ b/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx @@ -64,7 +64,11 @@ export function SettingsCloudBackupPasswordCreateScreen({ {showCloudBackupInfoModal && ( - + setShowCloudBackupInfoModal(false)} + > diff --git a/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx b/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx index 4394cd2cad5..058c12fb626 100644 --- a/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx +++ b/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx @@ -1,5 +1,5 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' import { FlatList } from 'react-native-gesture-handler' @@ -15,14 +15,18 @@ import { Check } from 'ui/src/components/icons' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { spacing } from 'ui/src/theme' import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' import { AccountType } from 'uniswap/src/features/accounts/types' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' +import { NumberType } from 'utilities/src/format/types' import { logger } from 'utilities/src/logger/logger' +import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData' import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { Account, BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' import { useAccounts } from 'wallet/src/features/wallet/hooks' @@ -40,6 +44,7 @@ export function SettingsCloudBackupStatus({ const insets = useAppInsets() const dimensions = useDeviceDimensions() const dispatch = useDispatch() + const { convertFiatAmountFormatted } = useLocalizationContext() const accounts = useAccounts() const mnemonicId = (accounts[address] as SignerMnemonicAccount).mnemonicId const androidCloudBackupEmail = useSelector(selectAndroidCloudBackupEmail) @@ -47,6 +52,24 @@ export function SettingsCloudBackupStatus({ (a) => a.type === AccountType.SignerMnemonic && a.mnemonicId === mnemonicId, ) + // Fetch balance data for associated accounts + const accountAddresses = useMemo(() => associatedAccounts.map((account) => account.address), [associatedAccounts]) + const { data: accountBalanceData, loading } = useAccountListData({ + addresses: accountAddresses, + }) + + // Create balance mapping + const balanceRecord: Record = useMemo(() => { + if (!accountBalanceData?.portfolios) { + return {} + } + return Object.fromEntries( + accountBalanceData.portfolios + .filter((portfolio): portfolio is NonNullable => Boolean(portfolio)) + .map((portfolio) => [portfolio.ownerAddress, portfolio.tokensTotalDenominatedValue?.value ?? 0]), + ) + }, [accountBalanceData]) + const [showBackupDeleteWarning, setShowBackupDeleteWarning] = useState(false) const onConfirmDeleteBackup = async (): Promise => { if (requiredForTransactions) { @@ -90,13 +113,28 @@ export function SettingsCloudBackupStatus({ navigation.goBack() } - const renderItem = ({ item, index }: { item: Account; index: number }): JSX.Element => ( - - - - ) + const renderItem = ({ item, index }: { item: Account; index: number }): JSX.Element => { + const balance = balanceRecord[item.address] ?? 0 + const formattedBalance = convertFiatAmountFormatted(balance, NumberType.PortfolioBalance) + + return ( + + + + {formattedBalance} + + + ) + } - const fullScreenContentHeight = (dimensions.fullHeight - insets.top - insets.bottom - spacing.spacing36) / 2 + const maxListHeight = (dimensions.fullHeight - insets.top - insets.bottom - spacing.spacing16) / 2.5 return ( @@ -155,21 +193,24 @@ export function SettingsCloudBackupStatus({ })} rejectText={t('common.button.close')} acknowledgeText={t('common.button.delete')} + acknowledgeButtonVariant="critical" isOpen={showBackupDeleteWarning} modalName={ModalName.ViewSeedPhraseWarning} title={t('settings.setting.backup.delete.confirm.title')} + severity={WarningSeverity.High} onClose={(): void => { setShowBackupDeleteWarning(false) }} onAcknowledge={onConfirmDeleteBackup} > {associatedAccounts.length > 1 && ( - - + + {t('settings.setting.backup.delete.confirm.message')} - + `${index}-${item.address}`} diff --git a/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx b/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx index ab778953bbc..e837e91ef18 100644 --- a/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx +++ b/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx @@ -1,76 +1,39 @@ -import React, { useCallback } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' -// TODO(WALL-7189): Explore removing FlatList. Currently using this to fix a scrolling regression. -import { FlatList } from 'react-native-gesture-handler' import { useDispatch } from 'react-redux' -import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' -import { Flex, Text, TouchableArea } from 'ui/src' -import { Check } from 'ui/src/components/icons' -import { Modal } from 'uniswap/src/components/modals/Modal' +import { SettingsListModal } from 'src/components/Settings/lists/SettingsListModal' import { FiatCurrency, ORDERED_CURRENCIES } from 'uniswap/src/features/fiatCurrency/constants' -import { useAppFiatCurrency, useFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' +import { getFiatCurrencyCode, getFiatCurrencyName, useAppFiatCurrency } from 'uniswap/src/features/fiatCurrency/hooks' import { setCurrentFiatCurrency } from 'uniswap/src/features/settings/slice' import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useEvent } from 'utilities/src/react/hooks' export function SettingsFiatCurrencyModal(): JSX.Element { + const dispatch = useDispatch() const { t } = useTranslation() const selectedCurrency = useAppFiatCurrency() - const { onClose } = useReactNavigationModal() - // render - const renderItem = useCallback( - ({ item: currency }: { item: FiatCurrency }) => ( - - ), - [selectedCurrency, onClose], - ) + const getCurrencyTitle = useEvent((currency: FiatCurrency) => { + return getFiatCurrencyName(t, currency).name + }) - return ( - - - {t('settings.setting.currency.title')} - - {/* When modifying this component, please test on a physical device that - scrolling the currencies list continues to work correctly. */} - item} - renderItem={renderItem} - showsVerticalScrollIndicator={false} - bounces={true} - keyboardShouldPersistTaps="always" - keyboardDismissMode="on-drag" - /> - - ) -} - -interface FiatCurrencyOptionProps { - active?: boolean - currency: FiatCurrency - onPress: () => void -} - -function FiatCurrencyOption({ active, currency, onPress }: FiatCurrencyOptionProps): JSX.Element { - const dispatch = useDispatch() - const { name, code } = useFiatCurrencyInfo(currency) + const getCurrencyCode = useEvent((currency: FiatCurrency) => { + return getFiatCurrencyCode(currency) + }) - const changeCurrency = useCallback(() => { + const onSelectCurrencyOption = useEvent(async (currency: FiatCurrency) => { dispatch(setCurrentFiatCurrency(currency)) - onPress() - }, [dispatch, onPress, currency]) + }) return ( - - - - {name} - - {code} - - - {active && } - - + ) } diff --git a/apps/mobile/src/screens/SettingsLanguageModal.tsx b/apps/mobile/src/screens/SettingsLanguageModal.tsx new file mode 100644 index 00000000000..95a47f2d680 --- /dev/null +++ b/apps/mobile/src/screens/SettingsLanguageModal.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { SettingsListModal } from 'src/components/Settings/lists/SettingsListModal' +import { Language, mapLanguageToLocale, WALLET_SUPPORTED_LANGUAGES } from 'uniswap/src/features/language/constants' +import { getLanguageInfo, useCurrentLanguage } from 'uniswap/src/features/language/hooks' +import { setCurrentLanguage } from 'uniswap/src/features/settings/slice' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { changeLanguage } from 'uniswap/src/i18n' +import { useEvent } from 'utilities/src/react/hooks' + +export function SettingsLanguageModal(): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + const selectedLanguage = useCurrentLanguage() + + const getLanguageTitle = useCallback( + (language: Language) => { + return getLanguageInfo(t, language).displayName + }, + [t], + ) + + const onSelectLanguageOption = useEvent(async (language: Language) => { + await changeLanguage(mapLanguageToLocale[language]) + dispatch(setCurrentLanguage(language)) + }) + + return ( + + ) +} diff --git a/apps/mobile/src/screens/SettingsNotificationsScreen.tsx b/apps/mobile/src/screens/SettingsNotificationsScreen.tsx index c21443f46f0..0a494ace85e 100644 --- a/apps/mobile/src/screens/SettingsNotificationsScreen.tsx +++ b/apps/mobile/src/screens/SettingsNotificationsScreen.tsx @@ -41,7 +41,7 @@ type SettingItem = { type NotificationItem = SettingItem | AccountItem -function _SettingsNotificationsScreen(): JSX.Element { +function SettingsNotificationsScreenInner(): JSX.Element { const { t } = useTranslation() const insets = useAppInsets() const { fullWidth, fullHeight } = useDeviceDimensions() @@ -117,7 +117,7 @@ function _SettingsNotificationsScreen(): JSX.Element { ) } -export const SettingsNotificationsScreen = memo(_SettingsNotificationsScreen) +export const SettingsNotificationsScreen = memo(SettingsNotificationsScreenInner) SettingsNotificationsScreen.displayName = 'SettingsNotificationsScreen' @@ -190,6 +190,7 @@ const AccountNotificationRow = memo(function AccountNotificationRow({ size={iconSizes.icon32} variant="subheading2" captionVariant="body3" + alignItems="center" /> @@ -205,7 +206,7 @@ function onPermissionChanged(enabled: boolean, type: NotificationToggleLoggingTy const PENDING_DELAY = 100 -function _AddressNotificationsSwitch({ address }: { address: string }): JSX.Element { +function AddressNotificationsSwitchInner({ address }: { address: string }): JSX.Element { const { isEnabled, isPending, toggle } = useAddressNotificationToggle({ address, onToggle: (enabled) => onPermissionChanged(enabled, 'wallet_activity'), @@ -235,6 +236,6 @@ function _AddressNotificationsSwitch({ address }: { address: string }): JSX.Elem return } -const AddressNotificationsSwitch = memo(_AddressNotificationsSwitch) +const AddressNotificationsSwitch = memo(AddressNotificationsSwitchInner) AddressNotificationsSwitch.displayName = 'AddressNotificationsSwitch' diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index 6fa0cf28f23..b6018af0409 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -3,12 +3,13 @@ import { FeatureFlags, useFeatureFlag } from '@universe/gating' import { default as React, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ListRenderItemInfo } from 'react-native' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import { OnboardingStackNavigationProp, SettingsStackNavigationProp } from 'src/app/navigation/types' import { ScreenWithHeader } from 'src/components/layout/screens/ScreenWithHeader' import { useReactNavigationModal } from 'src/components/modals/useReactNavigationModal' import { WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState' import { FooterSettings } from 'src/components/Settings/FooterSettings' +import { ForceReduxDataLossRow } from 'src/components/Settings/ForceReduxDataLossRow' import { SettingsList } from 'src/components/Settings/lists/SettingsList' import { SectionData } from 'src/components/Settings/lists/types' import { OnboardingRow } from 'src/components/Settings/OnboardingRow' @@ -20,6 +21,7 @@ import { SettingsSectionItemComponent, } from 'src/components/Settings/SettingsRow' import { WalletSettings } from 'src/components/Settings/WalletSettings' +import { useBiometricsAlert } from 'src/features/biometrics/useBiometricsAlert' import { useBiometricsState } from 'src/features/biometrics/useBiometricsState' import { useDeviceSupportsBiometricAuth } from 'src/features/biometrics/useDeviceSupportsBiometricAuth' import { useBiometricName } from 'src/features/biometricsSettings/hooks' @@ -27,6 +29,7 @@ import { NotificationPermission, useNotificationOSPermissionsEnabled, } from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' +import { useAdvancedSettingsMenuState } from 'src/features/settings/hooks/useAdvancedSettingsMenuState' import { useWalletRestore } from 'src/features/wallet/useWalletRestore' import { importFromCloudBackupOption, restoreFromCloudBackupOption } from 'src/screens/Import/constants' import { Flex, IconProps, Text, useSporeColors } from 'ui/src' @@ -55,20 +58,18 @@ import { } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' +import { useCurrentAppearanceSetting } from 'uniswap/src/features/appearance/hooks' import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' -import { setIsTestnetModeEnabled } from 'uniswap/src/features/settings/slice' import { useHapticFeedback } from 'uniswap/src/features/settings/useHapticFeedback/useHapticFeedback' -import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' -import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { ModalName } from 'uniswap/src/features/telemetry/constants' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { getCloudProviderName } from 'uniswap/src/utils/cloud-backup/getCloudProviderName' import { isDevEnv } from 'utilities/src/environment/env' import { isAndroid } from 'utilities/src/platform' -import { useCurrentAppearanceSetting } from 'wallet/src/features/appearance/hooks' import { selectHasCopiedPrivateKeys } from 'wallet/src/features/behaviorHistory/selectors' import { BackupType } from 'wallet/src/features/wallet/accounts/types' import { hasBackup } from 'wallet/src/features/wallet/accounts/utils' @@ -80,12 +81,12 @@ const AVOID_RENDER_DURING_ANIMATION_MS = 100 export function SettingsScreen(): JSX.Element { const navigation = useNavigation() - const dispatch = useDispatch() const colors = useSporeColors() const hasCopiedPrivateKeys = useSelector(selectHasCopiedPrivateKeys) const shouldShowPrivateKeys = useFeatureFlag(FeatureFlags.EnableExportPrivateKeys) - const { deviceSupportsBiometrics } = useBiometricsState() + const { isBiometricsDisabledInOSSettings } = useBiometricsState() const { t } = useTranslation() + const { showBiometricsAlert } = useBiometricsAlert({ t }) const { onClose } = useReactNavigationModal() // check if device supports biometric authentication, if not, hide option @@ -108,37 +109,11 @@ export function SettingsScreen(): JSX.Element { const { notificationPermissionsEnabled: notificationOSPermission } = useNotificationOSPermissionsEnabled() const { isTestnetModeEnabled } = useEnabledChains() - const handleTestnetModeToggle = useCallback((): void => { - const newIsTestnetMode = !isTestnetModeEnabled - const fireAnalytic = (): void => - sendAnalyticsEvent(WalletEventName.TestnetModeToggled, { - enabled: newIsTestnetMode, - location: 'settings', - }) - - if (isSmartWalletSettingsEnabled) { - // this assumes that we can only navigate to this toggle from the advanced settings modal - navigation.goBack() - } else { - onClose() - } - - setTimeout(() => { - // trigger before toggling on (ie disabling analytics) - if (newIsTestnetMode) { - fireAnalytic() - navigation.navigate(ModalName.TestnetMode, {}) - } - - dispatch(setIsTestnetModeEnabled(newIsTestnetMode)) - - // trigger after toggling off (ie enabling analytics) - if (!newIsTestnetMode) { - fireAnalytic() - } - }, AVOID_RENDER_DURING_ANIMATION_MS) - }, [dispatch, onClose, isSmartWalletSettingsEnabled, isTestnetModeEnabled, navigation]) + // For non-smart-wallet mode, we need to close the settings modal instead of going back + const advancedSettingsState = useAdvancedSettingsMenuState({ + onClose: isSmartWalletSettingsEnabled ? undefined : onClose, + }) // Signer account info const signerAccount = useSignerAccounts()[0] @@ -162,6 +137,7 @@ export function SettingsScreen(): JSX.Element { navigation={navigation} page={item} checkIfCanProceed={item.checkIfCanProceed} + cantProceedFallback={item.cantProceedFallback} testID={item.testID} /> ) @@ -241,13 +217,7 @@ export function SettingsScreen(): JSX.Element { navigationModal: ModalName.SmartWalletAdvancedSettingsModal, text: t('settings.setting.advanced.title'), icon: , - navigationProps: { - isTestnetEnabled: isTestnetModeEnabled, - onTestnetModeToggled: handleTestnetModeToggle, - onPressSmartWallet: (): void => { - navigation.navigate(MobileScreens.SettingsSmartWallet) - }, - }, + navigationProps: advancedSettingsState, }, ] : [ @@ -255,7 +225,7 @@ export function SettingsScreen(): JSX.Element { text: t('settings.setting.wallet.testnetMode.title'), icon: , isToggleEnabled: isTestnetModeEnabled, - onToggle: handleTestnetModeToggle, + onToggle: advancedSettingsState.handleTestnetModeToggle, }, ]), ], @@ -264,22 +234,22 @@ export function SettingsScreen(): JSX.Element { subTitle: t('settings.section.privacyAndSecurity'), isHidden: noSignerAccountImported, data: [ - ...(deviceSupportsBiometrics - ? [ - { - navigationModal: ModalName.BiometricsModal, - isHidden: !isTouchIdSupported && !isFaceIdSupported, - text: isAndroid ? t('settings.setting.biometrics.title') : biometricsMethod, - icon: isAndroid ? ( - - ) : isTouchIdSupported ? ( - - ) : ( - - ), - }, - ] - : []), + { + navigationModal: ModalName.BiometricsModal, + isHidden: !isTouchIdSupported && !isFaceIdSupported, + checkIfCanProceed: (): boolean => !isBiometricsDisabledInOSSettings, + cantProceedFallback: (): void => { + showBiometricsAlert(biometricsMethod) + }, + text: isAndroid ? t('settings.setting.biometrics.title') : biometricsMethod, + icon: isAndroid ? ( + + ) : isTouchIdSupported ? ( + + ) : ( + + ), + }, { screen: MobileScreens.SettingsViewSeedPhrase, text: t('settings.setting.recoveryPhrase.title'), @@ -406,8 +376,14 @@ export function SettingsScreen(): JSX.Element { text: 'Dev options', icon: , }, + { + screen: MobileScreens.DebugScreens, + text: 'Debug Screens', + icon: , + }, { component: }, { component: }, + { component: }, ], }, ] @@ -420,7 +396,6 @@ export function SettingsScreen(): JSX.Element { hapticsEnabled, onToggleEnableHaptics, noSignerAccountImported, - deviceSupportsBiometrics, isTouchIdSupported, isFaceIdSupported, biometricsMethod, @@ -430,12 +405,14 @@ export function SettingsScreen(): JSX.Element { hasPasskeyBackup, isTestnetModeEnabled, isSmartWalletSettingsEnabled, - handleTestnetModeToggle, + advancedSettingsState, notificationOSPermission, navigation, hasCopiedPrivateKeys, shouldShowPrivateKeys, walletRestoreType, + isBiometricsDisabledInOSSettings, + showBiometricsAlert, ]) return ( diff --git a/apps/mobile/src/screens/SettingsStorageScreen.tsx b/apps/mobile/src/screens/SettingsStorageScreen.tsx new file mode 100644 index 00000000000..ff560ddd728 --- /dev/null +++ b/apps/mobile/src/screens/SettingsStorageScreen.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { ScreenWithHeader } from 'src/components/layout/screens/ScreenWithHeader' +import { useAppStateResetter } from 'src/features/appState/appStateResetter' +import { Flex, Text } from 'ui/src' +import { StorageHelpIcon, StorageSettingsContent } from 'uniswap/src/features/settings/storage/StorageSettingsContent' +import { useEvent } from 'utilities/src/react/hooks' + +export function SettingsStorageScreen(): JSX.Element { + const { t } = useTranslation() + + const appStateResetter = useAppStateResetter() + const onPressClearAccountHistory = useEvent(() => appStateResetter.resetAccountHistory()) + const onPressClearUserSettings = useEvent(() => appStateResetter.resetUserSettings()) + const onPressClearCachedData = useEvent(() => appStateResetter.resetQueryCaches()) + const onPressClearAllData = useEvent(() => appStateResetter.resetAll()) + + return ( + {t('settings.setting.storage.title')}} + rightElement={} + > + + + + + ) +} diff --git a/apps/mobile/src/screens/SettingsViewSeedPhraseScreen.tsx b/apps/mobile/src/screens/SettingsViewSeedPhraseScreen.tsx index 6be051828db..77b2c3b5c7f 100644 --- a/apps/mobile/src/screens/SettingsViewSeedPhraseScreen.tsx +++ b/apps/mobile/src/screens/SettingsViewSeedPhraseScreen.tsx @@ -9,20 +9,19 @@ import { Text } from 'ui/src' import { AccountType } from 'uniswap/src/features/accounts/types' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { logger } from 'utilities/src/logger/logger' -import { useAccounts } from 'wallet/src/features/wallet/hooks' +import { useAccounts, useActiveSignerAccount } from 'wallet/src/features/wallet/hooks' type Props = NativeStackScreenProps -export function SettingsViewSeedPhraseScreen({ - navigation, - route: { - params: { address, walletNeedsRestore }, - }, -}: Props): JSX.Element { +export function SettingsViewSeedPhraseScreen({ navigation, route }: Props): JSX.Element { const { t } = useTranslation() + const { address: addressParam, walletNeedsRestore } = route.params ?? {} + // Use provided address or fall back to active signer account + const activeSignerAccount = useActiveSignerAccount() + const address = addressParam ?? activeSignerAccount?.address const accounts = useAccounts() - const account = accounts[address] + const account = address ? accounts[address] : undefined const mnemonicId = account?.type === AccountType.SignerMnemonic ? account.mnemonicId : undefined const navigateBack = (): void => { diff --git a/apps/mobile/src/screens/TokenDetailsScreen.tsx b/apps/mobile/src/screens/TokenDetailsScreen.tsx deleted file mode 100644 index 63e3176d019..00000000000 --- a/apps/mobile/src/screens/TokenDetailsScreen.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { useApolloClient } from '@apollo/client' -import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' -import { GQLQueries, GraphQLApi } from '@universe/api' -import { FeatureFlags, useFeatureFlag } from '@universe/gating' -import React, { memo, useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { FadeInDown, FadeOutDown } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { MODAL_OPEN_WAIT_TIME } from 'src/app/navigation/constants' -import { navigate } from 'src/app/navigation/rootNavigation' -import type { AppStackScreenProp } from 'src/app/navigation/types' -import { HeaderScrollScreen } from 'src/components/layout/screens/HeaderScrollScreen' -import { useIsInModal } from 'src/components/modals/useIsInModal' -import { PriceExplorer } from 'src/components/PriceExplorer/PriceExplorer' -import { ContractAddressExplainerModal } from 'src/components/TokenDetails/ContractAddressExplainerModal' -import { TokenBalances } from 'src/components/TokenDetails/TokenBalances' -import { TokenDetailsActionButtons } from 'src/components/TokenDetails/TokenDetailsActionButtons' -import { TokenDetailsBridgedAssetSection } from 'src/components/TokenDetails/TokenDetailsBridgedAssetSection' -import { TokenDetailsContextProvider, useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' -import { TokenDetailsHeader } from 'src/components/TokenDetails/TokenDetailsHeader' -import { TokenDetailsLinks } from 'src/components/TokenDetails/TokenDetailsLinks' -import { TokenDetailsStats } from 'src/components/TokenDetails/TokenDetailsStats' -import { useTokenDetailsCTAVariant } from 'src/components/TokenDetails/useTokenDetailsCTAVariant' -import { useTokenDetailsCurrentChainBalance } from 'src/components/TokenDetails/useTokenDetailsCurrentChainBalance' -import { HeaderRightElement, HeaderTitleElement } from 'src/screens/TokenDetailsHeaders' -import { useIsScreenNavigationReady } from 'src/utils/useIsScreenNavigationReady' -import { Flex, Separator, Text } from 'ui/src' -import { ArrowDownCircle, ArrowUpCircle, Bank, SendRoundedAirplane } from 'ui/src/components/icons' -import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' -import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import type { MenuOptionItem } from 'uniswap/src/components/menus/ContextMenuV2' -import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' -import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' -import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' -import { PollingInterval } from 'uniswap/src/constants/misc' -import { useCrossChainBalances } from 'uniswap/src/data/balances/hooks/useCrossChainBalances' -import { - useTokenBasicInfoPartsFragment, - useTokenBasicProjectPartsFragment, -} from 'uniswap/src/data/graphql/uniswap-data-api/fragments' -import { useBridgingTokenWithHighestBalance } from 'uniswap/src/features/bridging/hooks/tokens' -import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' -import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' -import { TokenList } from 'uniswap/src/features/dataApi/types' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' -import { useIsSupportedFiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/hooks' -import { pushNotification } from 'uniswap/src/features/notifications/slice/slice' -import { AppNotificationType } from 'uniswap/src/features/notifications/slice/types' -import { useOnChainNativeCurrencyBalance } from 'uniswap/src/features/portfolio/api' -import { ModalName } from 'uniswap/src/features/telemetry/constants' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { TokenWarningCard } from 'uniswap/src/features/tokens/warnings/TokenWarningCard' -import TokenWarningModal from 'uniswap/src/features/tokens/warnings/TokenWarningModal' -import { AZTEC_URL } from 'uniswap/src/features/transactions/swap/hooks/useSwapWarnings/getAztecUnavailableWarning' -import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' -import { useShouldShowAztecWarning } from 'uniswap/src/hooks/useShouldShowAztecWarning' -import type { CurrencyField } from 'uniswap/src/types/currency' -import { MobileScreens } from 'uniswap/src/types/screens/mobile' -import { AddressStringFormat, normalizeAddress } from 'uniswap/src/utils/addresses' -import { buildCurrencyId, isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' -import { useEvent } from 'utilities/src/react/hooks' -import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' -import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' - -export function TokenDetailsScreen({ route, navigation }: AppStackScreenProp): JSX.Element { - const { currencyId } = route.params - const normalizedCurrencyId = normalizeAddress(currencyId, AddressStringFormat.Lowercase) - - return ( - - - - ) -} - -function TokenDetailsWrapper(): JSX.Element { - const { chainId, address, currencyId } = useTokenDetailsContext() - const { data: token } = useTokenBasicInfoPartsFragment({ currencyId }) - - const traceProperties = useMemo( - () => ({ - chain: chainId, - address, - currencyName: token.name, - }), - [address, chainId, token.name], - ) - - return ( - - - - - - ) -} - -const TokenDetailsQuery = memo(function _TokenDetailsQuery(): JSX.Element { - const { currencyId, setError } = useTokenDetailsContext() - - const { error } = GraphQLApi.useTokenDetailsScreenQuery({ - variables: currencyIdToContractInput(currencyId), - pollInterval: PollingInterval.Normal, - notifyOnNetworkStatusChange: true, - returnPartialData: true, - }) - - useEffect(() => setError(error), [error, setError]) - - return -}) - -const TokenDetails = memo(function _TokenDetails(): JSX.Element { - const centerElement = useMemo(() => , []) - const rightElement = useMemo(() => , []) - - const inModal = useIsInModal(MobileScreens.Explore, true) - - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -}) - -const TokenDetailsErrorCard = memo(function _TokenDetailsErrorCard(): JSX.Element | null { - const apolloClient = useApolloClient() - const { error, setError } = useTokenDetailsContext() - - const onRetry = useEvent(() => { - setError(undefined) - apolloClient - .refetchQueries({ include: [GQLQueries.TokenDetailsScreen, GQLQueries.TokenPriceHistory] }) - .catch((e) => setError(e)) - }) - - return error ? ( - - - - ) : null -}) - -const TokenDetailsModals = memo(function _TokenDetailsModals(): JSX.Element { - const { t } = useTranslation() - const dispatch = useDispatch() - const { navigateToSwapFlow } = useWalletNavigation() - const isAztecDisabled = useFeatureFlag(FeatureFlags.DisableAztecToken) - - const { - chainId, - address, - activeTransactionType, - currencyInfo, - isTokenWarningModalOpen, - isContractAddressExplainerModalOpen, - isAztecWarningModalOpen, - closeTokenWarningModal, - closeContractAddressExplainerModal, - closeAztecWarningModal, - copyAddressToClipboard, - } = useTokenDetailsContext() - - const onAcknowledgeTokenWarning = useEvent(() => { - closeTokenWarningModal() - if (activeTransactionType !== undefined) { - navigateToSwapFlow({ currencyField: activeTransactionType, currencyAddress: address, currencyChainId: chainId }) - } - }) - - const onAcknowledgeContractAddressExplainer = useEvent(async (markViewed: boolean) => { - closeContractAddressExplainerModal(markViewed) - if (markViewed) { - await copyAddressToClipboard(address) - } - }) - - const onTokenWarningReportSuccess = useEvent(() => { - dispatch( - pushNotification({ - type: AppNotificationType.Success, - title: t('common.reported'), - }), - ) - }) - - return ( - <> - {isTokenWarningModalOpen && currencyInfo && ( - - )} - - {isContractAddressExplainerModalOpen && ( - - )} - - {isAztecWarningModalOpen && isAztecDisabled && ( - - - {t('swap.warning.aztecUnavailable.message')} - - - - } - acknowledgeText={t('common.button.close')} - onClose={closeAztecWarningModal} - onAcknowledge={closeAztecWarningModal} - /> - )} - - ) -}) - -const TokenDetailsActionButtonsWrapper = memo(function _TokenDetailsActionButtonsWrapper(): JSX.Element | null { - const { t } = useTranslation() - const insets = useAppInsets() - const activeAddress = useActiveAccountAddressWithThrow() - const { isTestnetModeEnabled } = useEnabledChains() - - const { - currencyId, - chainId, - address, - currencyInfo, - openTokenWarningModal, - openAztecWarningModal, - tokenColorLoading, - navigation, - } = useTokenDetailsContext() - const showAztecWarning = useShouldShowAztecWarning( - currencyInfo?.currency.isToken ? currencyInfo.currency.address : '', - ) - - const { navigateToFiatOnRamp, navigateToSwapFlow, navigateToSend, navigateToReceive } = useWalletNavigation() - - const token = useTokenBasicInfoPartsFragment({ currencyId }).data - - const isBlocked = currencyInfo?.safetyInfo?.tokenList === TokenList.Blocked - - const isNativeCurrency = isNativeCurrencyAddress(chainId, address) - const nativeCurrencyAddress = getChainInfo(chainId).nativeCurrency.address - - const { balance: nativeCurrencyBalance, isLoading: isNativeCurrencyBalanceLoading } = useOnChainNativeCurrencyBalance( - chainId, - activeAddress, - ) - const hasZeroNativeBalance = nativeCurrencyBalance && nativeCurrencyBalance.equalTo('0') - - const { currency: nativeFiatOnRampCurrency, isLoading: isNativeFiatOnRampCurrencyLoading } = - useIsSupportedFiatOnRampCurrency(buildCurrencyId(chainId, nativeCurrencyAddress)) - - const currentChainBalance = useTokenDetailsCurrentChainBalance() - - const hasTokenBalance = Boolean(currentChainBalance) - - const { currency: fiatOnRampCurrency, isLoading: isFiatOnRampCurrencyLoading } = - useIsSupportedFiatOnRampCurrency(currencyId) - - const { data: bridgingTokenWithHighestBalance, isLoading: isBridgingTokenLoading } = - useBridgingTokenWithHighestBalance({ - evmAddress: activeAddress, - currencyAddress: address, - currencyChainId: chainId, - }) - - const onPressSwap = useEvent((currencyField: CurrencyField) => { - if (showAztecWarning) { - openAztecWarningModal() - } else if (isBlocked) { - openTokenWarningModal() - } else { - navigateToSwapFlow({ currencyField, currencyAddress: address, currencyChainId: chainId }) - } - }) - - const onPressBuyFiatOnRamp = useEvent((isOfframp: boolean = false): void => { - if (showAztecWarning) { - openAztecWarningModal() - } else { - navigateToFiatOnRamp({ prefilledCurrency: fiatOnRampCurrency, isOfframp }) - } - }) - - const onPressGet = useEvent(() => { - if (showAztecWarning) { - openAztecWarningModal() - } else { - navigate(ModalName.BuyNativeToken, { - chainId, - currencyId, - }) - } - }) - - const onPressSend = useEvent(() => { - if (showAztecWarning) { - openAztecWarningModal() - } else { - navigateToSend({ currencyAddress: address, chainId }) - } - }) - - const onPressWithdraw = useEvent(() => { - setTimeout(() => { - navigate(ModalName.Wormhole, { - currencyInfo, - }) - }, MODAL_OPEN_WAIT_TIME) - }) - - const bridgedWithdrawalInfo = currencyInfo?.bridgedWithdrawalInfo - - const isScreenNavigationReady = useIsScreenNavigationReady({ navigation }) - - const getCTAVariant = useTokenDetailsCTAVariant({ - hasTokenBalance, - isNativeCurrency, - nativeFiatOnRampCurrency, - fiatOnRampCurrency, - bridgingTokenWithHighestBalance, - hasZeroNativeBalance, - tokenSymbol: token.symbol, - onPressBuyFiatOnRamp, - onPressGet, - onPressSwap, - }) - - const actionMenuOptions: MenuOptionItem[] = useMemo(() => { - const actions: MenuOptionItem[] = [] - - if (fiatOnRampCurrency) { - actions.push({ - label: t('common.button.buy'), - Icon: Bank, - onPress: () => onPressBuyFiatOnRamp(), - disabled: showAztecWarning, - }) - } - - if (bridgedWithdrawalInfo && hasTokenBalance) { - actions.push({ - label: t('common.withdraw'), - Icon: ArrowUpCircle, - onPress: () => onPressWithdraw(), - subheader: t('bridgedAsset.wormhole.toNativeChain', { nativeChainName: bridgedWithdrawalInfo.chain }), - actionType: 'external-link', - height: 56, - }) - } - - if (hasTokenBalance && fiatOnRampCurrency) { - actions.push({ - label: t('common.button.sell'), - Icon: ArrowUpCircle, - onPress: () => onPressBuyFiatOnRamp(true), - disabled: showAztecWarning, - }) - } - - if (hasTokenBalance) { - actions.push({ label: t('common.button.send'), Icon: SendRoundedAirplane, onPress: onPressSend }) - } - - // All cases have a receive action - actions.push({ label: t('common.button.receive'), Icon: ArrowDownCircle, onPress: navigateToReceive }) - - return actions - }, [ - fiatOnRampCurrency, - t, - bridgedWithdrawalInfo, - hasTokenBalance, - showAztecWarning, - onPressWithdraw, - onPressSend, - navigateToReceive, - onPressBuyFiatOnRamp, - ]) - - const hideActionButtons = - !isScreenNavigationReady || - tokenColorLoading || - isNativeCurrencyBalanceLoading || - isNativeFiatOnRampCurrencyLoading || - isFiatOnRampCurrencyLoading || - isBridgingTokenLoading - - return hideActionButtons ? null : ( - - - navigate(ModalName.TestnetMode, { - unsupported: true, - descriptionCopy: t('tdp.noTestnetSupportDescription'), - }) - : openTokenWarningModal - } - /> - - ) -}) - -const TokenBalancesWrapper = memo(function _TokenBalancesWrapper(): JSX.Element | null { - const activeAddress = useActiveAccountAddressWithThrow() - const { currencyId, isChainEnabled } = useTokenDetailsContext() - - const projectTokens = useTokenBasicProjectPartsFragment({ currencyId }).data.project?.tokens - - const crossChainTokens: Array<{ - address: string | null - chain: GraphQLApi.Chain - }> = [] - - for (const token of projectTokens ?? []) { - if (!token || !token.chain || token.address === undefined) { - continue - } - - crossChainTokens.push({ - address: token.address, - chain: token.chain, - }) - } - - const { currentChainBalance, otherChainBalances } = useCrossChainBalances({ - evmAddress: activeAddress, - currencyId, - crossChainTokens, - }) - - return isChainEnabled ? ( - - ) : null -}) - -const TokenWarningCardWrapper = memo(function _TokenWarningCardWrapper(): JSX.Element | null { - const { currencyInfo, openTokenWarningModal } = useTokenDetailsContext() - - return -}) diff --git a/apps/mobile/src/screens/TokenDetailsScreen/NetworkBalanceSheetContent.tsx b/apps/mobile/src/screens/TokenDetailsScreen/NetworkBalanceSheetContent.tsx new file mode 100644 index 00000000000..ef2472e3b0c --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/NetworkBalanceSheetContent.tsx @@ -0,0 +1,38 @@ +import { BottomSheetScrollView } from '@gorhom/bottom-sheet' +import { useTranslation } from 'react-i18next' +import { NetworkBalanceList } from 'src/components/TokenDetails/NetworkBalanceList' +import { Flex, Text } from 'ui/src' +import { spacing } from 'ui/src/theme' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' + +const STICKY_HEADER_INDICES = [0] +const NETWORK_SHEET_CONTENT_STYLE = { paddingBottom: spacing.spacing48 } + +interface NetworkBalanceSheetContentProps { + allChainBalances: PortfolioBalance[] + onSelectBalance: (balance: PortfolioBalance) => void +} + +export function NetworkBalanceSheetContent({ + allChainBalances, + onSelectBalance, +}: NetworkBalanceSheetContentProps): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {t('token.balances.chooseNetwork')} + + + + + + + ) +} diff --git a/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsActionButtonsWrapper.tsx b/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsActionButtonsWrapper.tsx new file mode 100644 index 00000000000..0f93b9b4487 --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsActionButtonsWrapper.tsx @@ -0,0 +1,352 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { FadeInDown } from 'react-native-reanimated' +import { MODAL_OPEN_WAIT_TIME } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/rootNavigation' +import { + TokenDetailsBuySellButtons, + TokenDetailsSwapButtons, +} from 'src/components/TokenDetails/TokenDetailsActionButtons' +import { useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { + useMultichainBuyVariant, + useTokenDetailsCTAVariant, +} from 'src/components/TokenDetails/useTokenDetailsCTAVariant' +import { useTokenDetailsCurrentChainBalance } from 'src/components/TokenDetails/useTokenDetailsCurrentChainBalance' +import { NetworkBalanceSheetContent } from 'src/screens/TokenDetailsScreen/NetworkBalanceSheetContent' +import { useHighestTvlChain } from 'src/screens/TokenDetailsScreen/useHighestTvlChain' +import { useNetworkBalanceSheet } from 'src/screens/TokenDetailsScreen/useNetworkBalanceSheet' +import { useIsScreenNavigationReady } from 'src/utils/useIsScreenNavigationReady' +import { ArrowDownCircle, ArrowUpCircle, Bank, QrCode, SendRoundedAirplane } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import type { MenuOptionItem } from 'uniswap/src/components/menus/ContextMenu' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { useTokenBasicInfoPartsFragment } from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import { useBridgingTokenWithHighestBalance } from 'uniswap/src/features/bridging/hooks/tokens' +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains' +import { type PortfolioBalance, TokenList } from 'uniswap/src/features/dataApi/types' +import { useIsSupportedFiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/hooks' +import { useChainGasToken } from 'uniswap/src/features/gas/hooks/useChainGasToken' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' +import { CurrencyField } from 'uniswap/src/types/currency' +import { buildCurrencyId, isNativeCurrencyAddress } from 'uniswap/src/utils/currencyId' +import { useEvent } from 'utilities/src/react/hooks' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +function getHighestBalanceEntry(balances: PortfolioBalance[]): PortfolioBalance { + return balances.reduce((best, current) => ((current.balanceUSD ?? 0) > (best.balanceUSD ?? 0) ? current : best)) +} + +export const TokenDetailsActionButtonsWrapper = memo( + function TokenDetailsActionButtonsWrapperInner(): JSX.Element | null { + const { t } = useTranslation() + const insets = useAppInsets() + const activeAddress = useActiveAccountAddressWithThrow() + const { isTestnetModeEnabled } = useEnabledChains() + const multichainTokenUxEnabled = useFeatureFlag(FeatureFlags.MultichainTokenUx) + + const { currencyId, chainId, address, currencyInfo, openTokenWarningModal, tokenColorLoading, navigation } = + useTokenDetailsContext() + + const { navigateToFiatOnRamp, navigateToSwapFlow, navigateToSend, navigateToReceive } = useWalletNavigation() + + const token = useTokenBasicInfoPartsFragment({ currencyId }).data + + const isBlocked = currencyInfo?.safetyInfo?.tokenList === TokenList.Blocked + + const isNativeCurrency = isNativeCurrencyAddress(chainId, address) + const nativeCurrencyAddress = getChainInfo(chainId).nativeCurrency.address + + const { gasBalance, isLoading: isGasBalanceLoading } = useChainGasToken({ chainId, accountAddress: activeAddress }) + const hasZeroGasBalance = gasBalance && gasBalance.equalTo('0') + + const { currency: nativeFiatOnRampCurrency, isLoading: isNativeFiatOnRampCurrencyLoading } = + useIsSupportedFiatOnRampCurrency(buildCurrencyId(chainId, nativeCurrencyAddress)) + + const currentChainBalance = useTokenDetailsCurrentChainBalance() + + const { currency: fiatOnRampCurrency, isLoading: isFiatOnRampCurrencyLoading } = + useIsSupportedFiatOnRampCurrency(currencyId) + + const { data: bridgingTokenWithHighestBalance, isLoading: isBridgingTokenLoading } = + useBridgingTokenWithHighestBalance({ + evmAddress: activeAddress, + currencyAddress: address, + currencyChainId: chainId, + }) + + const { + allChainBalances, + hasMultiChainBalances, + isNetworkSheetOpen, + openSellSheet, + openSendSheet, + onCloseNetworkSheet, + onSelectNetwork, + } = useNetworkBalanceSheet({ currencyId, chainId }) + + const hasTokenBalance = multichainTokenUxEnabled ? allChainBalances.length > 0 : Boolean(currentChainBalance) + + // For multichain UX: resolve the chain with the highest balance (computed once, used by multiple handlers) + const highestBalanceEntry = useMemo(() => { + if (!multichainTokenUxEnabled || !allChainBalances.length) { + return null + } + return getHighestBalanceEntry(allChainBalances) + }, [multichainTokenUxEnabled, allChainBalances]) + + const highestBalanceCurrencyId = highestBalanceEntry?.currencyInfo.currencyId ?? currencyId + + const { currency: highestBalanceFiatCurrency } = useIsSupportedFiatOnRampCurrency(highestBalanceCurrencyId) + + const { chainId: highestTvlChainId, address: highestTvlAddress } = useHighestTvlChain({ currencyId }) + + const onPressSwap = useEvent((currencyField: CurrencyField) => { + if (isBlocked) { + openTokenWarningModal() + } else { + navigateToSwapFlow({ currencyField, currencyAddress: address, currencyChainId: chainId }) + } + }) + + const onPressBuyFiatOnRamp = useEvent((isOfframp: boolean = false): void => { + navigateToFiatOnRamp({ prefilledCurrency: fiatOnRampCurrency, isOfframp }) + }) + + const onPressGet = useEvent(() => { + navigate(ModalName.BuyNativeToken, { + chainId, + currencyId, + }) + }) + + const onPressSend = useEvent(() => { + if (multichainTokenUxEnabled && hasMultiChainBalances) { + openSendSheet() + } else { + navigateToSend({ currencyAddress: address, chainId }) + } + }) + + const onPressWithdraw = useEvent(() => { + setTimeout(() => { + navigate(ModalName.Wormhole, { + currencyInfo, + }) + }, MODAL_OPEN_WAIT_TIME) + }) + + // Chain selection priority for the Buy (swap) flow: + // 1. Chain where the user holds the highest balance (they already have a position) + // 2. Chain with the highest TVL (best liquidity for new buyers with 0 balance) + // 3. Current TDP chain (fallback when data is unavailable) + const onPressBuy = useEvent(() => { + if (isBlocked) { + openTokenWarningModal() + return + } + if (multichainTokenUxEnabled && highestBalanceEntry) { + const { currency } = highestBalanceEntry.currencyInfo + const currencyAddress = currency.isToken ? currency.address : getNativeAddress(currency.chainId) + navigateToSwapFlow({ currencyField: CurrencyField.OUTPUT, currencyAddress, currencyChainId: currency.chainId }) + } else if (multichainTokenUxEnabled && highestTvlChainId) { + const currencyAddress = highestTvlAddress ?? getNativeAddress(highestTvlChainId) + navigateToSwapFlow({ currencyField: CurrencyField.OUTPUT, currencyAddress, currencyChainId: highestTvlChainId }) + } else { + navigateToSwapFlow({ currencyField: CurrencyField.OUTPUT, currencyAddress: address, currencyChainId: chainId }) + } + }) + + const onPressSell = useEvent(() => { + if (multichainTokenUxEnabled && hasMultiChainBalances) { + openSellSheet() + } else { + onPressSwap(CurrencyField.INPUT) + } + }) + + const onPressBuyWithCash = useEvent(() => { + navigateToFiatOnRamp({ prefilledCurrency: highestBalanceFiatCurrency ?? fiatOnRampCurrency }) + }) + + const onPressSellForCash = useEvent(() => { + navigateToFiatOnRamp({ prefilledCurrency: highestBalanceFiatCurrency ?? fiatOnRampCurrency, isOfframp: true }) + }) + + const bridgedWithdrawalInfo = currencyInfo?.bridgedWithdrawalInfo + + const isScreenNavigationReady = useIsScreenNavigationReady({ navigation }) + + const getCTAVariant = useTokenDetailsCTAVariant({ + hasTokenBalance, + isNativeCurrency, + nativeFiatOnRampCurrency, + fiatOnRampCurrency, + bridgingTokenWithHighestBalance, + hasZeroGasBalance, + tokenSymbol: token.symbol, + onPressBuyFiatOnRamp, + onPressGet, + onPressSwap, + }) + + const actionMenuOptions: MenuOptionItem[] = useMemo(() => { + const actions: MenuOptionItem[] = [] + + if (fiatOnRampCurrency) { + actions.push({ + label: t('common.button.buy'), + Icon: Bank, + onPress: onPressBuyFiatOnRamp, + }) + } + + if (bridgedWithdrawalInfo && hasTokenBalance) { + actions.push({ + label: t('common.withdraw'), + Icon: ArrowUpCircle, + onPress: onPressWithdraw, + subheader: t('bridgedAsset.wormhole.toNativeChain', { nativeChainName: bridgedWithdrawalInfo.chain }), + actionType: 'external-link', + height: 56, + }) + } + + if (hasTokenBalance && fiatOnRampCurrency) { + actions.push({ + label: t('common.button.sell'), + Icon: ArrowUpCircle, + onPress: () => onPressBuyFiatOnRamp(true), + }) + } + + if (hasTokenBalance) { + actions.push({ label: t('common.button.send'), Icon: SendRoundedAirplane, onPress: onPressSend }) + } + + // All cases have a receive action + actions.push({ label: t('common.button.receive'), Icon: ArrowDownCircle, onPress: navigateToReceive }) + + return actions + }, [ + fiatOnRampCurrency, + t, + bridgedWithdrawalInfo, + hasTokenBalance, + onPressWithdraw, + onPressSend, + navigateToReceive, + onPressBuyFiatOnRamp, + ]) + + const multichainActionMenuOptions: MenuOptionItem[] = useMemo(() => { + const actions: MenuOptionItem[] = [] + + if (hasTokenBalance) { + actions.push({ label: t('common.button.send'), Icon: SendRoundedAirplane, onPress: onPressSend }) + } + + actions.push({ label: t('common.button.receive'), Icon: QrCode, onPress: navigateToReceive }) + + if (highestBalanceFiatCurrency || fiatOnRampCurrency) { + actions.push({ label: t('fiatOnRamp.action.buyWithCash'), Icon: Bank, onPress: onPressBuyWithCash }) + } + + if (hasTokenBalance && (highestBalanceFiatCurrency || fiatOnRampCurrency)) { + actions.push({ label: t('fiatOnRamp.action.sellForCash'), Icon: ArrowUpCircle, onPress: onPressSellForCash }) + } + + if (bridgedWithdrawalInfo && hasTokenBalance) { + actions.push({ + label: t('common.withdraw'), + Icon: ArrowUpCircle, + onPress: onPressWithdraw, + subheader: t('bridgedAsset.wormhole.toNativeChain', { nativeChainName: bridgedWithdrawalInfo.chain }), + actionType: 'external-link', + height: 56, + }) + } + + return actions + }, [ + t, + hasTokenBalance, + bridgedWithdrawalInfo, + highestBalanceFiatCurrency, + fiatOnRampCurrency, + onPressSend, + navigateToReceive, + onPressBuyWithCash, + onPressSellForCash, + onPressWithdraw, + ]) + + const hideActionButtons = + !isScreenNavigationReady || + tokenColorLoading || + isGasBalanceLoading || + isNativeFiatOnRampCurrencyLoading || + isFiatOnRampCurrencyLoading || + isBridgingTokenLoading + + const onPressDisabled = isTestnetModeEnabled + ? (): void => + navigate(ModalName.TestnetMode, { + unsupported: true, + descriptionCopy: t('tdp.noTestnetSupportDescription'), + }) + : openTokenWarningModal + + const multichainBuyVariant = useMultichainBuyVariant({ + hasTokenBalance, + isNativeCurrency, + nativeFiatOnRampCurrency, + fiatOnRampCurrency, + bridgingTokenWithHighestBalance, + hasZeroGasBalance, + tokenSymbol: token.symbol, + onPressBuyWithCash, + onPressGet, + onPressBuy, + }) + + return hideActionButtons ? null : ( + + {multichainTokenUxEnabled ? ( + + ) : ( + + )} + + {multichainTokenUxEnabled && isNetworkSheetOpen && ( + + + + )} + + ) + }, +) diff --git a/apps/mobile/src/screens/TokenDetailsHeaders.tsx b/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsHeaders.tsx similarity index 90% rename from apps/mobile/src/screens/TokenDetailsHeaders.tsx rename to apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsHeaders.tsx index 66cadffd71e..ad05c6813e9 100644 --- a/apps/mobile/src/screens/TokenDetailsHeaders.tsx +++ b/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsHeaders.tsx @@ -1,3 +1,4 @@ +import { FeatureFlags, useFeatureFlag } from '@universe/gating' import React, { memo } from 'react' import { useTranslation } from 'react-i18next' import { FadeIn } from 'react-native-reanimated' @@ -11,7 +12,7 @@ import { Ellipsis } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { iconSizes, spacing } from 'ui/src/theme' import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' -import { ContextMenu } from 'uniswap/src/components/menus/ContextMenuV2' +import { ContextMenu } from 'uniswap/src/components/menus/ContextMenu' import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types' import { useTokenBasicInfoPartsFragment, @@ -23,7 +24,7 @@ import { TokenMenuActionType, useTokenContextMenuOptions, } from 'uniswap/src/features/portfolio/balances/hooks/useTokenContextMenuOptions' -import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { ElementName, ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { useEvent } from 'utilities/src/react/hooks' import { useBooleanState } from 'utilities/src/react/useBooleanState' @@ -33,8 +34,10 @@ export const HeaderTitleElement = memo(function HeaderTitleElement(): JSX.Elemen const { currencyId } = useTokenDetailsContext() + const multichainTokenUxEnabled = useFeatureFlag(FeatureFlags.MultichainTokenUx) const token = useTokenBasicInfoPartsFragment({ currencyId }).data const project = useTokenBasicProjectPartsFragment({ currencyId }).data.project + const isMultichainToken = multichainTokenUxEnabled && (project?.tokens?.length ?? 0) > 1 const logo = project?.logoUrl ?? undefined const symbol = token.symbol @@ -46,6 +49,7 @@ export const HeaderTitleElement = memo(function HeaderTitleElement(): JSX.Elemen { + closeTokenWarningModal() + if (activeTransactionType !== undefined) { + navigateToSwapFlow({ currencyField: activeTransactionType, currencyAddress: address, currencyChainId: chainId }) + } + }) + + const onAcknowledgeContractAddressExplainer = useEvent(async (markViewed: boolean) => { + closeContractAddressExplainerModal(markViewed) + if (markViewed) { + await copyAddressToClipboard(address) + } + }) + + const onTokenWarningReportSuccess = useEvent(() => { + dispatch( + pushNotification({ + type: AppNotificationType.Success, + title: t('common.reported'), + }), + ) + }) + + return ( + <> + {isTokenWarningModalOpen && currencyInfo && ( + + )} + + {isContractAddressExplainerModalOpen && ( + + )} + + ) +}) diff --git a/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsScreen.tsx b/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsScreen.tsx new file mode 100644 index 00000000000..1e266976135 --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/TokenDetailsScreen.tsx @@ -0,0 +1,199 @@ +import { useApolloClient } from '@apollo/client' +import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' +import { GQLQueries, GraphQLApi } from '@universe/api' +import { FeatureFlags, useFeatureFlag } from '@universe/gating' +import React, { memo, useEffect, useMemo } from 'react' +import { FadeInDown, FadeOutDown } from 'react-native-reanimated' +import type { AppStackScreenProp } from 'src/app/navigation/types' +import { HeaderScrollScreen } from 'src/components/layout/screens/HeaderScrollScreen' +import { useIsInModal } from 'src/components/modals/useIsInModal' +import { PriceExplorer } from 'src/components/PriceExplorer/PriceExplorer' +import { TokenBalances } from 'src/components/TokenDetails/TokenBalances' +import { TokenDetailsBridgedAssetSection } from 'src/components/TokenDetails/TokenDetailsBridgedAssetSection' +import { TokenDetailsContextProvider, useTokenDetailsContext } from 'src/components/TokenDetails/TokenDetailsContext' +import { TokenDetailsHeader } from 'src/components/TokenDetails/TokenDetailsHeader' +import { TokenDetailsLinks } from 'src/components/TokenDetails/TokenDetailsLinks' +import { TokenDetailsStats } from 'src/components/TokenDetails/TokenDetailsStats' +import { TokenPerformance } from 'src/components/TokenDetails/TokenPerformance' +import { TokenDetailsActionButtonsWrapper } from 'src/screens/TokenDetailsScreen/TokenDetailsActionButtonsWrapper' +import { HeaderRightElement, HeaderTitleElement } from 'src/screens/TokenDetailsScreen/TokenDetailsHeaders' +import { TokenDetailsModals } from 'src/screens/TokenDetailsScreen/TokenDetailsModals' +import { Flex, Separator } from 'ui/src' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { useCrossChainBalances } from 'uniswap/src/data/balances/hooks/useCrossChainBalances' +import { + useTokenBasicInfoPartsFragment, + useTokenBasicProjectPartsFragment, +} from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils/currencyIdToContractInput' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { TokenWarningCard } from 'uniswap/src/features/tokens/warnings/TokenWarningCard' +import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { AddressStringFormat, normalizeAddress } from 'uniswap/src/utils/addresses' +import { useEvent } from 'utilities/src/react/hooks' +import { useDelayedRender } from 'utilities/src/react/useDelayedRender' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +const CONTEXT_MENU_RENDER_DELAY_MS = 1000 + +export function TokenDetailsScreen({ route, navigation }: AppStackScreenProp): JSX.Element { + const { currencyId } = route.params + const normalizedCurrencyId = normalizeAddress(currencyId, AddressStringFormat.Lowercase) + + return ( + + + + ) +} + +function TokenDetailsWrapper(): JSX.Element { + const { chainId, address, currencyId } = useTokenDetailsContext() + const { data: token } = useTokenBasicInfoPartsFragment({ currencyId }) + + const traceProperties = useMemo( + () => ({ + chain: chainId, + address, + currencyName: token.name, + }), + [address, chainId, token.name], + ) + + return ( + + + + + + ) +} + +const TokenDetailsQuery = memo(function TokenDetailsQueryInner(): JSX.Element { + const { currencyId, setError } = useTokenDetailsContext() + const multichainTokenUxEnabled = useFeatureFlag(FeatureFlags.MultichainTokenUx) + + const { error } = GraphQLApi.useTokenDetailsScreenQuery({ + variables: { + ...currencyIdToContractInput(currencyId), + multichain: multichainTokenUxEnabled, + }, + pollInterval: PollingInterval.Normal, + notifyOnNetworkStatusChange: true, + returnPartialData: true, + }) + + useEffect(() => setError(error), [error, setError]) + + return +}) + +const TokenDetails = memo(function TokenDetailsInner(): JSX.Element { + const centerElement = useMemo(() => , []) + const rightElement = useMemo(() => , []) + const { isContentHidden } = useDelayedRender(CONTEXT_MENU_RENDER_DELAY_MS) + const multichainTokenUxEnabled = useFeatureFlag(FeatureFlags.MultichainTokenUx) + + const inModal = useIsInModal(MobileScreens.Explore, true) + + return ( + <> + + + + + + + + + + + + + + + + + {!multichainTokenUxEnabled && } + + + + + + + + + + + + + + + + ) +}) + +const TokenDetailsErrorCard = memo(function TokenDetailsErrorCardInner(): JSX.Element | null { + const apolloClient = useApolloClient() + const { error, setError } = useTokenDetailsContext() + + const onRetry = useEvent(() => { + setError(undefined) + apolloClient + .refetchQueries({ include: [GQLQueries.TokenDetailsScreen, GQLQueries.TokenPriceHistory] }) + .catch((e) => setError(e)) + }) + + return error ? ( + + + + ) : null +}) + +const TokenBalancesWrapper = memo(function TokenBalancesWrapperInner(): JSX.Element | null { + const activeAddress = useActiveAccountAddressWithThrow() + const { currencyId, isChainEnabled } = useTokenDetailsContext() + + const projectTokens = useTokenBasicProjectPartsFragment({ currencyId }).data.project?.tokens + + const crossChainTokens: Array<{ + address: string | null + chain: GraphQLApi.Chain + }> = [] + + for (const token of projectTokens ?? []) { + if (!token || !token.chain || token.address === undefined) { + continue + } + + crossChainTokens.push({ + address: token.address, + chain: token.chain, + }) + } + + const { currentChainBalance, otherChainBalances } = useCrossChainBalances({ + evmAddress: activeAddress, + currencyId, + crossChainTokens, + }) + + return isChainEnabled ? ( + + ) : null +}) + +const TokenWarningCardWrapper = memo(function TokenWarningCardWrapperInner(): JSX.Element | null { + const { currencyInfo, openTokenWarningModal } = useTokenDetailsContext() + + return +}) diff --git a/apps/mobile/src/screens/TokenDetailsScreen/useHighestTvlChain.test.ts b/apps/mobile/src/screens/TokenDetailsScreen/useHighestTvlChain.test.ts new file mode 100644 index 00000000000..a30ddc41d16 --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/useHighestTvlChain.test.ts @@ -0,0 +1,106 @@ +import { renderHook } from '@testing-library/react' +import { useHighestTvlChain } from 'src/screens/TokenDetailsScreen/useHighestTvlChain' +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +const mockFragmentData = jest.fn() + +jest.mock('uniswap/src/data/graphql/uniswap-data-api/fragments', () => ({ + useTokenProjectTokensTvlPartsFragment: () => ({ data: mockFragmentData() }), +})) + +describe(useHighestTvlChain, () => { + beforeEach(() => jest.clearAllMocks()) + + it('returns the chain with the highest TVL', () => { + mockFragmentData.mockReturnValue({ + project: { + tokens: [ + { chain: 'ETHEREUM', address: '0xEthAddress', market: { totalValueLocked: { value: 500_000 } } }, + { chain: 'BASE', address: '0xBaseAddress', market: { totalValueLocked: { value: 2_000_000 } } }, + { chain: 'ARBITRUM', address: '0xArbAddress', market: { totalValueLocked: { value: 300_000 } } }, + ], + }, + }) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xEthAddress' })) + + expect(result.current.chainId).toBe(UniverseChainId.Base) + expect(result.current.address).toBe('0xBaseAddress') + }) + + it('returns null when project tokens are empty', () => { + mockFragmentData.mockReturnValue({ project: { tokens: [] } }) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xEthAddress' })) + + expect(result.current.chainId).toBeNull() + expect(result.current.address).toBeNull() + }) + + it('returns null when project is undefined', () => { + mockFragmentData.mockReturnValue({}) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xEthAddress' })) + + expect(result.current.chainId).toBeNull() + expect(result.current.address).toBeNull() + }) + + it('returns null when all TVL values are 0', () => { + mockFragmentData.mockReturnValue({ + project: { + tokens: [ + { chain: 'ETHEREUM', address: '0xEthAddress', market: { totalValueLocked: { value: 0 } } }, + { chain: 'BASE', address: '0xBaseAddress', market: { totalValueLocked: { value: 0 } } }, + ], + }, + }) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xEthAddress' })) + + expect(result.current.chainId).toBeNull() + expect(result.current.address).toBeNull() + }) + + it('returns null when market data is missing', () => { + mockFragmentData.mockReturnValue({ + project: { + tokens: [ + { chain: 'ETHEREUM', address: '0xEthAddress', market: undefined }, + { chain: 'BASE', address: '0xBaseAddress', market: null }, + ], + }, + }) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xEthAddress' })) + + expect(result.current.chainId).toBeNull() + expect(result.current.address).toBeNull() + }) + + it('handles single-chain tokens', () => { + mockFragmentData.mockReturnValue({ + project: { + tokens: [{ chain: 'ETHEREUM', address: '0xEthAddress', market: { totalValueLocked: { value: 1_000_000 } } }], + }, + }) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xEthAddress' })) + + expect(result.current.chainId).toBe(UniverseChainId.Mainnet) + expect(result.current.address).toBe('0xEthAddress') + }) + + it('returns null address for native tokens', () => { + mockFragmentData.mockReturnValue({ + project: { + tokens: [{ chain: 'ETHEREUM', address: undefined, market: { totalValueLocked: { value: 1_000_000 } } }], + }, + }) + + const { result } = renderHook(() => useHighestTvlChain({ currencyId: '1-0xNative' })) + + expect(result.current.chainId).toBe(UniverseChainId.Mainnet) + expect(result.current.address).toBeNull() + }) +}) diff --git a/apps/mobile/src/screens/TokenDetailsScreen/useHighestTvlChain.ts b/apps/mobile/src/screens/TokenDetailsScreen/useHighestTvlChain.ts new file mode 100644 index 00000000000..2872261571b --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/useHighestTvlChain.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react' +import { useTokenProjectTokensTvlPartsFragment } from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' + +interface HighestTvlChainResult { + chainId: UniverseChainId | null + address: string | null +} + +/** + * Returns the chain with the highest TVL for a given token's project. + * Reads per-chain TVL data from Apollo cache (populated by the TokenDetailsScreen query). + * Returns nulls if data is unavailable or all TVL values are 0. + */ +export function useHighestTvlChain({ currencyId }: { currencyId: string }): HighestTvlChainResult { + const { data } = useTokenProjectTokensTvlPartsFragment({ currencyId }) + const projectTokens = data.project?.tokens + + return useMemo(() => { + if (!projectTokens?.length) { + return { chainId: null, address: null } + } + + let bestTvl = 0 + let bestIndex = -1 + + for (let i = 0; i < projectTokens.length; i++) { + const token = projectTokens[i] + if (!token) { + continue + } + const tvl = token.market?.totalValueLocked?.value ?? 0 + if (tvl > bestTvl) { + bestTvl = tvl + bestIndex = i + } + } + + const bestToken = bestIndex >= 0 ? projectTokens[bestIndex] : undefined + if (!bestToken) { + return { chainId: null, address: null } + } + + const chainId = fromGraphQLChain(bestToken.chain) + return { chainId: chainId ?? null, address: bestToken.address ?? null } + }, [projectTokens]) +} diff --git a/apps/mobile/src/screens/TokenDetailsScreen/useNetworkBalanceSheet.test.ts b/apps/mobile/src/screens/TokenDetailsScreen/useNetworkBalanceSheet.test.ts new file mode 100644 index 00000000000..d49d18cea9b --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/useNetworkBalanceSheet.test.ts @@ -0,0 +1,214 @@ +import { act, renderHook } from '@testing-library/react' +import { useNetworkBalanceSheet } from 'src/screens/TokenDetailsScreen/useNetworkBalanceSheet' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { CurrencyField } from 'uniswap/src/types/currency' + +const mockNavigateToSwapFlow = jest.fn() +const mockNavigateToSend = jest.fn() + +jest.mock('wallet/src/contexts/WalletNavigationContext', () => ({ + // oxlint-disable-next-line typescript/explicit-function-return-type + useWalletNavigation: () => ({ + navigateToSwapFlow: mockNavigateToSwapFlow, + navigateToSend: mockNavigateToSend, + }), +})) + +jest.mock('wallet/src/features/wallet/hooks', () => ({ + // oxlint-disable-next-line typescript/explicit-function-return-type + useActiveAccountAddressWithThrow: () => '0xTestAddress', +})) + +const mockCurrentChainBalance: PortfolioBalance = { + id: 'balance-mainnet', + cacheId: 'cache-mainnet', + quantity: 100, + balanceUSD: 100, + relativeChange24: 0, + isHidden: false, + currencyInfo: { + currencyId: `${UniverseChainId.Mainnet}-0xtoken`, + currency: { + chainId: UniverseChainId.Mainnet, + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + isNative: false, + isToken: true, + }, + logoUrl: null, + safetyLevel: null, + safetyInfo: null, + isSpam: false, + }, +} as unknown as PortfolioBalance + +const mockOtherChainBalance: PortfolioBalance = { + id: 'balance-base', + cacheId: 'cache-base', + quantity: 50, + balanceUSD: 50, + relativeChange24: 0, + isHidden: false, + currencyInfo: { + currencyId: `${UniverseChainId.Base}-0xtoken`, + currency: { + chainId: UniverseChainId.Base, + address: '0xBaseTokenAddress', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + isNative: false, + isToken: true, + }, + logoUrl: null, + safetyLevel: null, + safetyInfo: null, + isSpam: false, + }, +} as unknown as PortfolioBalance + +let mockCrossChainResult = { + currentChainBalance: null as PortfolioBalance | null, + otherChainBalances: null as PortfolioBalance[] | null, +} + +jest.mock('uniswap/src/data/balances/hooks/useCrossChainBalances', () => ({ + // oxlint-disable-next-line typescript/explicit-function-return-type + useCrossChainBalances: () => mockCrossChainResult, +})) + +jest.mock('uniswap/src/data/graphql/uniswap-data-api/fragments', () => ({ + // oxlint-disable-next-line typescript/explicit-function-return-type + useTokenBasicProjectPartsFragment: () => ({ + data: { project: { tokens: [] } }, + }), +})) + +const defaultArgs = { + currencyId: `${UniverseChainId.Mainnet}-0xtoken`, + chainId: UniverseChainId.Mainnet, +} + +describe(useNetworkBalanceSheet, () => { + beforeEach(() => { + jest.clearAllMocks() + mockCrossChainResult = { + currentChainBalance: null, + otherChainBalances: null, + } + }) + + describe('allChainBalances', () => { + it('returns empty array when no balances exist', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + expect(result.current.allChainBalances).toEqual([]) + }) + + it('returns current chain balance when only one chain has balance', () => { + mockCrossChainResult = { + currentChainBalance: mockCurrentChainBalance, + otherChainBalances: null, + } + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + expect(result.current.allChainBalances).toEqual([mockCurrentChainBalance]) + }) + + it('combines current and other chain balances', () => { + mockCrossChainResult = { + currentChainBalance: mockCurrentChainBalance, + otherChainBalances: [mockOtherChainBalance], + } + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + expect(result.current.allChainBalances).toEqual([mockCurrentChainBalance, mockOtherChainBalance]) + }) + }) + + describe('hasMultiChainBalances', () => { + it('returns false when only one chain balance exists', () => { + mockCrossChainResult = { + currentChainBalance: mockCurrentChainBalance, + otherChainBalances: null, + } + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + expect(result.current.hasMultiChainBalances).toBe(false) + }) + + it('returns true when multiple chain balances exist', () => { + mockCrossChainResult = { + currentChainBalance: mockCurrentChainBalance, + otherChainBalances: [mockOtherChainBalance], + } + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + expect(result.current.hasMultiChainBalances).toBe(true) + }) + }) + + describe('sheet state', () => { + it('starts with sheet closed', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + expect(result.current.isNetworkSheetOpen).toBe(false) + }) + + it('opens sheet when openSellSheet is called', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + act(() => result.current.openSellSheet()) + expect(result.current.isNetworkSheetOpen).toBe(true) + }) + + it('opens sheet when openSendSheet is called', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + act(() => result.current.openSendSheet()) + expect(result.current.isNetworkSheetOpen).toBe(true) + }) + + it('closes sheet when onCloseNetworkSheet is called', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + act(() => result.current.openSellSheet()) + expect(result.current.isNetworkSheetOpen).toBe(true) + act(() => result.current.onCloseNetworkSheet()) + expect(result.current.isNetworkSheetOpen).toBe(false) + }) + }) + + describe('onSelectNetwork', () => { + it('navigates to send when sheet was opened via openSendSheet', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + + act(() => result.current.openSendSheet()) + act(() => result.current.onSelectNetwork(mockOtherChainBalance)) + + expect(mockNavigateToSend).toHaveBeenCalledWith({ + currencyAddress: '0xBaseTokenAddress', + chainId: UniverseChainId.Base, + }) + expect(mockNavigateToSwapFlow).not.toHaveBeenCalled() + }) + + it('navigates to swap flow when sheet was opened via openSellSheet', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + + act(() => result.current.openSellSheet()) + act(() => result.current.onSelectNetwork(mockOtherChainBalance)) + + expect(mockNavigateToSwapFlow).toHaveBeenCalledWith({ + currencyField: CurrencyField.INPUT, + currencyAddress: '0xBaseTokenAddress', + currencyChainId: UniverseChainId.Base, + }) + expect(mockNavigateToSend).not.toHaveBeenCalled() + }) + + it('closes the sheet after selection', () => { + const { result } = renderHook(() => useNetworkBalanceSheet(defaultArgs)) + + act(() => result.current.openSellSheet()) + expect(result.current.isNetworkSheetOpen).toBe(true) + + act(() => result.current.onSelectNetwork(mockOtherChainBalance)) + expect(result.current.isNetworkSheetOpen).toBe(false) + }) + }) +}) diff --git a/apps/mobile/src/screens/TokenDetailsScreen/useNetworkBalanceSheet.ts b/apps/mobile/src/screens/TokenDetailsScreen/useNetworkBalanceSheet.ts new file mode 100644 index 00000000000..e3e98f35438 --- /dev/null +++ b/apps/mobile/src/screens/TokenDetailsScreen/useNetworkBalanceSheet.ts @@ -0,0 +1,100 @@ +import { GraphQLApi } from '@universe/api' +import { useMemo, useState } from 'react' +import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { useCrossChainBalances } from 'uniswap/src/data/balances/hooks/useCrossChainBalances' +import { useTokenBasicProjectPartsFragment } from 'uniswap/src/data/graphql/uniswap-data-api/fragments' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { CurrencyField } from 'uniswap/src/types/currency' +import { useEvent } from 'utilities/src/react/hooks' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +type NetworkSheetAction = 'sell' | 'send' + +interface UseNetworkBalanceSheetParams { + currencyId: string + chainId: UniverseChainId +} + +interface UseNetworkBalanceSheetResult { + allChainBalances: PortfolioBalance[] + hasMultiChainBalances: boolean + isNetworkSheetOpen: boolean + openSellSheet: () => void + openSendSheet: () => void + onCloseNetworkSheet: () => void + onSelectNetwork: (balance: PortfolioBalance) => void +} + +export function useNetworkBalanceSheet({ + currencyId, + chainId, +}: UseNetworkBalanceSheetParams): UseNetworkBalanceSheetResult { + const activeAddress = useActiveAccountAddressWithThrow() + const { navigateToSwapFlow, navigateToSend } = useWalletNavigation() + + // Cross-chain balances (Apollo-cached, no extra network requests) + const projectTokens = useTokenBasicProjectPartsFragment({ currencyId }).data.project?.tokens + const crossChainTokens = useMemo(() => { + const result: Array<{ address: string | null; chain: GraphQLApi.Chain }> = [] + for (const projectToken of projectTokens ?? []) { + if (projectToken?.chain && projectToken.address !== undefined) { + const chainIdForToken = fromGraphQLChain(projectToken.chain) + if (chainIdForToken && chainIdForToken !== chainId) { + result.push({ address: projectToken.address, chain: projectToken.chain }) + } + } + } + return result + }, [projectTokens, chainId]) + + const { currentChainBalance: crossChainCurrentBalance, otherChainBalances } = useCrossChainBalances({ + evmAddress: activeAddress, + currencyId, + crossChainTokens, + }) + + const allChainBalances = useMemo(() => { + const others = otherChainBalances ?? [] + return crossChainCurrentBalance ? [crossChainCurrentBalance, ...others] : others + }, [crossChainCurrentBalance, otherChainBalances]) + + const hasMultiChainBalances = allChainBalances.length > 1 + + // Sheet state + const [networkSheetAction, setNetworkSheetAction] = useState(null) + const isNetworkSheetOpen = networkSheetAction !== null + + const openSellSheet = useEvent(() => setNetworkSheetAction('sell')) + const openSendSheet = useEvent(() => setNetworkSheetAction('send')) + const onCloseNetworkSheet = useEvent(() => setNetworkSheetAction(null)) + + const onSelectNetwork = useEvent((balance: PortfolioBalance) => { + const action = networkSheetAction + setNetworkSheetAction(null) + const { currency } = balance.currencyInfo + const currencyAddress = currency.isToken ? currency.address : getNativeAddress(currency.chainId) + + if (action === 'send') { + navigateToSend({ currencyAddress, chainId: currency.chainId }) + } else { + navigateToSwapFlow({ + currencyField: CurrencyField.INPUT, + currencyAddress, + currencyChainId: currency.chainId, + }) + } + }) + + return { + allChainBalances, + hasMultiChainBalances, + isNetworkSheetOpen, + openSellSheet, + openSendSheet, + onCloseNetworkSheet, + onSelectNetwork, + } +} diff --git a/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx b/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx index 5d5bf6a3765..85e5f46dcd2 100644 --- a/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx +++ b/apps/mobile/src/screens/ViewPrivateKeys/ViewPrivateKeysScreen.tsx @@ -84,7 +84,7 @@ export function ViewPrivateKeysScreen({ navigation, route }: Props): JSX.Element {t('privateKeys.export.modal.speedbump.subtitle')} - + @@ -117,7 +117,7 @@ export function ViewPrivateKeysScreen({ navigation, route }: Props): JSX.Element justifyContent="space-between" p="$spacing12" borderRadius="$rounded16" - borderWidth={1} + borderWidth="$spacing1" borderColor="$surface3" gap="$spacing8" > diff --git a/apps/mobile/src/screens/components/hashcash/LogSection.tsx b/apps/mobile/src/screens/components/hashcash/LogSection.tsx new file mode 100644 index 00000000000..b08d303b518 --- /dev/null +++ b/apps/mobile/src/screens/components/hashcash/LogSection.tsx @@ -0,0 +1,50 @@ +import React, { memo } from 'react' +import { type LogEntry, useHashcashBenchmarkStore } from 'src/screens/stores/hashcashBenchmarkStore' +import { Flex, Text, TouchableArea } from 'ui/src' + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +const LogEntryRow = memo(function LogEntryRow({ log, index }: { log: LogEntry; index: number }): JSX.Element { + return ( + + {formatTime(log.timestamp)} - {log.message} + + ) +}) + +export const LogSection = memo(function LogSection(): JSX.Element | null { + const logs = useHashcashBenchmarkStore((state) => state.logs) + const clearLogs = useHashcashBenchmarkStore((state) => state.clearLogs) + + if (logs.length === 0) { + return null + } + + return ( + + + Operation Log + + + Clear + + + + + {logs.map((log, index) => ( + + ))} + + ) +}) diff --git a/apps/mobile/src/screens/components/hashcash/ProgressSection.tsx b/apps/mobile/src/screens/components/hashcash/ProgressSection.tsx new file mode 100644 index 00000000000..513266a30ce --- /dev/null +++ b/apps/mobile/src/screens/components/hashcash/ProgressSection.tsx @@ -0,0 +1,69 @@ +import React, { memo } from 'react' +import { useHashcashBenchmarkStore } from 'src/screens/stores/hashcashBenchmarkStore' +import { Flex, Text } from 'ui/src' +import { useShallow } from 'zustand/shallow' + +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms.toFixed(0)}ms` + } + return `${(ms / 1000).toFixed(2)}s` +} + +export const ProgressSection = memo(function ProgressSection(): JSX.Element | null { + const progress = useHashcashBenchmarkStore( + useShallow((state) => ({ + isRunning: state.progress.isRunning, + currentImpl: state.progress.currentImpl, + difficulty: state.progress.difficulty, + startTime: state.progress.startTime, + elapsedMs: state.progress.elapsedMs, + estimatedAttempts: state.progress.estimatedAttempts, + })), + ) + + if (!progress.isRunning) { + return null + } + + return ( + + Current Progress + + + + Running: + + + {progress.currentImpl === 'native' ? 'Native' : 'JavaScript'} @ Difficulty {progress.difficulty} + + + + {progress.startTime !== null ? ( + <> + + + Elapsed: + + + {formatDuration(progress.elapsedMs)} + + + + + + Est. Attempts: + + + ~{progress.estimatedAttempts.toLocaleString()} + + + + ) : ( + + JS blocks main thread - no progress updates + + )} + + ) +}) diff --git a/apps/mobile/src/screens/components/hashcash/ResultCard.tsx b/apps/mobile/src/screens/components/hashcash/ResultCard.tsx new file mode 100644 index 00000000000..a71e2f70cd5 --- /dev/null +++ b/apps/mobile/src/screens/components/hashcash/ResultCard.tsx @@ -0,0 +1,99 @@ +import React, { memo } from 'react' +import type { BenchmarkResult } from 'src/screens/stores/hashcashBenchmarkStore' +import { Flex, Text } from 'ui/src' + +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms.toFixed(0)}ms` + } + return `${(ms / 1000).toFixed(2)}s` +} + +export const ResultCard = memo(function ResultCard({ + difficulty, + native, + js, +}: { + difficulty: string + native: BenchmarkResult | undefined + js: BenchmarkResult | undefined +}): JSX.Element { + const speedup = native && js ? js.timeMs / native.timeMs : null + + return ( + + Difficulty {difficulty} + + {native && ( + + + + Native + + + {formatDuration(native.timeMs)} + + + + + Attempts: + + + {native.attempts.toLocaleString()} + + + + + Hash Rate: + + + {native.hashRate.toLocaleString()} h/s + + + + )} + + {js && ( + + + + JavaScript + + + {formatDuration(js.timeMs)} + + + + + Attempts: + + + {js.attempts.toLocaleString()} + + + + + Hash Rate: + + + {js.hashRate.toLocaleString()} h/s + + + + )} + + {speedup && ( + + 1 ? '$statusSuccess' : '$statusCritical'} + textAlign="center" + > + {speedup > 1 ? `Native is ${speedup.toFixed(1)}x faster` : `JS is ${(1 / speedup).toFixed(1)}x faster`} + + + )} + + ) +}) diff --git a/apps/mobile/src/screens/components/sessions/CurrentOperationSection.tsx b/apps/mobile/src/screens/components/sessions/CurrentOperationSection.tsx new file mode 100644 index 00000000000..621538491a8 --- /dev/null +++ b/apps/mobile/src/screens/components/sessions/CurrentOperationSection.tsx @@ -0,0 +1,19 @@ +import React, { memo } from 'react' +import { useSessionsDebugStore } from 'src/screens/stores/sessionsDebugStore' +import { Flex, Text } from 'ui/src' + +export const CurrentOperationSection = memo(function CurrentOperationSection(): JSX.Element | null { + const currentOperation = useSessionsDebugStore((state) => state.currentOperation) + + if (!currentOperation) { + return null + } + + return ( + + + {currentOperation} + + + ) +}) diff --git a/apps/mobile/src/screens/components/sessions/HashcashProgressSection.tsx b/apps/mobile/src/screens/components/sessions/HashcashProgressSection.tsx new file mode 100644 index 00000000000..1c4f0d96d46 --- /dev/null +++ b/apps/mobile/src/screens/components/sessions/HashcashProgressSection.tsx @@ -0,0 +1,81 @@ +import React, { memo } from 'react' +import { useSessionsDebugStore } from 'src/screens/stores/sessionsDebugStore' +import { Flex, Text } from 'ui/src' +import { useShallow } from 'zustand/shallow' + +export const HashcashProgressSection = memo(function HashcashProgressSection(): JSX.Element | null { + const progress = useSessionsDebugStore( + useShallow((state) => ({ + isRunning: state.hashcashProgress.isRunning, + difficulty: state.hashcashProgress.difficulty, + estimatedAttempts: state.hashcashProgress.estimatedAttempts, + elapsedMs: state.hashcashProgress.elapsedMs, + actualResult: state.hashcashProgress.actualResult, + })), + ) + + if (!progress.isRunning && !progress.actualResult) { + return null + } + + return ( + + Hashcash Progress + + + + Status: + + + {progress.isRunning ? 'Solving...' : 'Complete'} + + + + + + Difficulty: + + + {progress.difficulty} + + + + + + Attempts: + + + {progress.actualResult + ? (progress.actualResult.iterationCount ?? 0).toLocaleString() + : `~${progress.estimatedAttempts.toLocaleString()}`} + + + + + + Time: + + + {progress.actualResult + ? `${(progress.actualResult.durationMs / 1000).toFixed(2)}s` + : `${(progress.elapsedMs / 1000).toFixed(2)}s`} + + + + {progress.actualResult && ( + + + Hash Rate: + + + {((progress.actualResult.iterationCount ?? 0) / (progress.actualResult.durationMs / 1000)).toLocaleString( + undefined, + { maximumFractionDigits: 0 }, + )}{' '} + h/s + + + )} + + ) +}) diff --git a/apps/mobile/src/screens/components/sessions/LogSection.tsx b/apps/mobile/src/screens/components/sessions/LogSection.tsx new file mode 100644 index 00000000000..150b4f4393b --- /dev/null +++ b/apps/mobile/src/screens/components/sessions/LogSection.tsx @@ -0,0 +1,50 @@ +import React, { memo } from 'react' +import { type LogEntry, useSessionsDebugStore } from 'src/screens/stores/sessionsDebugStore' +import { Flex, Text, TouchableArea } from 'ui/src' + +function formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +const LogEntryRow = memo(function LogEntryRow({ log, index }: { log: LogEntry; index: number }): JSX.Element { + return ( + + {formatTime(log.timestamp)} - {log.message} + + ) +}) + +export const LogSection = memo(function LogSection(): JSX.Element | null { + const logs = useSessionsDebugStore((state) => state.logs) + const clearLogs = useSessionsDebugStore((state) => state.clearLogs) + + if (logs.length === 0) { + return null + } + + return ( + + + Operation Log + + + Clear + + + + + {logs.map((log, index) => ( + + ))} + + ) +}) diff --git a/apps/mobile/src/screens/stores/hashcashBenchmarkStore.ts b/apps/mobile/src/screens/stores/hashcashBenchmarkStore.ts new file mode 100644 index 00000000000..8dd22d77834 --- /dev/null +++ b/apps/mobile/src/screens/stores/hashcashBenchmarkStore.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand' + +export type Implementation = 'native' | 'js' | 'both' + +export interface BenchmarkResult { + implementation: 'native' | 'js' + difficulty: number + counter: string | null + attempts: number + timeMs: number + hashRate: number +} + +interface BenchmarkProgress { + isRunning: boolean + currentImpl: 'native' | 'js' | null + difficulty: number + startTime: number | null + elapsedMs: number + estimatedAttempts: number +} + +export interface LogEntry { + timestamp: Date + message: string + type: 'info' | 'success' | 'error' +} + +interface HashcashBenchmarkState { + // State + results: BenchmarkResult[] + selectedDifficulty: number + selectedImpl: Implementation + logs: LogEntry[] + progress: BenchmarkProgress + measuredHashRate: number | null + isCancelled: boolean + + // Actions + setDifficulty: (difficulty: number) => void + setImpl: (impl: Implementation) => void + addResult: (result: BenchmarkResult) => void + clearResults: () => void + addLog: (message: string, type?: 'info' | 'success' | 'error') => void + clearLogs: () => void + startBenchmark: (impl: 'native' | 'js', difficulty: number) => void + updateProgress: (elapsedMs: number, estimatedAttempts: number) => void + endBenchmark: () => void + cancel: () => void + resetCancel: () => void +} + +const initialProgress: BenchmarkProgress = { + isRunning: false, + currentImpl: null, + difficulty: 0, + startTime: null, + elapsedMs: 0, + estimatedAttempts: 0, +} + +export const useHashcashBenchmarkStore = create((set) => ({ + results: [], + selectedDifficulty: 2, + selectedImpl: 'both', + logs: [], + progress: initialProgress, + measuredHashRate: null, + isCancelled: false, + + setDifficulty: (difficulty): void => set({ selectedDifficulty: difficulty }), + + setImpl: (impl): void => set({ selectedImpl: impl }), + + addResult: (result): void => + set((state) => ({ + results: [...state.results, result], + // Auto-update measured hash rate from native results + measuredHashRate: + result.implementation === 'native' && result.hashRate > 0 ? result.hashRate : state.measuredHashRate, + })), + + clearResults: (): void => set({ results: [] }), + + addLog: (message, type = 'info'): void => + set((state) => ({ + logs: [...state.logs.slice(-19), { timestamp: new Date(), message, type }], + })), + + clearLogs: (): void => set({ logs: [] }), + + startBenchmark: (impl, difficulty): void => + set({ + isCancelled: false, + progress: { + isRunning: true, + currentImpl: impl, + difficulty, + // null for JS because it blocks the thread - no progress updates possible + startTime: impl === 'native' ? performance.now() : null, + elapsedMs: 0, + estimatedAttempts: 0, + }, + }), + + updateProgress: (elapsedMs, estimatedAttempts): void => + set((state) => ({ + progress: { ...state.progress, elapsedMs, estimatedAttempts }, + })), + + endBenchmark: (): void => set({ progress: initialProgress }), + + cancel: (): void => set({ isCancelled: true, progress: initialProgress }), + + resetCancel: (): void => set({ isCancelled: false }), +})) diff --git a/apps/mobile/src/screens/stores/sessionsDebugStore.ts b/apps/mobile/src/screens/stores/sessionsDebugStore.ts new file mode 100644 index 00000000000..4f90a1dc836 --- /dev/null +++ b/apps/mobile/src/screens/stores/sessionsDebugStore.ts @@ -0,0 +1,112 @@ +import type { ChallengeResponse, HashcashSolveAnalytics } from '@universe/sessions' +import { create } from 'zustand' + +interface SessionState { + sessionId: string | null + deviceId: string | null + uniswapIdentifier: string | null +} + +export interface LogEntry { + timestamp: Date + message: string + type: 'info' | 'success' | 'error' +} + +interface HashcashProgress { + isRunning: boolean + difficulty: number + estimatedAttempts: number + elapsedMs: number + startTime: number | null + actualResult: HashcashSolveAnalytics | null +} + +interface SessionsDebugState { + // State + session: SessionState + challenge: ChallengeResponse | null + isLoading: boolean + currentOperation: string | null + logs: LogEntry[] + hashcashProgress: HashcashProgress + + // Actions + setSession: (session: SessionState) => void + setChallenge: (challenge: ChallengeResponse | null) => void + startOperation: (operation: string) => void + endOperation: () => void + addLog: (message: string, type?: 'info' | 'success' | 'error') => void + clearLogs: () => void + startHashcash: (difficulty: number) => void + updateHashcashProgress: (elapsedMs: number, estimatedAttempts: number) => void + completeHashcash: (result: HashcashSolveAnalytics) => void + stopHashcash: () => void + reset: () => void +} + +const initialHashcashProgress: HashcashProgress = { + isRunning: false, + difficulty: 0, + estimatedAttempts: 0, + elapsedMs: 0, + startTime: null, + actualResult: null, +} + +const initialState = { + session: { sessionId: null, deviceId: null, uniswapIdentifier: null }, + challenge: null, + isLoading: false, + currentOperation: null, + logs: [] as LogEntry[], + hashcashProgress: initialHashcashProgress, +} + +export const useSessionsDebugStore = create((set) => ({ + ...initialState, + + setSession: (session): void => set({ session }), + + setChallenge: (challenge): void => set({ challenge }), + + startOperation: (operation): void => set({ isLoading: true, currentOperation: operation }), + + endOperation: (): void => set({ isLoading: false, currentOperation: null }), + + addLog: (message, type = 'info'): void => + set((state) => ({ + logs: [...state.logs.slice(-19), { timestamp: new Date(), message, type }], + })), + + clearLogs: (): void => set({ logs: [] }), + + startHashcash: (difficulty): void => + set({ + hashcashProgress: { + isRunning: true, + difficulty, + estimatedAttempts: 0, + elapsedMs: 0, + startTime: performance.now(), + actualResult: null, + }, + }), + + updateHashcashProgress: (elapsedMs, estimatedAttempts): void => + set((state) => ({ + hashcashProgress: { ...state.hashcashProgress, elapsedMs, estimatedAttempts }, + })), + + completeHashcash: (result): void => + set((state) => ({ + hashcashProgress: { ...state.hashcashProgress, isRunning: false, actualResult: result }, + })), + + stopHashcash: (): void => + set((state) => ({ + hashcashProgress: { ...state.hashcashProgress, isRunning: false }, + })), + + reset: (): void => set(initialState), +})) diff --git a/apps/mobile/src/test/fixtures/redux.ts b/apps/mobile/src/test/fixtures/redux.ts index ef3153c669c..4122efaa4b9 100644 --- a/apps/mobile/src/test/fixtures/redux.ts +++ b/apps/mobile/src/test/fixtures/redux.ts @@ -1,7 +1,7 @@ import { PreloadedState } from 'redux' import { MobileState } from 'src/app/mobileReducer' -import { ModalsState } from 'src/features/modals/ModalsState' import { initialModalsState } from 'src/features/modals/modalSlice' +import { ModalsState } from 'src/features/modals/ModalsState' import { createFixture } from 'uniswap/src/test/utils' import { Account } from 'wallet/src/features/wallet/accounts/types' import { preloadedWalletPackageState } from 'wallet/src/test/fixtures' diff --git a/apps/mobile/src/test/render.tsx b/apps/mobile/src/test/render.tsx index 3af71d6b99b..496d80081d7 100644 --- a/apps/mobile/src/test/render.tsx +++ b/apps/mobile/src/test/render.tsx @@ -11,13 +11,12 @@ import { } from '@testing-library/react-native' import { GraphQLApi } from '@universe/api' import React, { PropsWithChildren } from 'react' -import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider' import type { MobileState } from 'src/app/mobileReducer' +import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider' import { navigationRef } from 'src/app/navigation/navigationRef' import { store as appStore, persistedReducer } from 'src/app/store' import { UniswapProvider } from 'uniswap/src/contexts/UniswapContext' import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext' -import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' import { AutoMockedApolloProvider } from 'uniswap/src/test/mocks' import { mockUniswapContext } from 'uniswap/src/test/render' import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider' @@ -48,7 +47,7 @@ export function renderWithProviders( store = configureStore({ reducer: persistedReducer, preloadedState, - middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(fiatOnRampAggregatorApi.middleware), + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), }), ...renderOptions }: ExtendedRenderOptions = {}, @@ -118,7 +117,7 @@ export function renderHookWithProviders( store = configureStore({ reducer: persistedReducer, preloadedState, - middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(fiatOnRampAggregatorApi.middleware), + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), }), ...renderOptions } = (hookOptions ?? {}) as ExtendedRenderHookOptions

diff --git a/apps/mobile/src/utils/reanimated.ts b/apps/mobile/src/utils/reanimated.ts index de1d1f9353b..863cb8d9a64 100644 --- a/apps/mobile/src/utils/reanimated.ts +++ b/apps/mobile/src/utils/reanimated.ts @@ -1,4 +1,4 @@ -/* eslint-disable max-lines */ +/* oxlint-disable max-lines */ /** * Util to format numbers inside reanimated worklets. * @@ -267,6 +267,7 @@ const currencyFormatMap = { 'zh-Hant': 'pre', } +// oxlint-disable-next-line typescript/no-duplicate-type-constituents -- biome-parity: oxlint is stricter here export type Language = keyof typeof currencyFormatMap | keyof typeof transformForLocale const currencySymbols: { [key: string]: string } = { diff --git a/apps/mobile/src/utils/useNavigationHeader.tsx b/apps/mobile/src/utils/useNavigationHeader.tsx index d57dd422903..19304509dc7 100644 --- a/apps/mobile/src/utils/useNavigationHeader.tsx +++ b/apps/mobile/src/utils/useNavigationHeader.tsx @@ -3,7 +3,6 @@ import React, { ReactNode, useEffect } from 'react' import { HeaderSkipButton } from 'src/app/navigation/components' import { OnboardingStackParamList } from 'src/app/navigation/types' import { BackButton } from 'src/components/buttons/BackButton' -import { iconSizes } from 'ui/src/theme' import { UnitagStackParamList } from 'uniswap/src/types/screens/mobile' /** @@ -16,7 +15,7 @@ export function useNavigationHeader( ): void { useEffect((): void => { navigation.setOptions({ - headerLeft: () => , + headerLeft: () => , headerRight: onSkip ? (_props): ReactNode => : undefined, }) }, [navigation, onSkip]) diff --git a/apps/mobile/src/utils/useOpenBackupReminderModal.ts b/apps/mobile/src/utils/useOpenBackupReminderModal.ts deleted file mode 100644 index 761eeb6565a..00000000000 --- a/apps/mobile/src/utils/useOpenBackupReminderModal.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useNavigation } from '@react-navigation/native' -import { useEffect } from 'react' -import { useSelector } from 'react-redux' -import { AccountType } from 'uniswap/src/features/accounts/types' -import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances/balancesRest' -import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { ONE_DAY_MS } from 'utilities/src/time/time' -import { selectBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/selectors' -import { Account } from 'wallet/src/features/wallet/accounts/types' -import { hasExternalBackup } from 'wallet/src/features/wallet/accounts/utils' -import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' - -const MIN_PORTFOLIO_VALUE_FOR_BACKUP_REMINDER = 100 // $100 USD -const BACKUP_REMINDER_COOLDOWN_MS = ONE_DAY_MS - -export function useOpenBackupReminderModal(activeAccount: Account): void { - const navigation = useNavigation() - const activeAddress = useActiveAccountAddress() - const { data: portfolioData } = usePortfolioTotalValue({ - evmAddress: activeAddress ?? undefined, - }) - - const isBackupReminderModalOpen = navigation - .getState() - ?.routes.some((route) => route.name === ModalName.BackupReminder) - const isBackupReminderWarningModalOpen = navigation - .getState() - ?.routes.some((route) => route.name === ModalName.BackupReminderWarning) - - const backupReminderLastSeenTs = useSelector(selectBackupReminderLastSeenTs) - const externalBackups = hasExternalBackup(activeAccount) - - const isSignerAccount = activeAccount.type === AccountType.SignerMnemonic - - // Check portfolio value threshold - const portfolioValue = portfolioData?.balanceUSD ?? 0 - const hasMinimumPortfolioValue = portfolioValue >= MIN_PORTFOLIO_VALUE_FOR_BACKUP_REMINDER - - // Check if 24 hours have passed since last seen - const now = Date.now() - const timeSinceLastSeen = backupReminderLastSeenTs ? now - backupReminderLastSeenTs : Infinity - const has24HoursPassed = timeSinceLastSeen >= BACKUP_REMINDER_COOLDOWN_MS - - const shouldOpenBackupReminderModal = - !isBackupReminderModalOpen && - !isBackupReminderWarningModalOpen && - isSignerAccount && - !externalBackups && - hasMinimumPortfolioValue && - has24HoursPassed - - useEffect(() => { - if (shouldOpenBackupReminderModal) { - const timeoutId = setTimeout(() => { - navigation.navigate(ModalName.BackupReminder as never) - }, 1000) - - return () => clearTimeout(timeoutId) - } - - return undefined - }, [shouldOpenBackupReminderModal, navigation]) -} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 9a7067e0a47..59614477d3b 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -5,16 +5,13 @@ "exclude": [".storybook/storybook.requires.ts"], "references": [ { - "path": "../../packages/wallet" + "path": "../../packages/sessions" }, { - "path": "../../packages/utilities" + "path": "../../packages/notifications" }, { - "path": "../../packages/uniswap" - }, - { - "path": "../../packages/sessions" + "path": "../../packages/hashcash-native" }, { "path": "../../packages/gating" @@ -23,12 +20,23 @@ "path": "../../packages/api" }, { - "path": "../../packages/gating" + "path": "../../packages/wallet" + }, + { + "path": "../../packages/utilities" + }, + { + "path": "../../packages/ui" + }, + { + "path": "../../packages/uniswap" } ], "compilerOptions": { - "baseUrl": "./", - "types": ["jest", "node"] + "types": ["jest", "node"], + "paths": { + "src/*": ["./src/*"] + } }, - "include": ["**/*.ts", "**/*.tsx", "global.d.ts"] + "include": ["**/*.ts", "**/*.tsx", "src/polyfills/**/*.js", "global.d.ts"] } diff --git a/apps/mobile/tsconfig.eslint.json b/apps/mobile/tsconfig.lint.json similarity index 100% rename from apps/mobile/tsconfig.eslint.json rename to apps/mobile/tsconfig.lint.json diff --git a/apps/web/.depcheckrc b/apps/web/.depcheckrc index 6d78dbec8d8..021ee269474 100644 --- a/apps/web/.depcheckrc +++ b/apps/web/.depcheckrc @@ -24,7 +24,6 @@ ignores: [ '@datadog/datadog-ci', # Dependencies that depcheck thinks are missing but are actually present or never used 'stories', - 'porto', ## package.json scripts 'esbuild-register', ## GraphQL @@ -39,7 +38,6 @@ ignores: [ 'resize-observer-polyfill', ## Linting and Babel '@babel/preset-env', - 'eslint-plugin-import', 'terser-webpack-plugin', ## Storybook '@svgr/webpack', @@ -54,7 +52,6 @@ ignores: [ 'storybook-addon-pseudo-states', 'wait-on', 'detect-package-manager', - 'eslint-plugin-storybook', 'prop-types', # Storybook requires react-scripts 'react-scripts', @@ -83,7 +80,6 @@ ignores: [ 'lib', 'locales', 'nft', - 'notification-service', 'pages', 'polyfills', 'rpc', diff --git a/apps/web/.env b/apps/web/.env index 0b43a06d734..047cb12d8c0 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -1,5 +1,4 @@ # These API keys are intentionally public. Please do not report them - thank you for your concern. -ESLINT_NO_DEV_ERRORS=true REACT_APP_AMPLITUDE_PROXY_URL="https://interface.gateway.uniswap.org/v1/amplitude-proxy" REACT_APP_AWS_API_ENDPOINT="https://beta.gateway.uniswap.org/v1/graphql" REACT_APP_INFURA_KEY="4bf032f2d38a4ed6bb975b80d6340847" @@ -11,7 +10,6 @@ REACT_APP_MOONPAY_PUBLISHABLE_KEY="pk_test_DycfESRid31UaSxhI5yWKe1r5E5kKSz" REACT_APP_STATSIG_API_KEY="statsig_api_key" REACT_APP_STATSIG_PROXY_URL="https://interface.gateway.uniswap.org/v1/statsig-proxy" REACT_APP_TEMP_API_URL="https://temp.gateway.uniswap.org/v1" -REACT_APP_UNISWAP_BASE_API_URL="https://interface.gateway.uniswap.org" REACT_APP_UNISWAP_GATEWAY_DNS="https://interface.gateway.uniswap.org/v2" REACT_APP_WALLET_CONNECT_PROJECT_ID="c6c9bacd35afa3eb9e6cccf6d8464395" REACT_APP_TRADING_API_KEY="TRADING_API_KEY" diff --git a/apps/web/.env.production b/apps/web/.env.production index 5da7d9e1165..37c42eae4e1 100644 --- a/apps/web/.env.production +++ b/apps/web/.env.production @@ -3,3 +3,6 @@ REACT_APP_AWS_API_ENDPOINT="https://interface.gateway.uniswap.org/v1/graphql" REACT_APP_JUPITER_PROXY_URL="https://entry-gateway.backend-prod.api.uniswap.org/jupiter" REACT_APP_INFURA_KEY="099fc58e0de9451d80b18d7c74caa7c1" REACT_APP_ANALYTICS_ENABLED=true +VITE_ENABLE_ENTRY_GATEWAY_PROXY=false +PRIVY_APP_ID="cmm59god700a40bibrazsxp7n" + diff --git a/apps/web/.env.staging b/apps/web/.env.staging index 0a1782bda6d..a816b432f36 100644 --- a/apps/web/.env.staging +++ b/apps/web/.env.staging @@ -1,4 +1,6 @@ # These API keys are intentionally public. Please do not report them - thank you for your concern. REACT_APP_JUPITER_PROXY_URL="https://entry-gateway.backend-prod.api.uniswap.org/jupiter" ENTRY_GATEWAY_API_URL_OVERRIDE="https://entry-gateway.api.corn-staging.com" +VITE_ENABLE_ENTRY_GATEWAY_PROXY=true +PRIVY_APP_ID="cmm59g8g900f20cjr21tbqtmd" diff --git a/apps/web/.eslintignore b/apps/web/.eslintignore deleted file mode 100644 index 5563bf10ecb..00000000000 --- a/apps/web/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -.eslintrc.js -babel.config.js -metro.config.js -node_modules -.storybook/stories diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index 3f9abcfcb2b..00000000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,195 +0,0 @@ -/* eslint-env node */ -require('@uniswap/eslint-config/load') - -module.exports = { - root: true, - extends: ['@uniswap/eslint-config/interface', 'plugin:storybook/recommended'], - parserOptions: { - project: 'tsconfig.eslint.json', - tsconfigRootDir: __dirname, - ecmaFeatures: { - jsx: true, - }, - }, - rules: { - // let biome do things: - semi: 0, - quotes: 0, - 'comma-dangle': 0, - 'no-trailing-spaces': 0, - 'no-extra-semi': 0, - }, - - overrides: [ - { - // Portfolio pages must not use useAccount directly. Use usePortfolioAddress (or a domain-specific hook) instead. - files: ['src/pages/Portfolio/*.{ts,tsx}', 'src/pages/Portfolio/**/*.{ts,tsx}'], - rules: { - 'no-restricted-imports': [ - 'error', - { - paths: [ - { - name: 'hooks/useAccount', - message: - "Do not import 'useAccount' in portfolio pages. Use 'pages/Portfolio/hooks/usePortfolioAddress' (or a domain-specific hook) instead.", - }, - ], - }, - ], - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="useAccount"]', - message: - "Do not call 'useAccount' in portfolio pages. Use 'pages/Portfolio/hooks/usePortfolioAddress' (or a domain-specific hook) instead.", - }, - ], - }, - }, - { - files: [ - 'src/index.tsx', - 'src/tracing/index.ts', - 'src/state/index.ts', - 'src/state/explore/index.tsx', - 'src/components/**', - 'src/nft/**', - 'src/theme/**', - 'src/pages/**', - ], - rules: { - 'check-file/no-index': 'off', - }, - }, - { - files: ['src/**/*.ts', 'src/**/*.tsx'], - rules: { - 'no-relative-import-paths/no-relative-import-paths': [ - 'error', - { - allowSameFolder: false, - rootDir: 'src', - }, - ], - }, - }, - { - files: ['**/*'], - rules: { - 'multiline-comment-style': ['error', 'separate-lines'], - }, - }, - { - // Configuration/typings typically export objects/definitions that are used outside of the transpiled package - // (eg not captured by the tsconfig). Because it's typical and not exceptional, this is turned off entirely. - files: ['**/*.config.*', '**/*.d.ts'], - rules: { - 'import/no-unused-modules': 'off', - }, - }, - { - files: ['**/*.ts', '**/*.tsx'], - rules: { - 'import/no-restricted-paths': [ - 'error', - { - zones: [ - { - target: ['src/**/*[!.test].ts', 'src/**/*[!.test].tsx'], - from: 'src/test-utils', - }, - ], - }, - ], - 'no-restricted-syntax': [ - 'error', - { - selector: ':matches(ExportAllDeclaration)', - message: 'Barrel exports bloat the bundle size by preventing tree-shaking.', - }, - { - selector: `:matches(Literal[value='NATIVE'])`, - message: - "Don't use the string 'NATIVE' directly. Use the NATIVE_CHAIN_ID variable from constants/tokens instead.", - }, - { - selector: - 'ImportDeclaration[source.value="src/nft/components/icons"], ImportDeclaration[source.value="nft/components/icons"]', - message: 'Please import icons from nft/components/iconExports instead of directly from icons.tsx', - }, - // TODO(WEB-4251) - remove useWeb3React rules once web3 react is removed - { - selector: `VariableDeclarator[id.type='ObjectPattern'][init.callee.name='useWeb3React'] > ObjectPattern > Property[key.name='account']`, - message: - "Do not use account directly from useWeb3React. Use the useAccount hook from 'hooks/useAccount' instead.", - }, - { - selector: `VariableDeclarator[id.type='ObjectPattern'][init.callee.name='useWeb3React'] > ObjectPattern > Property[key.name='chainId']`, - message: - "Do not use chainId directly from useWeb3React. Use the useAccount hook from 'hooks/useAccount' and access account.chainId instead.", - }, - { - selector: `VariableDeclarator[id.type='ObjectPattern'][init.callee.name='useAccount'] > ObjectPattern > Property[key.name='address']`, - message: - "Do not use address directly from useWeb3React. Use the useAccount hook from 'hooks/useAccount' and access account.address instead.", - }, - { - selector: `TSTypeAssertion[typeAnnotation.typeName.name='Address'], TSAsExpression[typeAnnotation.typeName.name='Address'], TSAsExpression[typeAnnotation.type='TSUnionType'] TSTypeReference[typeName.name='Address'], TSTypeAssertion[typeAnnotation.type='TSUnionType'] TSTypeReference[typeName.name='Address']`, - message: - 'Do not use type assertions with "

". Use `assumeOxAddress` to treat a string as an address, or isAddress/getAddress from viem to validate a string as an Address.', - }, - ], - }, - }, - { - files: ['**/*.e2e.test.ts'], - rules: { - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.property.name="getByTestId"] > Literal', - message: - 'Use TestID enum from uniswap/src/test/fixtures/testIDs instead of string literals with getByTestId (e.g. TestID.SwapSettings)', - }, - ], - }, - }, - { - // Enforce anvil test separation - anvil tests must only be in *.anvil.e2e.test.ts files - files: ['**/*.e2e.test.ts'], - excludedFiles: ['**/*.anvil.e2e.test.ts'], - rules: { - 'no-restricted-syntax': [ - 'error', - // Block getTest({ withAnvil: true }) - { - selector: - 'CallExpression[callee.name="getTest"] > ObjectExpression > Property[key.name="withAnvil"][value.value=true]', - message: - 'Anvil tests must be in *.anvil.e2e.test.ts files. Move this test to a file with .anvil.e2e.test.ts extension.', - }, - // Block anvil fixture usage (anvil.setErc20Balance, etc.) - { - selector: 'MemberExpression[object.name="anvil"]', - message: - 'Anvil fixture usage must be in *.anvil.e2e.test.ts files. Move this test to a file with .anvil.e2e.test.ts extension.', - }, - ], - }, - }, - { - files: ['**/*.ts', '**/*.tsx'], - excludedFiles: ['src/analytics/*'], - rules: {}, - }, - { - files: ['*.mts'], - parser: '@typescript-eslint/parser', - parserOptions: { - sourceType: 'module', - project: './tsconfig.eslint.json', - }, - }, - ], -} diff --git a/apps/web/.gitignore b/apps/web/.gitignore index abc356fe394..dcaf9d606dd 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -21,6 +21,7 @@ /.swc /test-results /src/playwright-report +anvil-test-*.log bundlemeta.json # builds diff --git a/apps/web/.oxlintrc.fast.json b/apps/web/.oxlintrc.fast.json new file mode 100644 index 00000000000..9d9c29e1502 --- /dev/null +++ b/apps/web/.oxlintrc.fast.json @@ -0,0 +1,18 @@ +{ + "extends": ["./.oxlintrc.json", "../../.oxlintrc.fast.json"], + "ignorePatterns": [ + "playwright/**", + "cypress/**", + "public/**", + "functions/**", + "vite/**", + "scripts/**", + "twist-configs/**", + "test-results/**", + ".storybook/**", + "setupTests.ts", + "**/test-utils/**/*", + "**/*.config.*", + "**/*.d.ts" + ] +} diff --git a/apps/web/.oxlintrc.json b/apps/web/.oxlintrc.json new file mode 100644 index 00000000000..a39d367a058 --- /dev/null +++ b/apps/web/.oxlintrc.json @@ -0,0 +1,196 @@ +{ + "extends": ["../../.oxlintrc.json"], + "rules": { + "typescript/no-floating-promises": "off" + }, + "ignorePatterns": [ + "playwright/**", + "cypress/**", + "public/**", + "functions/**", + "vite/**", + "scripts/**", + "twist-configs/**", + "test-results/**", + ".storybook/**", + "setupTests.ts", + "**/test-utils/**/*", + "**/*.config.*", + "**/*.d.ts" + ], + "overrides": [ + { + "files": ["src/**/*.ts", "src/**/*.tsx"], + "rules": { + "universe-custom/no-relative-import-paths": [ + "error", + { + "allowSameFolder": false, + "rootDir": "src" + } + ] + } + }, + { + "files": ["src/pages/Portfolio/**"], + "rules": { + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.name='useAccount']", + "message": "Do not call 'useAccount' in portfolio pages. Use 'pages/Portfolio/hooks/usePortfolioAddress' instead." + } + ] + } + }, + { + "files": ["src/**/*.ts", "src/**/*.tsx"], + "rules": { + "no-restricted-syntax": [ + "error", + { + "selector": ":matches(ExportAllDeclaration)", + "message": "Barrel exports bloat the bundle size by preventing tree-shaking." + }, + { + "selector": ":matches(Literal[value='NATIVE'])", + "message": "Don't use the string 'NATIVE' directly. Use the NATIVE_CHAIN_ID variable from constants/tokens instead." + }, + { + "selector": "ImportDeclaration[source.value='src/nft/components/icons'], ImportDeclaration[source.value='nft/components/icons']", + "message": "Please import icons from nft/components/iconExports instead of directly from icons.tsx" + }, + { + "selector": "VariableDeclarator[id.type='ObjectPattern'][init.callee.name='useWeb3React'] > ObjectPattern > Property[key.name='account']", + "message": "Do not use account directly from useWeb3React. Use the useAccount hook from 'hooks/useAccount' instead." + }, + { + "selector": "VariableDeclarator[id.type='ObjectPattern'][init.callee.name='useWeb3React'] > ObjectPattern > Property[key.name='chainId']", + "message": "Do not use chainId directly from useWeb3React. Use the useAccount hook instead." + }, + { + "selector": "VariableDeclarator[id.type='ObjectPattern'][init.callee.name='useAccount'] > ObjectPattern > Property[key.name='address']", + "message": "Do not use address directly from useAccount. Access account.address instead." + }, + { + "selector": "TSTypeAssertion[typeAnnotation.typeName.name='Address'], TSAsExpression[typeAnnotation.typeName.name='Address']", + "message": "Do not use type assertions with Address. Use assumeOxAddress or isAddress/getAddress from viem." + } + ] + } + }, + { + "files": ["**/*.e2e.test.ts"], + "rules": { + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.property.name='getByTestId'] > Literal", + "message": "Use TestID enum instead of string literals with getByTestId." + } + ] + } + }, + { + "files": ["**/*.e2e.test.ts"], + "rules": { + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.name='getTest'] > ObjectExpression > Property[key.name='withAnvil'][value.value=true]", + "message": "Anvil tests must be in *.anvil.e2e.test.ts files." + }, + { + "selector": "MemberExpression[object.name='anvil']", + "message": "Anvil fixture usage must be in *.anvil.e2e.test.ts files." + } + ] + } + }, + { + "files": ["vite.config.*", "vite/**", "functions/**", ".storybook/**", "playwright.config.ts", "public/**"], + "rules": { + "no-console": "off", + "typescript/no-explicit-any": "off", + "no-unused-vars": "off" + } + }, + { + "files": ["src/**"], + "rules": { + "typescript/no-explicit-any": "off", + "no-bitwise": "off", + "typescript/no-non-null-assertion": "off", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "src/nft/components/icons", + "message": "Please import icons from nft/components/iconExports instead of directly from icons.tsx" + }, + { + "name": "nft/components/icons", + "message": "Please import icons from nft/components/iconExports instead of directly from icons.tsx" + }, + { + "name": "@playwright/test", + "message": "Import test and expect from playwright/fixtures instead." + }, + { + "name": "styled-components", + "message": "Styled components is deprecated, please use Flex or styled from \"ui/src\" instead." + }, + { + "name": "ethers", + "message": "Please import from '@ethersproject/module' directly to support tree-shaking." + }, + { + "name": "ui/src/components/icons", + "message": "Please import icons directly from their respective files to avoid importing the entire icons folder." + }, + { + "name": "utilities/src/platform", + "importNames": ["isIOS", "isAndroid"], + "message": "Use isWebIOS and isWebAndroid instead." + }, + { + "name": "src/test-utils", + "message": "test-utils should not be imported in non-test files" + }, + { + "name": "wagmi", + "importNames": [ + "useChainId", + "useAccount", + "useConnect", + "useDisconnect", + "useBlockNumber", + "useWatchBlockNumber" + ], + "message": "Import wrapped utilities from internal hooks instead." + }, + { + "name": "uniswap/src/features/chains/chainInfo", + "importNames": ["UNIVERSE_CHAIN_INFO"], + "message": "Use useChainInfo or helpers in packages/uniswap/src/features/chains/utils.ts when possible!" + } + ] + } + ] + } + }, + { + "files": ["src/playwright/**"], + "rules": { + "no-console": "off" + } + }, + { + "files": ["**/*.e2e.test.ts", "**/*.anvil.e2e.test.ts"], + "rules": { + "no-restricted-imports": "off" + } + } + ] +} diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts index a9341f02f1f..c4069721c5a 100644 --- a/apps/web/.storybook/main.ts +++ b/apps/web/.storybook/main.ts @@ -1,5 +1,5 @@ -import type { StorybookConfig } from '@storybook/react-webpack5' import { dirname, join, resolve } from 'path' +import type { StorybookConfig } from '@storybook/react-webpack5' import TerserPlugin from 'terser-webpack-plugin' import { DefinePlugin } from 'webpack' @@ -147,6 +147,7 @@ const config: StorybookConfig = { ...config?.resolve?.alias, 'react-native$': 'react-native-web', 'expo-blur': require.resolve('./__mocks__/expo-blur.jsx'), + '~': resolve(__dirname, '../src'), }, } diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx index 2c8b0e68fae..1aa9219ecc9 100644 --- a/apps/web/.storybook/preview.tsx +++ b/apps/web/.storybook/preview.tsx @@ -1,13 +1,12 @@ +import '@reach/dialog/styles.css' +import '../src/global.css' +import '../src/polyfills' import type { Preview } from '@storybook/react' import { Provider } from 'react-redux' -import store from 'state' +import { MemoryRouter } from 'react-router' import { ReactRouterUrlProvider } from 'uniswap/src/contexts/UrlContext' import { TamaguiProvider } from '../src/theme/tamaguiProvider' - -import '@reach/dialog/styles.css' -import { MemoryRouter } from 'react-router' -import '../src/global.css' -import '../src/polyfills' +import store from '~/state' const preview: Preview = { decorators: [ diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md deleted file mode 100644 index 0098f3b1840..00000000000 --- a/apps/web/CLAUDE.md +++ /dev/null @@ -1,146 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the Uniswap Web Interface - a React-based decentralized exchange application that's part of the Uniswap Universe monorepo. The web app enables users to swap tokens, provide liquidity, and interact with the Uniswap protocol across multiple blockchain networks. - -## Essential Development Commands - -### Daily Development - -```bash -# Start development server -bun dev - -# Run type checking -bun typecheck - -# Run linting -bun lint -bun lint:fix - -# Run tests -bun run test # Run all tests -bun run test:watch # Watch mode -bun run test:set1 # Components only -bun run test:set2 # Pages and state -bun run test:set3 # Hooks, NFT, utils -bun run test:set4 # Remaining tests - -# Build for production -bun build:production - -# Run production preview web server -bun preview - -# Run E2E Playwright tests -bun e2e # Run all e2e tests -bun e2e:no-anvil # Run non-anvil e2e tests -bun e2e:anvil # Run anvil e2e tests -bun e2e ExampleTest.e2e.test # Run a specific test file -``` - -### Monorepo Commands (from root) - -```bash -# Initial setup -bun lfg # Full setup with env vars - -# Global checks (all packages) -bun g:typecheck -bun g:lint -bun g:test -bun g:build - -# Quick checks (changed files only) -bun g:lint:changed -bun g:typecheck:changed -bun g:format:changed -``` - -## Architecture & Code Organization - -### Key Technologies - -- **Framework**: React with TypeScript -- **Build**: Vite (primary) with experimental Rolldown, Craco/webpack (legacy) -- **State**: Redux Toolkit, React Query, Jotai -- **Styling**: Styled Components, Tamagui UI framework -- **Web3**: Wagmi, Ethers.js, Web3-React -- **Testing**: Vitest (unit), Playwright (E2E), Storybook (visual) - -### Directory Structure - -```tree -apps/web/src/ -├── components/ # Reusable UI components -├── pages/ # Route-based page components -├── state/ # Redux slices and state logic -├── hooks/ # Custom React hooks -├── connection/ # Web3 wallet connection logic -├── lib/ # External library integrations -├── nft/ # NFT marketplace features -├── utils/ # Utility functions -└── constants/ # App-wide constants -``` - -### Key Architectural Patterns - -1. **Feature-based Organization**: Code is organized by feature/domain rather than by file type -2. **Shared UI Library**: Uses `@uniswap/ui` package from `packages/ui` for consistent components -3. **Strong TypeScript**: Strict typing with comprehensive type definitions -4. **GraphQL Code Generation**: Auto-generated types from GraphQL schemas -5. **Test Colocation**: Unit tests live alongside source files as `.test.ts(x)` - -### Testing Strategy - -- **Unit Tests**: Use Vitest, focus on logic and hooks -- **Component Tests**: Use React Testing Library with TestID enum -- **E2E Tests**: Playwright tests with `.e2e.test.ts` extension -- **Visual Tests**: Storybook for component documentation and testing - -### Important Development Notes - -1. **Environment Variables**: Managed via 1Password CLI - run `bun lfg` for setup -2. **Node Version**: Must use Node at the version specified in @.nvmrc -3. **Imports**: Use absolute imports within the app (enforced by ESLint) -4. **TestIDs**: Use the TestID enum instead of string literals for test selectors -5. **GitHub Actions**: External actions must be pinned to commit hashes with version comments - -### Common Workflows - -**Adding a new feature:** - -1. Create feature directory under appropriate section -2. Follow existing patterns for component structure -3. Add Redux slice if state management needed -4. Write tests alongside implementation -5. Update GraphQL queries if needed - -**Debugging blockchain interactions:** - -```bash -# Start local Ethereum fork -bun anvil:mainnet - -# Start local Base fork -bun anvil:base -``` - -**Working with translations:** - -```bash -bun i18n:extract # Extract new strings -bun i18n:upload # Upload to Crowdin -bun i18n:download # Download translations -``` - -### Code Style Guidelines - -- Follow existing patterns in neighboring files -- No unnecessary comments unless explicitly needed -- Use Tamagui components from `@uniswap/ui` when available -- Maintain consistency with Web3 naming conventions -- Always check existing imports before adding new dependencies diff --git a/apps/web/README.md b/apps/web/README.md index 27b01ea11b6..35ee3b99b03 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -6,45 +6,65 @@ To access the Uniswap Interface, use an IPFS gateway link from the [latest release](https://github.com/Uniswap/uniswap-interface/releases/latest), or visit [app.uniswap.org](https://app.uniswap.org). -## Running the interface locally +## Tech Stack + +- **Build**: Vite with experimental Rolldown support +- **Deployment**: Cloudflare Workers via `@cloudflare/vite-plugin` +- **Edge Functions**: Hono.js for SSR meta tags and OG image generation + +## Prerequisites + +- **Node.js version** - Use the version specified in `.nvmrc`. Run `nvm use` to switch. +- **Bun** - Package manager +- **1Password CLI** - Required for environment variables (run `bun lfg` from monorepo root for full setup) + +## Running Locally ```bash bun install -bun web start +bun web dev ``` +The dev server runs on port 3000 by default. + +Using a different port may cause CORS errors for certain Uniswap Backend services. + +## Development Commands + +| Command | Description | +|---------|-------------| +| `bun web dev` | Start development server | +| `bun web build:production` | Production build | +| `bun web preview` | Preview production build locally | +| `bun web typecheck` | Run type checking | +| `bun web test` | Run unit tests | +| `bun web e2e` | Run E2E Playwright tests with prod build | +| `bun web e2e:dev` | Run E2E Playwright tests with dev build | + ## Translations To get translations to work you'll need to set up 1Password, and then: -``` +```bash eval $(op signin) ``` Sign into 1Password, then: -``` +```bash bun mobile env:local:download ``` -Which downs a `.env.defaults.local` file at the root. Finally: +Which downloads a `.env.defaults.local` file at the root. Finally: -``` +```bash bun web i18n:download ``` Which will download the translations to `./apps/web/src/i18n/locales/translations`. -## Accessing Uniswap V2 - -The Uniswap Interface supports swapping, adding liquidity, removing liquidity and migrating liquidity for Uniswap protocol V2. - -- Swap on Uniswap V2: -- View V2 liquidity: -- Add V2 liquidity: -- Migrate V2 liquidity to V3: +## Further Documentation -## Accessing Uniswap V1 +See [CLAUDE.md](./CLAUDE.md) for detailed development guidance, architecture patterns, and workflows. -The Uniswap V1 interface for mainnet and testnets is accessible via IPFS gateways -linked from the [v1.0.0 release](https://github.com/Uniswap/uniswap-interface/releases/tag/v1.0.0). +See [the e2e skill](../../.claude/skills/web-e2e/SKILL.md) for information about creating and running e2e tests. diff --git a/apps/web/functions/api/image/pools.tsx b/apps/web/functions/api/image/pools.tsx index c62d4c9a7ff..d002d7e3162 100644 --- a/apps/web/functions/api/image/pools.tsx +++ b/apps/web/functions/api/image/pools.tsx @@ -1,8 +1,9 @@ -// biome-ignore-all lint/correctness/noRestrictedElements: ignoring for the whole file +/* oxlint-disable react/forbid-elements -- ignoring for the whole file */ import { ProtocolVersion } from '@universe/api/src/clients/graphql/__generated__/schema-types' import { ImageResponse } from '@vercel/og' import { WATERMARK_URL } from 'functions/constants' +import { Data, PositionStatus } from 'functions/utils/cache' import getFont from 'functions/utils/getFont' import getNetworkLogoUrl from 'functions/utils/getNetworkLogoURL' import getPool from 'functions/utils/getPool' @@ -32,7 +33,7 @@ function UnknownTokenImage({ symbol }: { symbol?: string }) { ) } -function PoolImage({ +export function PoolImage({ token0ImageUrl, token1ImageUrl, tokenSymbol0, @@ -95,111 +96,114 @@ function PoolImage({ ) } -export async function poolImageHandler(c: Context) { - try { - const { networkName, poolAddress } = c.req.param() - const origin = new URL(c.req.url).origin - - const cacheUrl = origin + '/pools/' + networkName + '/' + poolAddress - const data = await getRequest({ - url: cacheUrl, - getData: () => getPool({ networkName, poolAddress, url: cacheUrl }), - validateData: (data): data is NonNullable>> => Boolean(data.title), - }) - - if (!data) { - return new Response('Pool not found.', { status: 404 }) - } +const positionStatusConfig: Record = { + in_range: { color: '#40B66B', label: 'In range' }, + out_of_range: { color: '#FF5F52', label: 'Out of range' }, + closed: { color: '#9B9B9B', label: 'Closed' }, +} - const [fontData] = await Promise.all([getFont(origin, c.env)]) - const networkLogo = getNetworkLogoUrl(networkName.toUpperCase(), origin) +export async function renderPoolOgImage({ + data, + networkName, + c, + versionBadge, +}: { + data: Data + networkName: string + c: Context + versionBadge?: string +}): Promise { + const origin = new URL(c.req.url).origin + const [fontData] = await Promise.all([getFont(origin, c.env)]) + const networkLogo = getNetworkLogoUrl(networkName.toUpperCase(), origin) - return new ImageResponse( + return new ImageResponse( +
-
- - {networkLogo !== '' && ( - - )} - -
-
+ {networkLogo !== '' && ( + + )} + +
+
+
+ {data.name} +
+ {versionBadge && (
- {data.name} + {versionBadge}
- {data.poolData?.protocolVersion === ProtocolVersion.V2 && ( -
- {data.poolData?.protocolVersion} -
- )} -
-
+ )} +
+
+
{data.poolData?.feeTier}
- Uniswap + {data.positionStatus && ( +
+
+
+ {positionStatusConfig[data.positionStatus].label} +
+
+ )}
+ Uniswap
-
, - { - width: 1200, - height: 630, - fonts: [ - { - name: 'Inter', - data: fontData, - style: 'normal', - }, - ], - }, - ) as Response +
+
, + { + width: 1200, + height: 630, + fonts: [ + { + name: 'Inter', + data: fontData, + style: 'normal', + }, + ], + }, + ) as Response +} + +export async function poolImageHandler(c: Context) { + try { + const { networkName, poolAddress } = c.req.param() + const origin = new URL(c.req.url).origin + + const cacheUrl = origin + '/pools/' + networkName + '/' + poolAddress + const data = await getRequest({ + url: cacheUrl, + getData: () => getPool({ networkName, poolAddress, url: cacheUrl }), + validateData: (data): data is NonNullable>> => Boolean(data.title), + }) + + if (!data) { + return new Response('Pool not found.', { status: 404 }) + } + + return renderPoolOgImage({ + data, + networkName, + c, + versionBadge: data.poolData?.protocolVersion === ProtocolVersion.V2 ? data.poolData.protocolVersion : undefined, + }) } catch (error: any) { return new Response(error.message || error.toString(), { status: 500 }) } diff --git a/apps/web/functions/api/image/positions.tsx b/apps/web/functions/api/image/positions.tsx new file mode 100644 index 00000000000..15480ac42e4 --- /dev/null +++ b/apps/web/functions/api/image/positions.tsx @@ -0,0 +1,38 @@ +import { renderPoolOgImage } from 'functions/api/image/pools' +import getPosition from 'functions/utils/getPosition' +import { getRequest } from 'functions/utils/getRequest' +import { type Context } from 'hono' + +export async function positionImageHandler(c: Context) { + try { + const { version, chainName, identifier } = c.req.param() + const origin = new URL(c.req.url).origin + + const cacheUrl = `${origin}/positions/${version}/${chainName}/${identifier}` + const data = await getRequest({ + url: cacheUrl, + getData: () => + getPosition({ + version: version as 'v2' | 'v3' | 'v4', + chainName, + identifier, + url: cacheUrl, + }), + validateData: (data): data is NonNullable>> => Boolean(data.title), + }) + + if (!data) { + return new Response('Position not found.', { status: 404 }) + } + + return renderPoolOgImage({ + data, + networkName: chainName, + c, + versionBadge: data.poolData?.protocolVersion, + }) + } catch (error: unknown) { + console.error('positionImageHandler failed', error) + return new Response('Internal server error.', { status: 500 }) + } +} diff --git a/apps/web/functions/api/image/tokens.tsx b/apps/web/functions/api/image/tokens.tsx index fad14a0d3b2..8b94fa2ad73 100644 --- a/apps/web/functions/api/image/tokens.tsx +++ b/apps/web/functions/api/image/tokens.tsx @@ -1,4 +1,4 @@ -// biome-ignore-all lint/correctness/noRestrictedElements: ignoring for the whole file +/* oxlint-disable react/forbid-elements -- ignoring for the whole file */ import { ImageResponse } from '@vercel/og' import { WATERMARK_URL } from 'functions/constants' diff --git a/apps/web/functions/app.test.ts b/apps/web/functions/app.test.ts new file mode 100644 index 00000000000..613bb1cb913 --- /dev/null +++ b/apps/web/functions/app.test.ts @@ -0,0 +1,45 @@ +import { createApp } from 'functions/app' + +const mockHtml = `Uniswap` + +function buildApp() { + return createApp({ + fetchSpaHtml: async () => new Response(mockHtml, { headers: { 'content-type': 'text/html' } }), + getEntryGatewayUrl: () => 'https://entry-gateway.backend-prod.api.uniswap.org', + getWebSocketUrl: () => 'https://websockets.backend-prod.api.uniswap.org', + getTrustedClientIp: () => undefined, + }) +} + +describe('frame protection headers', () => { + it('sets frame-ancestors CSP header on SPA routes', async () => { + const app = buildApp() + const res = await app.request('/') + + expect(res.headers.get('Content-Security-Policy')).toBe("frame-ancestors 'self' https://app.safe.global") + }) + + it('sets X-Frame-Options header on SPA routes', async () => { + const app = buildApp() + const res = await app.request('/') + + expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN') + }) + + it('sets frame headers on /swap route', async () => { + const app = buildApp() + const res = await app.request('/swap') + + expect(res.headers.get('Content-Security-Policy')).toBe("frame-ancestors 'self' https://app.safe.global") + expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN') + }) + + it('does not include other CSP directives in the frame-ancestors header', async () => { + const app = buildApp() + const res = await app.request('/') + + const csp = res.headers.get('Content-Security-Policy') + expect(csp).not.toContain('default-src') + expect(csp).not.toContain('script-src') + }) +}) diff --git a/apps/web/functions/app.ts b/apps/web/functions/app.ts new file mode 100644 index 00000000000..3aeee72c431 --- /dev/null +++ b/apps/web/functions/app.ts @@ -0,0 +1,156 @@ +import { poolImageHandler } from 'functions/api/image/pools' +import { positionImageHandler } from 'functions/api/image/positions' +import { tokenImageHandler } from 'functions/api/image/tokens' +import { metaTagInjectionMiddleware } from 'functions/components/metaTagInjector' +import { rewriteProxiedCookies } from 'functions/cookie-utils' +import { Context, Hono } from 'hono' +import { proxy } from 'hono/proxy' + +type Bindings = { + ASSETS?: { fetch: typeof fetch } // Only present on Cloudflare Workers +} + +/** Platform-specific dependencies injected by each entry point. */ +interface AppConfig { + fetchSpaHtml: (c: Context) => Promise + getEntryGatewayUrl: (c: Context) => string + getWebSocketUrl: (c: Context) => string + getTrustedClientIp: (c: Context) => string | undefined +} + +// ── Frame protection ───────────────────────────────────────────────── +// frame-ancestors cannot be enforced via CSP tags (W3C spec) — it +// must be an HTTP response header. Cloudflare Workers returns responses +// with immutable headers, so we clone into a mutable Response. +function withFrameProtection(res: Response): Response { + const headers = new Headers(res.headers) + headers.set('Content-Security-Policy', "frame-ancestors 'self' https://app.safe.global") + headers.set('X-Frame-Options', 'SAMEORIGIN') + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }) +} + +// ── Shared constants ───────────────────────────────────────────────── +export const ENTRY_GATEWAY_URLS = { + development: 'https://entry-gateway.backend-staging.api.uniswap.org', + staging: 'https://entry-gateway.backend-staging.api.uniswap.org', + production: 'https://entry-gateway.backend-prod.api.uniswap.org', +} as const + +// Statsig proxy via Cloudflare gateway — the URL is constant for the web app +// (platform prefix "interface", service prefix "gating") +const STATSIG_PROXY_TARGET = 'https://gating.interface.gateway.uniswap.org' + +export const WEBSOCKET_URLS = { + development: 'https://websockets.backend-staging.api.uniswap.org', + staging: 'https://websockets.backend-staging.api.uniswap.org', + production: 'https://websockets.backend-prod.api.uniswap.org', +} as const + +// ── Cache-Control middleware for image routes ─────────────────────────── +function cacheControl(maxAge: number) { + return async (c: Context, next: () => Promise) => { + await next() + if (c.res.ok) { + c.res.headers.set('Cache-Control', `public, max-age=${maxAge}`) + } + } +} + +export function createApp({ fetchSpaHtml, getEntryGatewayUrl, getWebSocketUrl, getTrustedClientIp }: AppConfig) { + const app = new Hono<{ Bindings: Bindings }>() + + // ── OG image routes ──────────────────────────────────────────────────── + app.get('/api/image/tokens/:networkName/:tokenAddress', cacheControl(604800), tokenImageHandler) + + app.get('/api/image/pools/:networkName/:poolAddress', cacheControl(604800), poolImageHandler) + + app.get('/api/image/positions/:version/:chainName/:identifier', cacheControl(604800), positionImageHandler) + + // ── BFF proxy: entry-gateway ───────────────────────────────────────── + app.all('/entry-gateway/*', async (c) => { + const backendUrl = getEntryGatewayUrl(c) + const path = c.req.path.slice('/entry-gateway'.length) || '/' + const query = new URL(c.req.url).search + + // Forward the real client IP so the EGW authorizer (and downstream + // providers like Coinbase) see the user's IP, not the proxy's IP. + // Each platform provides a trusted IP source — Cloudflare sets + // cf-connecting-ip automatically; Vercel sets x-real-ip at the TCP + // level. We always overwrite cf-connecting-ip from the trusted source + // to prevent clients from spoofing it (especially on Vercel where + // there's no Cloudflare to sanitize headers). + const clientIp = getTrustedClientIp(c) + + const targetUrl = `${backendUrl}${path}${query}` + // redirect:'manual' prevents SSRF via 3xx redirects to internal services + const response = await proxy(targetUrl, { + ...c.req, + headers: { + ...c.req.header(), + host: undefined, + ...(clientIp ? { 'cf-connecting-ip': clientIp } : {}), + }, + redirect: 'manual', + }) + + // Rewrite Set-Cookie headers so cookies work on non-.uniswap.org domains + // (Vercel previews, staging, etc.) + return rewriteProxiedCookies(response) + }) + + // ── BFF proxy: config ────────────────────────────────────────────── + app.all('/config/*', async (c) => { + const path = c.req.path.replace(/^\/config/, '/v1/statsig-proxy') + const query = new URL(c.req.url).search + + return proxy(`${STATSIG_PROXY_TARGET}${path}${query}`, { + ...c.req, + headers: { + ...c.req.header(), + host: undefined, + }, + redirect: 'manual', + }) + }) + + // ── BFF proxy: WebSocket ──────────────────────────────────────────── + // In production, clients connect directly to the backend WebSocket + // service — see getWebSocketUrl() in packages/api/src/getWebSocketUrl.ts. + // This proxy is used in local dev (Vite + @cloudflare/vite-plugin) and + // on Cloudflare Workers staging, where the CF Workers runtime handles + // the WebSocket upgrade natively via fetch(). + // Headers are stripped to avoid forwarding the local dev origin/host + // to the backend, which would reject them. + app.get('/ws', async (c) => { + const wsUrl = getWebSocketUrl(c) + const headers = new Headers(c.req.raw.headers) + headers.delete('host') + headers.delete('origin') + try { + return await fetch(wsUrl, { headers }) + } catch (err) { + return c.text(`WebSocket proxy error: ${err}`, 502) + } + }) + + // ── Catch-all: SPA serving + meta tag injection ──────────────────────── + app.all('*', async (c: Context) => { + const url = new URL(c.req.url) + + const next = async () => { + const response = await fetchSpaHtml(c) + c.res = response + } + + // API routes should not be processed by meta tag injection + if (url.pathname.startsWith('/api/')) { + await next() + return withFrameProtection(c.res) + } + + // For non-API routes, use meta tag injection middleware + return withFrameProtection(await metaTagInjectionMiddleware(c, next)) + }) + + return app +} diff --git a/apps/web/functions/components/metaTagInjector.ts b/apps/web/functions/components/metaTagInjector.ts index 759528546ae..cd936f5722b 100644 --- a/apps/web/functions/components/metaTagInjector.ts +++ b/apps/web/functions/components/metaTagInjector.ts @@ -1,16 +1,17 @@ import { Data } from 'functions/utils/cache' import getPool from 'functions/utils/getPool' +import getPosition from 'functions/utils/getPosition' import { getRequest } from 'functions/utils/getRequest' import getToken from 'functions/utils/getToken' import { Context, Next } from 'hono' import { encode } from 'html-entities' -import { MetaTagInjectorInput } from 'shared-cloud/metatags' -import { paths } from 'src/pages/paths' +import { paths } from '~/pages/paths' +import { MetaTagInjectorInput } from '~/shared-cloud/metatags' function doesMatchPath(path: string): boolean { const regexPaths = paths.map((p) => '^' + p.replace(/:[^/]+/g, '[^/]+').replace(/\*/g, '.*') + '$') // These come from a constant we define (paths.ts), so we don't need to worry about them being malicious. - // eslint-disable-next-line security/detect-non-literal-regexp + // oxlint-disable-next-line security/detect-non-literal-regexp return regexPaths.some((regex) => new RegExp(regex).test(path)) } @@ -34,7 +35,21 @@ function parseExplorePath(pathname: string): { type: 'token' | 'pool'; networkNa return null } -// eslint-disable-next-line max-params +function parsePositionPath( + pathname: string, +): { version: 'v2' | 'v3' | 'v4'; chainName: string; identifier: string } | null { + const match = pathname.match(/^\/positions\/(v2|v3|v4)\/([^/]+)\/([^/]+)$/) + if (match) { + return { + version: match[1] as 'v2' | 'v3' | 'v4', + chainName: match[2], + identifier: match[3], + } + } + return null +} + +// oxlint-disable-next-line max-params function append(tags: string, attribute: string, content: string): string { return tags + `\n` } @@ -100,6 +115,30 @@ async function fetchExploreData({ return data ? { title: data.title, image: data.image, url: requestUrl } : null } +async function fetchPositionData({ + version, + chainName, + identifier, + origin, + requestUrl, +}: { + version: 'v2' | 'v3' | 'v4' + chainName: string + identifier: string + origin: string + requestUrl: string +}): Promise { + const cacheUrl = `${origin}/positions/${version}/${chainName}/${identifier}` + + const data = await getRequest({ + url: cacheUrl, + getData: () => getPosition({ version, chainName, identifier, url: cacheUrl }), + validateData: (data): data is NonNullable>> => Boolean(data.title), + }) + + return data ? { title: data.title, image: data.image, url: requestUrl } : null +} + export async function metaTagInjectionMiddleware(c: Context, next: Next): Promise { const requestURL = new URL(c.req.url) @@ -122,6 +161,7 @@ export async function metaTagInjectionMiddleware(c: Context, next: Next): Promis const html = await clonedResponse.text() const exploreData = parseExplorePath(requestURL.pathname) + const positionData = parsePositionPath(requestURL.pathname) let data: MetaTagInjectorInput if (exploreData) { @@ -139,6 +179,21 @@ export async function metaTagInjectionMiddleware(c: Context, next: Next): Promis } data = exploreMeta + } else if (positionData) { + const origin = requestURL.origin + const positionMeta = await fetchPositionData({ + version: positionData.version, + chainName: positionData.chainName, + identifier: positionData.identifier, + origin, + requestUrl: c.req.url, + }) + + if (!positionMeta) { + return originalResponse + } + + data = positionMeta } else { const imageUri = requestURL.origin + '/images/1200x630_Rich_Link_Preview_Image.png' data = { diff --git a/apps/web/functions/cookie-utils.ts b/apps/web/functions/cookie-utils.ts new file mode 100644 index 00000000000..8e0e0802f08 --- /dev/null +++ b/apps/web/functions/cookie-utils.ts @@ -0,0 +1,78 @@ +/** + * Cookie utilities for rewriting Set-Cookie headers from the Entry Gateway proxy. + * + * The backend sets cookies with Domain=.uniswap.org and __Host-/__Secure- prefixes. + * On Vercel previews (*.vercel.app) or staging domains, the browser silently drops + * these cookies because the domain doesn't match. This causes session-based flows + * (InitSession -> RequestChallenge) to fail with "no session id provided". + * + * Based on apps/dev-portal/app/lib/entry-gateway/cookie-utils.ts + */ + +/** + * Rewrites a single Set-Cookie header value for the proxy. + * + * Transformations: + * 1. Strips __Host- and __Secure- prefixes from cookie names + * 2. Removes Domain attribute (lets browser default to request origin) + * 3. Ensures SameSite=Lax + * 4. Keeps Secure flag (both Vercel and Cloudflare serve over HTTPS) + */ +export function rewriteProxiedCookie(cookie: string): string { + let rewritten = cookie + + // Strip __Host- and __Secure- prefixes from cookie name only. + // Only touch the name portion (before first '=') to avoid corrupting values. + const nameEndIndex = rewritten.indexOf('=') + if (nameEndIndex > 0) { + const cookieName = rewritten.substring(0, nameEndIndex) + const strippedName = cookieName.replace(/^(__Host-|__Secure-)/, '') + if (strippedName !== cookieName) { + rewritten = strippedName + rewritten.substring(nameEndIndex) + } + } + + // Remove Domain attribute (e.g., Domain=.uniswap.org) + rewritten = rewritten.replace(/Domain=[^;]+;?\s?/gi, '') + + // Handle SameSite attribute — ensure Lax + if (rewritten.includes('SameSite=')) { + rewritten = rewritten.replace(/SameSite=\w+/gi, 'SameSite=Lax') + } else if (rewritten.includes('Path=')) { + rewritten = rewritten.replace(/(Path=[^;]+)/, 'SameSite=Lax; $1') + } else { + rewritten = `${rewritten}; SameSite=Lax` + } + + return rewritten +} + +/** + * Rewrites Set-Cookie headers on a proxied Response. + * + * If the response has no Set-Cookie headers, returns it unchanged. + * Otherwise, creates a new Response with rewritten cookies. + */ +export function rewriteProxiedCookies(response: Response): Response { + const setCookies = response.headers.getSetCookie() + + if (!setCookies.length) { + return response + } + + const rewritten = setCookies.map(rewriteProxiedCookie) + + // Clone the response with new headers — we need to rebuild Set-Cookie + // since Headers.set() would collapse multiple values into one. + const newHeaders = new Headers(response.headers) + newHeaders.delete('Set-Cookie') + for (const cookie of rewritten) { + newHeaders.append('Set-Cookie', cookie) + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }) +} diff --git a/apps/web/functions/explore/pools/__snapshots__/pool.test.ts.snap b/apps/web/functions/explore/pools/__snapshots__/pool.test.ts.snap index 7365b5381a7..4f56733432b 100644 --- a/apps/web/functions/explore/pools/__snapshots__/pool.test.ts.snap +++ b/apps/web/functions/explore/pools/__snapshots__/pool.test.ts.snap @@ -31,6 +31,10 @@ window.$RefreshSig$ = () => (type) => type; + + + + -