From 8d06b0e64beca0f8b9df9f9c409ebb5d4baa017d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Tue, 13 Jan 2026 10:51:14 -0700 Subject: [PATCH 1/8] feat(predict): add live data hooks for WebSocket updates (#24482) ## **Description** This PR adds React hooks for subscribing to real-time WebSocket updates in the Predict feature. The hooks integrate with the existing `PredictController` and `WebSocketManager` infrastructure to provide live game updates and market price feeds. **Why:** - Enable real-time UI updates for NFL games (scores, periods, game status) - Provide live market price updates (best bid/ask) without polling - Support the upcoming live NFL game experience **What's included:** - `useLiveGameUpdates` - Hook for subscribing to real-time game state changes - `useLiveMarketPrices` - Hook for subscribing to market price updates with Map-based state - Controller methods to bridge UI hooks with provider WebSocket subscriptions - Comprehensive unit tests (33 tests, 100% passing) ## **Changelog** CHANGELOG entry: null ## **Related issues** [PRED-462](https://consensyssoftware.atlassian.net/browse/PRED-462) ## **Manual testing steps** ```gherkin Feature: Live game updates subscription Scenario: User views live game data Given user is on a market details page for an active NFL game And WebSocket connection is established When game state changes (score update, period change) Then UI receives real-time update via useLiveGameUpdates hook And game information displays updated values Scenario: User views live market prices Given user is viewing market outcomes with token IDs And WebSocket connection is established When market prices change Then UI receives real-time price update via useLiveMarketPrices hook And prices map contains latest bid/ask values ``` ## **Screenshots/Recordings** ### **Before** N/A - New feature (hooks only, no UI changes) ### **After** N/A - New feature (hooks only, no UI changes) ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Enables real-time data flows in Predict via controller/provider APIs and React hooks. > > - Adds `useLiveGameUpdates` and `useLiveMarketPrices` hooks for subscribing to game state and price updates, with connection polling, cleanup, and state (lastUpdateTime, Map-backed prices) > - Extends `PredictController` with `subscribeToGameUpdates`, `subscribeToMarketPrices`, and `getConnectionStatus` (no-op/disconnected fallbacks when unsupported) > - Updates `PolymarketProvider` to delegate to `WebSocketManager` (`subscribeToGame`, `subscribeToMarketPrices`, `getConnectionStatus`) and map status to `ConnectionStatus` > - Enhances provider `types` with `GameUpdateCallback`, `PriceUpdateCallback`, and `ConnectionStatus`; exports hooks in `hooks/index.ts` > - Adds thorough unit tests for hooks, controller WebSocket methods, and provider delegation > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23776cb41c927400c1f5e2f9bc176452208f365c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [PRED-462]: https://consensyssoftware.atlassian.net/browse/PRED-462?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../controllers/PredictController.test.ts | 135 +++++++ .../Predict/controllers/PredictController.ts | 57 ++- app/components/UI/Predict/hooks/index.ts | 11 + .../Predict/hooks/useLiveGameUpdates.test.ts | 298 +++++++++++++++ .../UI/Predict/hooks/useLiveGameUpdates.ts | 80 ++++ .../Predict/hooks/useLiveMarketPrices.test.ts | 351 ++++++++++++++++++ .../UI/Predict/hooks/useLiveMarketPrices.ts | 106 ++++++ .../polymarket/PolymarketProvider.test.ts | 135 +++++++ .../polymarket/PolymarketProvider.ts | 29 ++ app/components/UI/Predict/providers/types.ts | 22 ++ 10 files changed, 1222 insertions(+), 2 deletions(-) create mode 100644 app/components/UI/Predict/hooks/index.ts create mode 100644 app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts create mode 100644 app/components/UI/Predict/hooks/useLiveGameUpdates.ts create mode 100644 app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts create mode 100644 app/components/UI/Predict/hooks/useLiveMarketPrices.ts diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index bdb860d1f81..5c080aeb635 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -4926,4 +4926,139 @@ describe('PredictController', () => { ); }); }); + + describe('WebSocket subscription methods', () => { + describe('subscribeToGameUpdates', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToGameUpdates = jest + .fn() + .mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + mockCallback, + ); + + expect( + mockPolymarketProvider.subscribeToGameUpdates, + ).toHaveBeenCalledWith('game123', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + }); + + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + // @ts-expect-error Testing undefined method scenario + mockPolymarketProvider.subscribeToGameUpdates = undefined; + + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + + it('returns no-op function for unknown provider', () => { + withController(({ controller }) => { + const unsubscribe = controller.subscribeToGameUpdates( + 'game123', + jest.fn(), + 'unknown-provider', + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + }); + + describe('subscribeToMarketPrices', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToMarketPrices = jest + .fn() + .mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToMarketPrices( + ['token1', 'token2'], + mockCallback, + ); + + expect( + mockPolymarketProvider.subscribeToMarketPrices, + ).toHaveBeenCalledWith(['token1', 'token2'], mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + }); + + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + // @ts-expect-error Testing undefined method scenario + mockPolymarketProvider.subscribeToMarketPrices = undefined; + + const unsubscribe = controller.subscribeToMarketPrices( + ['token1'], + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + }); + + describe('getConnectionStatus', () => { + it('returns connection status from provider', () => { + withController(({ controller }) => { + mockPolymarketProvider.getConnectionStatus = jest + .fn() + .mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); + + const status = controller.getConnectionStatus(); + + expect(mockPolymarketProvider.getConnectionStatus).toHaveBeenCalled(); + expect(status).toEqual({ + sportsConnected: true, + marketConnected: false, + }); + }); + }); + + it('returns disconnected status when provider lacks method', () => { + withController(({ controller }) => { + // @ts-expect-error Testing undefined method scenario + mockPolymarketProvider.getConnectionStatus = undefined; + + const status = controller.getConnectionStatus(); + + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); + }); + }); + + it('returns disconnected status for unknown provider', () => { + withController(({ controller }) => { + const status = controller.getConnectionStatus('unknown-provider'); + + expect(status).toEqual({ + sportsConnected: false, + marketConnected: false, + }); + }); + }); + }); + }); }); diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 00705e0337b..2982099b2e8 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -44,6 +44,8 @@ import { import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { AccountState, + ConnectionStatus, + GameUpdateCallback, GetAccountStateParams, GetBalanceParams, GetMarketsParams, @@ -54,6 +56,7 @@ import { PrepareDepositParams, PrepareWithdrawParams, PreviewOrderParams, + PriceUpdateCallback, Signer, } from '../providers/types'; import { @@ -1707,9 +1710,59 @@ export class PredictController extends BaseController< } /** - * Test utility method to update state for testing purposes - * @param updater - Function that updates the state + * Subscribes to real-time game updates via WebSocket. + * + * @param gameId - Unique identifier of the game to subscribe to + * @param callback - Function invoked when game state changes (score, period, status) + * @param providerId - Provider to use for subscription (default: 'polymarket') + * @returns Unsubscribe function to clean up the subscription */ + public subscribeToGameUpdates( + gameId: string, + callback: GameUpdateCallback, + providerId = 'polymarket', + ): () => void { + const provider = this.providers.get(providerId); + if (!provider?.subscribeToGameUpdates) { + return () => undefined; + } + return provider.subscribeToGameUpdates(gameId, callback); + } + + /** + * Subscribes to real-time market price updates via WebSocket. + * + * @param tokenIds - Array of token IDs to subscribe to price updates for + * @param callback - Function invoked when prices change (includes bestBid/bestAsk) + * @param providerId - Provider to use for subscription (default: 'polymarket') + * @returns Unsubscribe function to clean up the subscription + */ + public subscribeToMarketPrices( + tokenIds: string[], + callback: PriceUpdateCallback, + providerId = 'polymarket', + ): () => void { + const provider = this.providers.get(providerId); + if (!provider?.subscribeToMarketPrices) { + return () => undefined; + } + return provider.subscribeToMarketPrices(tokenIds, callback); + } + + /** + * Gets the current WebSocket connection status for live data feeds. + * + * @param providerId - Provider to check connection status for (default: 'polymarket') + * @returns Connection status for sports and market data WebSocket channels + */ + public getConnectionStatus(providerId = 'polymarket'): ConnectionStatus { + const provider = this.providers.get(providerId); + if (!provider?.getConnectionStatus) { + return { sportsConnected: false, marketConnected: false }; + } + return provider.getConnectionStatus(); + } + public updateStateForTesting( updater: (state: PredictControllerState) => void, ): void { diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts new file mode 100644 index 00000000000..f66187ac32b --- /dev/null +++ b/app/components/UI/Predict/hooks/index.ts @@ -0,0 +1,11 @@ +export { + useLiveGameUpdates, + type UseLiveGameUpdatesOptions, + type UseLiveGameUpdatesResult, +} from './useLiveGameUpdates'; + +export { + useLiveMarketPrices, + type UseLiveMarketPricesOptions, + type UseLiveMarketPricesResult, +} from './useLiveMarketPrices'; diff --git a/app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts b/app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts new file mode 100644 index 00000000000..c54f838584f --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveGameUpdates.test.ts @@ -0,0 +1,298 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useLiveGameUpdates } from './useLiveGameUpdates'; +import Engine from '../../../../core/Engine'; +import { GameUpdate } from '../types'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PredictController: { + subscribeToGameUpdates: jest.fn(), + getConnectionStatus: jest.fn(), + }, + }, +})); + +describe('useLiveGameUpdates', () => { + const mockSubscribeToGameUpdates = Engine.context.PredictController + .subscribeToGameUpdates as jest.Mock; + const mockGetConnectionStatus = Engine.context.PredictController + .getConnectionStatus as jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockSubscribeToGameUpdates.mockReturnValue(mockUnsubscribe); + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('subscription management', () => { + it('subscribes to game updates when gameId is provided', () => { + renderHook(() => useLiveGameUpdates('game123')); + + expect(mockSubscribeToGameUpdates).toHaveBeenCalledWith( + 'game123', + expect.any(Function), + ); + }); + + it('does not subscribe when gameId is null', () => { + renderHook(() => useLiveGameUpdates(null)); + + expect(mockSubscribeToGameUpdates).not.toHaveBeenCalled(); + }); + + it('does not subscribe when enabled is false', () => { + renderHook(() => useLiveGameUpdates('game123', { enabled: false })); + + expect(mockSubscribeToGameUpdates).not.toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useLiveGameUpdates('game123')); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('resubscribes when gameId changes', () => { + const { rerender } = renderHook( + ({ gameId }) => useLiveGameUpdates(gameId), + { initialProps: { gameId: 'game123' } }, + ); + + expect(mockSubscribeToGameUpdates).toHaveBeenCalledTimes(1); + expect(mockSubscribeToGameUpdates).toHaveBeenCalledWith( + 'game123', + expect.any(Function), + ); + + rerender({ gameId: 'game456' }); + + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(mockSubscribeToGameUpdates).toHaveBeenCalledTimes(2); + expect(mockSubscribeToGameUpdates).toHaveBeenLastCalledWith( + 'game456', + expect.any(Function), + ); + }); + }); + + describe('game update handling', () => { + it('updates gameUpdate state when callback is invoked', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.gameUpdate).toBeNull(); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.gameUpdate).toEqual({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + it('updates lastUpdateTime when game update is received', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const mockNow = 1704067200000; + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.lastUpdateTime).toBeNull(); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.lastUpdateTime).toBe(mockNow); + }); + + it('includes turn field when present in game update', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + turn: 'SEA', + }); + }); + + expect(result.current.gameUpdate?.turn).toBe('SEA'); + }); + + it('resets gameUpdate when gameId changes to different valid value', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ gameId }) => useLiveGameUpdates(gameId), + { initialProps: { gameId: 'game123' } }, + ); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.gameUpdate).not.toBeNull(); + expect(result.current.gameUpdate?.score).toBe('21-14'); + + rerender({ gameId: 'game456' }); + + expect(result.current.gameUpdate).toBeNull(); + expect(result.current.lastUpdateTime).toBeNull(); + }); + }); + + describe('connection status', () => { + it('reflects connected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: true, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.isConnected).toBe(true); + }); + + it('reflects disconnected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.isConnected).toBe(false); + }); + + it('updates connection status on interval', () => { + mockGetConnectionStatus + .mockReturnValueOnce({ sportsConnected: true, marketConnected: false }) + .mockReturnValueOnce({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.isConnected).toBe(true); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.isConnected).toBe(false); + }); + + it('clears interval on unmount', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { unmount } = renderHook(() => useLiveGameUpdates('game123')); + + unmount(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + }); + + describe('initial state', () => { + it('returns null gameUpdate initially', () => { + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.gameUpdate).toBeNull(); + }); + + it('returns null lastUpdateTime initially', () => { + const { result } = renderHook(() => useLiveGameUpdates('game123')); + + expect(result.current.lastUpdateTime).toBeNull(); + }); + + it('resets state when disabled', () => { + let capturedCallback: (update: GameUpdate) => void = jest.fn(); + mockSubscribeToGameUpdates.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ enabled }) => useLiveGameUpdates('game123', { enabled }), + { initialProps: { enabled: true } }, + ); + + act(() => { + capturedCallback({ + gameId: 'game123', + score: '21-14', + elapsed: '12:34', + period: 'Q2', + status: 'ongoing', + }); + }); + + expect(result.current.gameUpdate).not.toBeNull(); + + rerender({ enabled: false }); + + expect(result.current.gameUpdate).toBeNull(); + expect(result.current.isConnected).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/useLiveGameUpdates.ts b/app/components/UI/Predict/hooks/useLiveGameUpdates.ts new file mode 100644 index 00000000000..c08754ff7a3 --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveGameUpdates.ts @@ -0,0 +1,80 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import Engine from '../../../../core/Engine'; +import { GameUpdate } from '../types'; + +export interface UseLiveGameUpdatesOptions { + enabled?: boolean; +} + +export interface UseLiveGameUpdatesResult { + gameUpdate: GameUpdate | null; + isConnected: boolean; + lastUpdateTime: number | null; +} + +/** + * Hook for subscribing to real-time game updates via WebSocket. + * + * @param gameId - Game ID to subscribe to, or null to disable + * @param options - Configuration options (enabled: boolean) + * @returns Game update state, connection status, and last update timestamp + */ +export const useLiveGameUpdates = ( + gameId: string | null, + options: UseLiveGameUpdatesOptions = {}, +): UseLiveGameUpdatesResult => { + const { enabled = true } = options; + + const [gameUpdate, setGameUpdate] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [lastUpdateTime, setLastUpdateTime] = useState(null); + + const isMountedRef = useRef(true); + + const handleGameUpdate = useCallback((update: GameUpdate) => { + if (!isMountedRef.current) return; + + setGameUpdate(update); + setLastUpdateTime(Date.now()); + }, []); + + useEffect(() => { + isMountedRef.current = true; + + // Reset state when gameId changes to avoid stale data from previous game + setGameUpdate(null); + setLastUpdateTime(null); + + if (!enabled || !gameId) { + setIsConnected(false); + return; + } + + const { PredictController } = Engine.context; + const unsubscribe = PredictController.subscribeToGameUpdates( + gameId, + handleGameUpdate, + ); + + const checkConnection = () => { + if (!isMountedRef.current) return; + const status = PredictController.getConnectionStatus(); + setIsConnected(status.sportsConnected); + }; + + checkConnection(); + const intervalId = setInterval(checkConnection, 1000); + + return () => { + isMountedRef.current = false; + unsubscribe(); + clearInterval(intervalId); + }; + }, [gameId, enabled, handleGameUpdate]); + + return { + gameUpdate, + isConnected, + lastUpdateTime, + }; +}; diff --git a/app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts b/app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts new file mode 100644 index 00000000000..a225f8fd185 --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveMarketPrices.test.ts @@ -0,0 +1,351 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useLiveMarketPrices } from './useLiveMarketPrices'; +import Engine from '../../../../core/Engine'; +import { PriceUpdate } from '../types'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PredictController: { + subscribeToMarketPrices: jest.fn(), + getConnectionStatus: jest.fn(), + }, + }, +})); + +describe('useLiveMarketPrices', () => { + const mockSubscribeToMarketPrices = Engine.context.PredictController + .subscribeToMarketPrices as jest.Mock; + const mockGetConnectionStatus = Engine.context.PredictController + .getConnectionStatus as jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + mockSubscribeToMarketPrices.mockReturnValue(mockUnsubscribe); + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: true, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('subscription management', () => { + it('subscribes to price updates when tokenIds are provided', () => { + renderHook(() => useLiveMarketPrices(['token1', 'token2'])); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledWith( + ['token1', 'token2'], + expect.any(Function), + ); + }); + + it('does not subscribe when tokenIds is empty', () => { + renderHook(() => useLiveMarketPrices([])); + + expect(mockSubscribeToMarketPrices).not.toHaveBeenCalled(); + }); + + it('does not subscribe when enabled is false', () => { + renderHook(() => + useLiveMarketPrices(['token1', 'token2'], { enabled: false }), + ); + + expect(mockSubscribeToMarketPrices).not.toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => + useLiveMarketPrices(['token1', 'token2']), + ); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('resubscribes when tokenIds change', () => { + const { rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['token1'] } }, + ); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + + rerender({ tokenIds: ['token1', 'token2'] }); + + expect(mockUnsubscribe).toHaveBeenCalled(); + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(2); + }); + + it('does not resubscribe when tokenIds order changes but content is same', () => { + const { rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['token2', 'token1'] } }, + ); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + + rerender({ tokenIds: ['token1', 'token2'] }); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + }); + }); + + describe('price update handling', () => { + it('updates prices map when callback is invoked', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.prices.size).toBe(0); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.prices.get('token1')).toEqual({ + tokenId: 'token1', + price: 0.75, + bestBid: 0.74, + bestAsk: 0.76, + }); + }); + + it('accumulates prices from multiple updates', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => + useLiveMarketPrices(['token1', 'token2']), + ); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + act(() => { + capturedCallback([ + { tokenId: 'token2', price: 0.25, bestBid: 0.24, bestAsk: 0.26 }, + ]); + }); + + expect(result.current.prices.size).toBe(2); + expect(result.current.prices.get('token1')?.price).toBe(0.75); + expect(result.current.prices.get('token2')?.price).toBe(0.25); + }); + + it('overwrites previous price for same token', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.8, bestBid: 0.79, bestAsk: 0.81 }, + ]); + }); + + expect(result.current.prices.get('token1')?.price).toBe(0.8); + }); + + it('updates lastUpdateTime when price update is received', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const mockNow = 1704067200000; + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.lastUpdateTime).toBeNull(); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.lastUpdateTime).toBe(mockNow); + }); + }); + + describe('getPrice helper', () => { + it('returns price for existing token', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.getPrice('token1')?.price).toBe(0.75); + }); + + it('returns undefined for non-existent token', () => { + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.getPrice('token2')).toBeUndefined(); + }); + }); + + describe('connection status', () => { + it('reflects connected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: true, + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.isConnected).toBe(true); + }); + + it('reflects disconnected status from PredictController', () => { + mockGetConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.isConnected).toBe(false); + }); + + it('updates connection status on interval', () => { + mockGetConnectionStatus + .mockReturnValueOnce({ sportsConnected: false, marketConnected: true }) + .mockReturnValueOnce({ + sportsConnected: false, + marketConnected: false, + }); + + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.isConnected).toBe(true); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(result.current.isConnected).toBe(false); + }); + }); + + describe('initial state', () => { + it('returns empty prices map initially', () => { + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.prices.size).toBe(0); + }); + + it('returns null lastUpdateTime initially', () => { + const { result } = renderHook(() => useLiveMarketPrices(['token1'])); + + expect(result.current.lastUpdateTime).toBeNull(); + }); + + it('resets state when disabled', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ enabled }) => useLiveMarketPrices(['token1'], { enabled }), + { initialProps: { enabled: true } }, + ); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.prices.size).toBe(1); + + rerender({ enabled: false }); + + expect(result.current.prices.size).toBe(0); + expect(result.current.isConnected).toBe(false); + }); + + it('resets lastUpdateTime when tokenIds change to different valid value', () => { + let capturedCallback: (updates: PriceUpdate[]) => void = jest.fn(); + mockSubscribeToMarketPrices.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const mockNow = 1704067200000; + jest.spyOn(Date, 'now').mockReturnValue(mockNow); + + const { result, rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['token1'] } }, + ); + + act(() => { + capturedCallback([ + { tokenId: 'token1', price: 0.75, bestBid: 0.74, bestAsk: 0.76 }, + ]); + }); + + expect(result.current.lastUpdateTime).toBe(mockNow); + + rerender({ tokenIds: ['token2'] }); + + expect(result.current.lastUpdateTime).toBeNull(); + expect(result.current.prices.size).toBe(0); + }); + + it('differentiates tokenIds with commas that could otherwise collide', () => { + const { rerender } = renderHook( + ({ tokenIds }) => useLiveMarketPrices(tokenIds), + { initialProps: { tokenIds: ['a,b', 'c'] } }, + ); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(1); + + rerender({ tokenIds: ['a', 'b,c'] }); + + expect(mockSubscribeToMarketPrices).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/useLiveMarketPrices.ts b/app/components/UI/Predict/hooks/useLiveMarketPrices.ts new file mode 100644 index 00000000000..5f05413e53b --- /dev/null +++ b/app/components/UI/Predict/hooks/useLiveMarketPrices.ts @@ -0,0 +1,106 @@ +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import Engine from '../../../../core/Engine'; +import { PriceUpdate } from '../types'; + +export interface UseLiveMarketPricesOptions { + enabled?: boolean; +} + +export interface UseLiveMarketPricesResult { + prices: Map; + getPrice: (tokenId: string) => PriceUpdate | undefined; + isConnected: boolean; + lastUpdateTime: number | null; +} + +/** + * Hook for subscribing to real-time market price updates via WebSocket. + * + * @param tokenIds - Array of token IDs to subscribe to price updates for + * @param options - Configuration options (enabled: boolean) + * @returns Price map, getPrice helper, connection status, and last update timestamp + */ +export const useLiveMarketPrices = ( + tokenIds: string[], + options: UseLiveMarketPricesOptions = {}, +): UseLiveMarketPricesResult => { + const { enabled = true } = options; + + const [prices, setPrices] = useState>(new Map()); + const [isConnected, setIsConnected] = useState(false); + const [lastUpdateTime, setLastUpdateTime] = useState(null); + + const isMountedRef = useRef(true); + const tokenIdsRef = useRef(tokenIds); + + // Use JSON.stringify to avoid key collisions if token IDs contain commas + const tokenIdsKey = useMemo( + () => JSON.stringify([...tokenIds].sort((a, b) => a.localeCompare(b))), + [tokenIds], + ); + + // Sync ref in effect to avoid render impurity (React Concurrent Mode safe) + useEffect(() => { + tokenIdsRef.current = tokenIds; + }, [tokenIds]); + + const handlePriceUpdates = useCallback((updates: PriceUpdate[]) => { + if (!isMountedRef.current) return; + + setPrices((prevPrices) => { + const newPrices = new Map(prevPrices); + updates.forEach((update) => { + newPrices.set(update.tokenId, update); + }); + return newPrices; + }); + + setLastUpdateTime(Date.now()); + }, []); + + useEffect(() => { + isMountedRef.current = true; + + // Reset state when token set changes to avoid stale data from previous subscriptions + setPrices(new Map()); + setLastUpdateTime(null); + + if (!enabled || tokenIdsRef.current.length === 0) { + setIsConnected(false); + return; + } + + const { PredictController } = Engine.context; + const unsubscribe = PredictController.subscribeToMarketPrices( + tokenIdsRef.current, + handlePriceUpdates, + ); + + const checkConnection = () => { + if (!isMountedRef.current) return; + const status = PredictController.getConnectionStatus(); + setIsConnected(status.marketConnected); + }; + + checkConnection(); + const intervalId = setInterval(checkConnection, 1000); + + return () => { + isMountedRef.current = false; + unsubscribe(); + clearInterval(intervalId); + }; + }, [tokenIdsKey, enabled, handlePriceUpdates]); + + const getPrice = useCallback( + (tokenId: string): PriceUpdate | undefined => prices.get(tokenId), + [prices], + ); + + return { + prices, + getPrice, + isConnected, + lastUpdateTime, + }; +}; diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 0d5abe80be0..85cbba06c9a 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -141,6 +141,21 @@ jest.mock('./GameCache', () => ({ }, })); +const mockWebSocketManagerInstance = { + subscribeToGame: jest.fn(), + subscribeToMarketPrices: jest.fn(), + getConnectionStatus: jest.fn(), + disconnect: jest.fn(), + cleanup: jest.fn(), +}; + +jest.mock('./WebSocketManager', () => ({ + WebSocketManager: { + getInstance: jest.fn(() => mockWebSocketManagerInstance), + resetInstance: jest.fn(), + }, +})); + jest.mock('../../../../../util/transactions', () => ({ generateTransferData: jest.fn(), isSmartContractAddress: jest.fn(), @@ -6497,4 +6512,124 @@ describe('PolymarketProvider', () => { }); }); }); + + describe('WebSocket methods', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('subscribeToGameUpdates', () => { + it('delegates to WebSocketManager.subscribeToGame', () => { + const provider = new PolymarketProvider(); + const mockCallback = jest.fn(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToGameUpdates( + 'game-123', + mockCallback, + ); + + expect( + mockWebSocketManagerInstance.subscribeToGame, + ).toHaveBeenCalledWith('game-123', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('returns unsubscribe function from WebSocketManager', () => { + const provider = new PolymarketProvider(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToGameUpdates( + 'game-456', + jest.fn(), + ); + + unsubscribe(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('subscribeToMarketPrices', () => { + it('delegates to WebSocketManager.subscribeToMarketPrices', () => { + const provider = new PolymarketProvider(); + const mockCallback = jest.fn(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToMarketPrices( + ['token-1', 'token-2'], + mockCallback, + ); + + expect( + mockWebSocketManagerInstance.subscribeToMarketPrices, + ).toHaveBeenCalledWith(['token-1', 'token-2'], mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + + it('returns unsubscribe function from WebSocketManager', () => { + const provider = new PolymarketProvider(); + const mockUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( + mockUnsubscribe, + ); + + const unsubscribe = provider.subscribeToMarketPrices( + ['token-1'], + jest.fn(), + ); + + unsubscribe(); + + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + }); + + describe('getConnectionStatus', () => { + it('returns connection status from WebSocketManager', () => { + const provider = new PolymarketProvider(); + mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ + sportsConnected: true, + marketConnected: false, + gameSubscriptionCount: 5, + priceSubscriptionCount: 10, + }); + + const status = provider.getConnectionStatus(); + + expect(status).toEqual({ + sportsConnected: true, + marketConnected: false, + }); + }); + + it('maps WebSocketManager status to ConnectionStatus interface', () => { + const provider = new PolymarketProvider(); + mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ + sportsConnected: false, + marketConnected: true, + gameSubscriptionCount: 0, + priceSubscriptionCount: 3, + }); + + const status = provider.getConnectionStatus(); + + expect(status.sportsConnected).toBe(false); + expect(status.marketConnected).toBe(true); + expect(Object.keys(status)).toEqual([ + 'sportsConnected', + 'marketConnected', + ]); + }); + }); + }); }); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 4e45a3e0d73..0127cebb9fd 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -32,6 +32,8 @@ import { AccountState, ClaimOrderParams, ClaimOrderResponse, + ConnectionStatus, + GameUpdateCallback, GeoBlockResponse, GetBalanceParams, GetMarketsParams, @@ -45,6 +47,7 @@ import { PrepareWithdrawParams, PrepareWithdrawResponse, PreviewOrderParams, + PriceUpdateCallback, Signer, SignWithdrawParams, SignWithdrawResponse, @@ -97,6 +100,7 @@ import { } from './utils'; import { PredictFeeCollection } from '../../types/flags'; import { GameCache } from './GameCache'; +import { WebSocketManager } from './WebSocketManager'; export type SignTypedMessageFn = ( params: TypedMessageParams, @@ -1565,4 +1569,29 @@ export class PolymarketProvider implements PredictProvider { amount, }; } + + public subscribeToGameUpdates( + gameId: string, + callback: GameUpdateCallback, + ): () => void { + return WebSocketManager.getInstance().subscribeToGame(gameId, callback); + } + + public subscribeToMarketPrices( + tokenIds: string[], + callback: PriceUpdateCallback, + ): () => void { + return WebSocketManager.getInstance().subscribeToMarketPrices( + tokenIds, + callback, + ); + } + + public getConnectionStatus(): ConnectionStatus { + const status = WebSocketManager.getInstance().getConnectionStatus(); + return { + sportsConnected: status.sportsConnected, + marketConnected: status.marketConnected, + }; + } } diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts index 59647ad55d1..0665663b98f 100644 --- a/app/components/UI/Predict/providers/types.ts +++ b/app/components/UI/Predict/providers/types.ts @@ -1,5 +1,6 @@ import { KeyringController } from '@metamask/keyring-controller'; import { + GameUpdate, GetPriceHistoryParams, GetPriceParams, GetPriceResponse, @@ -8,6 +9,7 @@ import { PredictMarket, PredictPosition, PredictPriceHistoryPoint, + PriceUpdate, Result, Side, } from '../types'; @@ -15,6 +17,14 @@ import { Hex } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; import { PredictFeeCollection } from '../types/flags'; +export type GameUpdateCallback = (update: GameUpdate) => void; +export type PriceUpdateCallback = (updates: PriceUpdate[]) => void; + +export interface ConnectionStatus { + sportsConnected: boolean; + marketConnected: boolean; +} + export interface GetMarketsParams { providerId?: string; @@ -266,4 +276,16 @@ export interface PredictProvider { signWithdraw?(params: SignWithdrawParams): Promise; getBalance(params: GetBalanceParams): Promise; + + subscribeToGameUpdates?( + gameId: string, + callback: GameUpdateCallback, + ): () => void; + + subscribeToMarketPrices?( + tokenIds: string[], + callback: PriceUpdateCallback, + ): () => void; + + getConnectionStatus?(): ConnectionStatus; } From b811524c435b144e4971f69f4b842970e424e6b3 Mon Sep 17 00:00:00 2001 From: adrigug <99034515+adrigug@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:02:58 -0600 Subject: [PATCH 2/8] style: explore tab button styling (#24424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update the browser button styling in TrendingView to match the ExploreSearchBar for visual consistency. image ## **Changelog** CHANGELOG entry: style: explore tab button styling ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/24344 https://consensyssoftware.atlassian.net/browse/ASSETS-2240 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-01-13 at 15 14 39 Screenshot 2026-01-13 at 15 14 47 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- Open in
Cursor Open in Web --- > [!NOTE] > Aligns the TrendingView browser button with `ExploreSearchBar` styling for consistency. > > - Wraps button content in a rounded, muted `Box` with `min-h/min-w` (44px) instead of bordered square > - Uses `TextVariant.BodyLg` with `TextColor.TextAlternative` for tab count; otherwise `IconName.Explore` at `IconSize.Lg` with `IconColor.IconAlternative` > - Moves `testID` to the `TouchableOpacity` (`trending-view-browser-button`) > - Adds imports for `IconColor` and `TextColor` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 67b895709a428868af3c9ee38c1664ffb0a5ace7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor Agent Co-authored-by: Prithpal Sooriya --- .../Views/TrendingView/TrendingView.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index ad4795093cb..c5bdc5ca30a 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -16,6 +16,8 @@ import { IconName, Icon, IconSize, + IconColor, + TextColor, } from '@metamask/design-system-react-native'; import { strings } from '../../../../locales/i18n'; import AppConstants from '../../../core/AppConstants'; @@ -186,28 +188,26 @@ export const ExploreFeed: React.FC = () => { - - {browserTabsCount > 0 ? ( - + + + {browserTabsCount > 0 ? ( {browserTabsCount} - - ) : ( - - )} + ) : ( + + )} + From 796855539c788c55d6db5f7ede39a74ea4820745 Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Tue, 13 Jan 2026 10:24:03 -0800 Subject: [PATCH 3/8] chore: Update headers for Accounts flow (#24306) ## **Description** This PR updates the header and footer styling across account-related screens to align with the new design system patterns. The changes migrate multiple screens from legacy navigation headers to the new `HeaderWithTitleLeft` and `HeaderCenter` components from `component-library/components-temp`. **Screens updated:** - **AccountSelector** - Now uses `HeaderCenter` for consistent bottom sheet header styling with a close button - **ImportNewSecretRecoveryPhrase** - Migrated to `HeaderWithTitleLeft` with proper back/scan button props - **SelectHardware (ConnectHardware)** - Migrated to `HeaderWithTitleLeft` with subtitle support - **ImportPrivateKey** - Migrated to `HeaderWithTitleLeft` and replaced raw `TextInput` with `TextField` component - **SeedphraseModal** - Added `HeaderCenter` component and updated button variant from Primary to Secondary **Additional improvements:** - Standardized platform-specific padding for footers (16px on iOS, 0 on Android) - Updated text colors to use `TextColor.Alternative` for subtitle/description text - Cleaned up unused style properties and imports - Improved hardware wallet button layout with proper flex styling ## **Changelog** CHANGELOG entry: Updated header and footer styling across account management screens to align with new design system ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/MDP/boards/2972?assignee=62afb43d33a882e2be47c36f&quickFilter=3325&selectedIssue=MDP-322 https://consensyssoftware.atlassian.net/jira/software/c/projects/MDP/boards/2972?assignee=62afb43d33a882e2be47c36f&quickFilter=3325&selectedIssue=MDP-324 ## **Manual testing steps** ```gherkin Feature: Account flow header styling Scenario: User views account selector Given the user is on the wallet home screen When user taps on the account selector Then the bottom sheet should display with a centered header and close button Scenario: User imports a wallet with SRP Given the user is on the add account flow When user selects "Import wallet" Then the import SRP screen should display the new left-aligned header with back and scan buttons Scenario: User imports a private key Given the user is on the add account flow When user selects "Import account" (private key) Then the import screen should display with the new left-aligned header And the text input should display with the updated TextField component styling Scenario: User connects hardware wallet Given the user is on the add account flow When user selects "Add hardware wallet" Then the select hardware screen should display with the new left-aligned header and subtitle Scenario: User views seedphrase modal Given the user is on the backup seedphrase flow When user taps on "What is a Secret Recovery Phrase?" Then the modal should display with centered header and close button And the "Got it" button should be styled as secondary variant ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/dd34213c-42a6-4779-8fd1-15dfe2d84616 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Aligns account-related screens with the new design system headers and spacing. > > - Replaces legacy nav headers with `HeaderWithTitleLeft`/`HeaderCenter` in `ImportNewSecretRecoveryPhrase`, `ImportPrivateKey`, `SelectHardware`, `AccountSelector`, and `SeedphraseModal` > - Standardizes footer/content spacing: `paddingBottom` iOS=16/Android=0; adds `paddingHorizontal`/`paddingTop` where applicable > - Updates text styling to `TextColor.Alternative` for descriptions and refines list/bullet spacing > - Changes `SeedphraseModal` CTA to `ButtonVariants.Secondary`; keeps SRP import CTA primary and moves it to sticky footer area > - Refactors `ImportPrivateKey`: new header with inline learn-more link, updated placeholder, disabled state while loading, and refreshed input/typography styles > - Cleans up unused styles/imports and updates tests/snapshots and e2e selectors to match new headers and layout > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1f1b86b142eff2da91249bccf94fbf4aec4c04e9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 201 ++++++-- app/components/UI/SeedphraseModal/index.js | 57 +-- .../UI/SrpInputGrid/SrpInputGrid.styles.ts | 1 - .../__snapshots__/index.test.tsx.snap | 4 - .../AccountSelector/AccountSelector.styles.ts | 7 +- .../AccountSelector.test.tsx.snap | 6 +- .../ConnectHardware/SelectHardware/index.tsx | 66 ++- .../__snapshots__/index.test.tsx.snap | 1 - .../index.test.tsx | 75 +-- .../ImportNewSecretRecoveryPhrase/index.tsx | 157 +++---- .../ImportNewSecretRecoveryPhrase/styles.ts | 94 +--- .../__snapshots__/index.test.tsx.snap | 432 ++++++++++-------- .../Views/ImportPrivateKey/index.test.tsx | 18 +- .../Views/ImportPrivateKey/index.tsx | 101 ++-- .../Views/ImportPrivateKey/styles.ts | 65 +-- e2e/pages/importSrp/ImportSrpView.ts | 4 +- 16 files changed, 634 insertions(+), 655 deletions(-) diff --git a/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap b/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap index 02b3bdfe4c6..6f2df2c3a38 100644 --- a/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/SeedphraseModal/__snapshots__/index.test.tsx.snap @@ -7,42 +7,167 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` - + + + - What’s a Secret Recovery Phrase? - + + + What’s a Secret Recovery Phrase? + + + + + + + + + + + + @@ -50,7 +175,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -71,7 +196,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -84,7 +209,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` @@ -103,10 +228,10 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` { "color": "#121314", "fontFamily": "Geist-Regular", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, - "marginRight": 10, + "marginRight": 12, } } > @@ -116,7 +241,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -142,10 +267,10 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` { "color": "#121314", "fontFamily": "Geist-Regular", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, - "marginRight": 10, + "marginRight": 12, } } > @@ -155,7 +280,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -181,10 +306,10 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` { "color": "#121314", "fontFamily": "Geist-Regular", - "fontSize": 20, + "fontSize": 16, "letterSpacing": 0, "lineHeight": 24, - "marginRight": 10, + "marginRight": 12, } } > @@ -194,7 +319,7 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#686e7d", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -211,7 +336,9 @@ exports[`SeedphraseModal renders matches snapshot 1`] = ` StyleSheet.create({ modalContainer: { - padding: 16, flexDirection: 'column', - rowGap: 16, - justifyContent: 'center', - alignItems: 'center', - }, - titleContainer: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', }, explanationText: { - fontSize: 14, marginTop: 16, - textAlign: 'left', - ...fontStyles.normal, - color: colors.text.default, - lineHeight: 20, }, list: { marginTop: 24, @@ -46,22 +33,20 @@ const createStyles = (colors) => justifyContent: 'flex-start', }, bullet: { - fontSize: 20, - marginRight: 10, - }, - itemText: { - fontSize: 16, + marginRight: 12, }, listContainer: { - marginLeft: 10, - }, - explanationTextContainer: { - flexDirection: 'column', + marginLeft: 12, }, buttonContainer: { - marginTop: 16, + paddingTop: 24, + paddingHorizontal: 16, + paddingBottom: Platform.OS === 'android' ? 0 : 16, width: '100%', }, + contentContainer: { + paddingHorizontal: 16, + }, }); const SeedphraseModal = () => { @@ -83,24 +68,26 @@ const SeedphraseModal = () => { return ( - - - {strings('account_backup_step_1.what_is_seedphrase_title')} - - - - + + + {strings('account_backup_step_1.what_is_seedphrase_text_1')} - + {strings('account_backup_step_1.what_is_seedphrase_text_4')} {seedPhrasePoints.map((point) => ( {'\u2022'} - + {point} @@ -110,7 +97,7 @@ const SeedphraseModal = () => {