diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 9af84e0444d..f6483252311 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -45,6 +45,9 @@ scripts/build.sh @MetaMask/mobile-pla
fingerprint.config.js @MetaMask/mobile-platform
scripts/update-expo-channel.js @MetaMask/mobile-admins
certs/certificate.pem @MetaMask/mobile-admins
+ios/fastlane/ @MetaMask/mobile-admins
+scripts/setup-app-store-connect-api-key.sh @MetaMask/mobile-admins
+scripts/upload-to-testflight.sh @MetaMask/mobile-admins
# Platform & Snaps Code Fencing File
metro.transform.js @MetaMask/mobile-platform @MetaMask/core-platform
diff --git a/android/app/build.gradle b/android/app/build.gradle
index a66dba67dd7..b3027f17243 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -188,7 +188,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionName "7.62.0"
- versionCode 3092
+ versionCode 3418
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST"
diff --git a/app/components/UI/Predict/components/MarketListContent/MarketListContent.styles.ts b/app/components/UI/Predict/components/MarketListContent/MarketListContent.styles.ts
deleted file mode 100644
index 5f7290d2f3d..00000000000
--- a/app/components/UI/Predict/components/MarketListContent/MarketListContent.styles.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { StyleSheet } from 'react-native';
-
-const styleSheet = () =>
- StyleSheet.create({
- loadingContainer: {
- flex: 1,
- width: '100%',
- },
- skeleton: {
- marginVertical: 8,
- borderRadius: 16,
- },
- errorContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- width: '100%',
- padding: 16,
- },
- emptyContainer: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- width: '100%',
- padding: 16,
- },
- emptySearchContainer: {
- justifyContent: 'center',
- alignItems: 'center',
- width: '100%',
- marginTop: 42,
- padding: 16,
- },
- marketListContainer: {
- flex: 1,
- width: '100%',
- },
- });
-
-export default styleSheet;
diff --git a/app/components/UI/Predict/components/MarketListContent/MarketListContent.test.tsx b/app/components/UI/Predict/components/MarketListContent/MarketListContent.test.tsx
deleted file mode 100644
index 2243a62c6fd..00000000000
--- a/app/components/UI/Predict/components/MarketListContent/MarketListContent.test.tsx
+++ /dev/null
@@ -1,331 +0,0 @@
-import React from 'react';
-import { act } from '@testing-library/react-native';
-import MarketListContent from './';
-import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import { backgroundState } from '../../../../../util/test/initial-root-state';
-import { PredictCategory, PredictMarket, Recurrence } from '../../types';
-import { usePredictMarketData } from '../../hooks/usePredictMarketData';
-import { strings } from '../../../../../../locales/i18n';
-import { getPredictMarketListSelector } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
-
-// Mock animations to prevent act() warnings
-jest.mock('react-native/Libraries/Animated/Animated', () => {
- const actualAnimated = jest.requireActual(
- 'react-native/Libraries/Animated/Animated',
- );
- return {
- ...actualAnimated,
- timing: jest.fn(() => ({
- start: jest.fn(),
- })),
- sequence: jest.fn(() => ({
- start: jest.fn(),
- })),
- };
-});
-
-jest.mock('../../hooks/usePredictMarketData', () => ({
- usePredictMarketData: jest.fn(),
-}));
-
-const mockUsePredictMarketData = usePredictMarketData as jest.MockedFunction<
- typeof usePredictMarketData
->;
-
-jest.mock('../PredictMarket', () => {
- const { View } = jest.requireActual('react-native');
- return jest.fn(({ market }) => (
- PredictMarket
- ));
-});
-
-jest.mock('../PredictOffline', () => {
- const { View, Text, TouchableOpacity } = jest.requireActual('react-native');
- return jest.fn(({ onRetry }) => (
-
- Unable to connect to predictions
-
- Prediction markets are temporarily offline. Please check you have a
- stable connection and try again.
-
-
- Retry
-
-
- ));
-});
-
-// Simple FlashList mock
-jest.mock('@shopify/flash-list', () => {
- const { FlatList } = jest.requireActual('react-native');
- return {
- FlashList: FlatList,
- };
-});
-
-const mockPredictMarket: PredictMarket = {
- id: 'test-market-1',
- providerId: 'test-provider',
- slug: 'bitcoin-prediction',
- title: 'Will Bitcoin reach $150,000 by end of year?',
- description: 'Bitcoin price prediction market',
- image: 'https://example.com/bitcoin.png',
- status: 'open',
- recurrence: Recurrence.NONE,
- category: 'crypto',
- tags: ['trending'],
- outcomes: [
- {
- id: 'outcome-1',
- marketId: 'test-market-1',
- providerId: 'test-provider',
- title: 'Yes',
- description: 'Bitcoin will reach $150,000',
- image: 'https://example.com/bitcoin.png',
- status: 'open',
- tokens: [
- {
- id: '1',
- title: 'Yes Token',
- price: 0.65,
- },
- ],
- volume: 1000000,
- groupItemTitle: 'Bitcoin Prediction',
- },
- ],
- liquidity: 1000000,
- volume: 1000000,
-};
-
-const mockPredictMarketMultiple: PredictMarket = {
- id: 'test-market-3',
- providerId: 'test-provider',
- slug: 'crypto-predictions',
- title: 'Crypto Predictions Event',
- description: 'Multiple crypto prediction markets',
- image: 'https://example.com/crypto.png',
- status: 'open',
- recurrence: Recurrence.NONE,
- category: 'crypto',
- tags: ['trending'],
- outcomes: [
- {
- id: 'outcome-3',
- marketId: 'test-market-3',
- providerId: 'test-provider',
- title: 'Bitcoin Yes',
- description: 'Bitcoin will reach $150,000',
- image: 'https://example.com/bitcoin.png',
- status: 'open',
- tokens: [
- {
- id: '3',
- title: 'Bitcoin Yes Token',
- price: 0.65,
- },
- ],
- volume: 1000000,
- groupItemTitle: 'Bitcoin Prediction',
- },
- {
- id: 'outcome-4',
- providerId: 'test-provider',
- marketId: 'test-market-3',
- title: 'Ethereum Yes',
- description: 'Ethereum will reach $10,000',
- image: 'https://example.com/ethereum.png',
- status: 'open',
- tokens: [
- {
- id: '4',
- title: 'Ethereum Yes Token',
- price: 0.45,
- },
- ],
- volume: 500000,
- groupItemTitle: 'Ethereum Prediction',
- },
- ],
- liquidity: 1000000,
- volume: 1000000,
-};
-
-const mockRefetch = jest.fn();
-const mockFetchMore = jest.fn();
-
-const defaultMockReturn = {
- marketData: [] as PredictMarket[],
- isFetching: false,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: mockRefetch,
- fetchMore: mockFetchMore,
-};
-
-const initialState = {
- engine: {
- backgroundState,
- },
-};
-
-// Helper function to set up test environment
-function setupMarketListContentTest(
- marketDataOverrides = {},
- category: PredictCategory = 'trending',
- q?: string,
-) {
- jest.clearAllMocks();
- mockUsePredictMarketData.mockReturnValue({
- ...defaultMockReturn,
- ...marketDataOverrides,
- });
- return renderWithProvider(, {
- state: initialState,
- });
-}
-
-describe('MarketListContent', () => {
- describe('Loading States', () => {
- it('renders loading skeleton when isFetching is true', () => {
- const { getByTestId } = setupMarketListContentTest({
- isFetching: true,
- });
-
- // Check for skeleton components in loading state
- expect(getByTestId('skeleton-loading-1')).toBeOnTheScreen();
- expect(getByTestId('skeleton-loading-2')).toBeOnTheScreen();
- });
-
- it('renders footer loading skeleton when isFetchingMore is true', () => {
- const { getByTestId } = setupMarketListContentTest({
- marketData: [mockPredictMarket],
- isFetchingMore: true,
- });
-
- // The footer skeleton should be rendered when fetching more
- expect(getByTestId('skeleton-footer-1')).toBeOnTheScreen();
- expect(getByTestId('skeleton-footer-2')).toBeOnTheScreen();
- });
- });
-
- describe('Error States', () => {
- it('renders error state when there is an error', () => {
- const { getByText, getByTestId } = setupMarketListContentTest({
- error: 'Failed to fetch markets',
- });
-
- expect(getByTestId('predict-error-state')).toBeOnTheScreen();
- expect(getByText('Unable to connect to predictions')).toBeOnTheScreen();
- expect(
- getByText(
- 'Prediction markets are temporarily offline. Please check you have a stable connection and try again.',
- ),
- ).toBeOnTheScreen();
- });
-
- it('calls refetch when retry button is pressed', async () => {
- const { getByTestId } = setupMarketListContentTest({
- error: 'Failed to fetch markets',
- });
-
- const retryButton = getByTestId('retry-button');
-
- await act(async () => {
- await retryButton.props.onPress();
- });
-
- expect(mockRefetch).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('Empty States', () => {
- it('renders empty state when no markets are available', () => {
- const { getByText } = setupMarketListContentTest(
- { marketData: [] },
- 'sports',
- );
-
- expect(getByText('No sports markets available')).toBeOnTheScreen();
- });
-
- it('renders empty state when marketData is null', () => {
- const { getByText } = setupMarketListContentTest(
- { marketData: null as unknown as PredictMarket[] },
- 'crypto',
- );
-
- expect(getByText('No crypto markets available')).toBeOnTheScreen();
- });
-
- it('renders search empty state when query has no matching markets', () => {
- const searchTerm = 'bitcoin';
- const expectedEmptySearchText = strings(
- 'predict.search_no_markets_found',
- { q: searchTerm },
- );
-
- const { getByText, getByTestId } = setupMarketListContentTest(
- { marketData: [] },
- 'crypto',
- searchTerm,
- );
-
- expect(
- getByTestId(getPredictMarketListSelector.emptyState()),
- ).toBeOnTheScreen();
- expect(getByTestId('icon')).toBeOnTheScreen();
- expect(getByText(expectedEmptySearchText)).toBeOnTheScreen();
- });
- });
-
- describe('Data Rendering', () => {
- it('renders PredictMarket component for single outcome market', () => {
- const { getByTestId } = setupMarketListContentTest({
- marketData: [mockPredictMarket],
- });
-
- expect(getByTestId('predict-market-test-market-1')).toBeOnTheScreen();
- });
-
- it('renders PredictMarket component for multiple outcome market', () => {
- const { getByTestId } = setupMarketListContentTest(
- { marketData: [mockPredictMarketMultiple] },
- 'crypto',
- );
-
- expect(getByTestId('predict-market-test-market-3')).toBeOnTheScreen();
- });
-
- it('renders PredictMarket components for all markets regardless of outcome count', () => {
- const { getByTestId } = setupMarketListContentTest({
- marketData: [mockPredictMarket, mockPredictMarketMultiple],
- });
-
- expect(getByTestId('predict-market-test-market-1')).toBeOnTheScreen();
- expect(getByTestId('predict-market-test-market-3')).toBeOnTheScreen();
- });
- });
-
- describe('Hook Integration', () => {
- it('calls usePredictMarketData with correct parameters', () => {
- const categories: PredictCategory[] = [
- 'trending',
- 'new',
- 'sports',
- 'crypto',
- 'politics',
- ];
-
- categories.forEach((category) => {
- setupMarketListContentTest({}, category);
-
- expect(mockUsePredictMarketData).toHaveBeenCalledWith({
- category,
- pageSize: 20,
- });
- });
- });
- });
-});
diff --git a/app/components/UI/Predict/components/MarketListContent/MarketListContent.tsx b/app/components/UI/Predict/components/MarketListContent/MarketListContent.tsx
deleted file mode 100644
index 02370a97176..00000000000
--- a/app/components/UI/Predict/components/MarketListContent/MarketListContent.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import { RefreshControl } from 'react-native';
-import Animated from 'react-native-reanimated';
-import { useStyles } from '../../../../../component-library/hooks';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import { Box } from '@metamask/design-system-react-native';
-import Text, {
- TextVariant,
- TextColor,
-} from '../../../../../component-library/components/Texts/Text';
-import { usePredictMarketData } from '../../hooks/usePredictMarketData';
-import { FlashList, FlashListProps } from '@shopify/flash-list';
-import styleSheet from './MarketListContent.styles';
-import {
- PredictCategory,
- PredictMarket as PredictMarketType,
-} from '../../types';
-import { PredictEntryPoint } from '../../types/navigation';
-import { PredictEventValues } from '../../constants/eventNames';
-import PredictMarket from '../PredictMarket';
-import PredictMarketSkeleton from '../PredictMarketSkeleton';
-import { getPredictMarketListSelector } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
-import { ScrollCoordinator } from '../../types/scrollCoordinator';
-import PredictOffline from '../PredictOffline';
-import { strings } from '../../../../../../locales/i18n';
-import PredictionsDark from '../../../../../images/predictions-no-search-results-dark.svg';
-import PredictionsLight from '../../../../../images/predictions-no-search-results-light.svg';
-import { useAssetFromTheme } from '../../../../../util/theme';
-interface MarketListContentProps {
- q?: string;
- category: PredictCategory;
- entryPoint?: PredictEntryPoint;
- scrollCoordinator?: ScrollCoordinator;
-}
-
-// create once at module scope to avoid remounting on each render
-type PredictFlashListProps = FlashListProps;
-const AnimatedFlashList = Animated.createAnimatedComponent(
- FlashList as unknown as React.ComponentType,
-) as unknown as React.ComponentType;
-
-const MarketListContent: React.FC = ({
- category,
- q,
- entryPoint = PredictEventValues.ENTRY_POINT.PREDICT_FEED,
- scrollCoordinator,
-}) => {
- const { styles } = useStyles(styleSheet, {});
- const ThemedPredictions = useAssetFromTheme(
- PredictionsLight,
- PredictionsDark,
- );
- const tw = useTailwind();
- const {
- marketData,
- isFetching,
- isFetchingMore,
- error,
- hasMore,
- refetch,
- fetchMore,
- } = usePredictMarketData({ category, q, pageSize: 20 });
-
- const [isRefreshing, setIsRefreshing] = useState(false);
-
- const scrollHandler = scrollCoordinator?.getScrollHandler(category);
-
- const renderItem = useCallback(
- (info: { item: PredictMarketType; index: number }) => (
-
- ),
- [category, entryPoint],
- );
-
- const keyExtractor = useCallback((item: PredictMarketType) => item.id, []);
-
- const handleEndReached = useCallback(() => {
- if (hasMore && !isFetchingMore) {
- fetchMore();
- }
- }, [hasMore, isFetchingMore, fetchMore]);
-
- const handleRefresh = useCallback(async () => {
- setIsRefreshing(true);
- try {
- await refetch();
- } finally {
- setIsRefreshing(false);
- }
- }, [refetch]);
-
- const renderFooter = useCallback(() => {
- if (!isFetchingMore) return null;
-
- return (
-
-
-
-
- );
- }, [isFetchingMore]);
-
- if (isFetching) {
- return (
-
-
-
-
-
-
- );
- }
-
- if (error) {
- return ;
- }
-
- if (q && q.length > 0 && marketData.length === 0) {
- return (
-
-
-
- {strings('predict.search_no_markets_found', { q })}
-
-
- );
- }
-
- if (!marketData || marketData.length === 0) {
- return (
-
-
- {strings('predict.search_empty_state', { category })}
-
-
- );
- }
-
- if (scrollCoordinator && scrollHandler) {
- return (
-
- }
- contentContainerStyle={tw.style('pt-2 pb-4 px-4')}
- showsVerticalScrollIndicator={false}
- removeClippedSubviews
- getItemType={() => 'market'}
- />
- );
- }
-
- return (
-
- }
- contentContainerStyle={tw.style('pt-4 pb-5 px-4')}
- showsVerticalScrollIndicator={false}
- removeClippedSubviews
- getItemType={() => 'market'}
- />
- );
-};
-
-export default MarketListContent;
diff --git a/app/components/UI/Predict/components/MarketListContent/index.tsx b/app/components/UI/Predict/components/MarketListContent/index.tsx
deleted file mode 100644
index c7e105a6acf..00000000000
--- a/app/components/UI/Predict/components/MarketListContent/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './MarketListContent';
diff --git a/app/components/UI/Predict/components/PredictFeedHeader/PredictFeedHeader.test.tsx b/app/components/UI/Predict/components/PredictFeedHeader/PredictFeedHeader.test.tsx
deleted file mode 100644
index 1a4df8c66da..00000000000
--- a/app/components/UI/Predict/components/PredictFeedHeader/PredictFeedHeader.test.tsx
+++ /dev/null
@@ -1,826 +0,0 @@
-import { fireEvent } from '@testing-library/react-native';
-import React from 'react';
-import { backgroundState } from '../../../../../util/test/initial-root-state';
-import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import PredictFeedHeader from './PredictFeedHeader';
-
-// Mock navigation
-const mockNavigate = jest.fn();
-const mockCanGoBack = jest.fn(() => true);
-const mockGoBack = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- ...jest.requireActual('@react-navigation/native'),
- useNavigation: () => ({
- navigate: mockNavigate,
- canGoBack: mockCanGoBack,
- goBack: mockGoBack,
- }),
-}));
-
-// Mock SearchBox component
-jest.mock('../SearchBox', () => {
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
- const ReactLocal = jest.requireActual('react');
- return {
- __esModule: true,
- default: jest.fn(({ isVisible, onCancel, onSearch }) => {
- if (!isVisible) return null;
- return ReactLocal.createElement(
- 'View',
- { testID: 'search-box-mock' },
- ReactLocal.createElement(
- 'Pressable',
- { testID: 'mock-cancel-button', onPress: onCancel },
- null,
- ),
- ReactLocal.createElement(
- 'Pressable',
- { testID: 'mock-search-trigger', onPress: () => onSearch('test') },
- null,
- ),
- );
- }),
- };
-});
-
-const initialState = {
- engine: {
- backgroundState,
- },
-};
-
-describe('PredictFeedHeader', () => {
- const mockOnSearchToggle = jest.fn();
- const mockOnSearchCancel = jest.fn();
- const mockOnSearch = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
- mockNavigate.mockClear();
- mockCanGoBack.mockClear();
- mockGoBack.mockClear();
- mockCanGoBack.mockReturnValue(true);
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe('default view', () => {
- it('displays Predictions heading when search is not visible', () => {
- // Arrange & Act
- const { getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(getByText('Predictions')).toBeOnTheScreen();
- });
-
- it('displays search toggle button when search is not visible', () => {
- // Arrange & Act
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(getByTestId('search-toggle-button')).toBeOnTheScreen();
- });
-
- it('does not display SearchBox when search is not visible', () => {
- // Arrange & Act
- const { queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(queryByTestId('search-box-mock')).toBeNull();
- });
- });
-
- describe('search toggle functionality', () => {
- it('calls onSearchToggle when search toggle button is pressed', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
-
- // Assert
- expect(mockOnSearchToggle).toHaveBeenCalledTimes(1);
- });
-
- it('does not call onSearchToggle when not pressed', () => {
- // Arrange & Act
- renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(mockOnSearchToggle).not.toHaveBeenCalled();
- });
- });
-
- describe('search visible view', () => {
- it('displays SearchBox when search is visible', () => {
- // Arrange & Act
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
- });
-
- it('does not display Predictions heading when search is visible', () => {
- // Arrange & Act
- const { queryByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(queryByText('Predictions')).not.toBeOnTheScreen();
- });
-
- it('does not display search toggle button when search is visible', () => {
- // Arrange & Act
- const { queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(queryByTestId('search-toggle-button')).toBeNull();
- });
- });
-
- describe('SearchBox integration', () => {
- it('passes onSearchCancel prop to SearchBox', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const cancelButton = getByTestId('mock-cancel-button');
- fireEvent.press(cancelButton);
-
- // Assert
- expect(mockOnSearchCancel).toHaveBeenCalledTimes(1);
- });
-
- it('passes onSearch prop to SearchBox', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchTrigger = getByTestId('mock-search-trigger');
- fireEvent.press(searchTrigger);
-
- // Assert
- expect(mockOnSearch).toHaveBeenCalledWith('test');
- });
-
- it('passes isSearchVisible prop to SearchBox', () => {
- // Arrange & Act
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert - SearchBox is rendered, which means isVisible=true was passed
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
- });
- });
-
- describe('visibility state changes', () => {
- it('switches from default view to search view', () => {
- // Arrange
- const { getByText, rerender, getByTestId, queryByText } =
- renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert initial state
- expect(getByText('Predictions')).toBeOnTheScreen();
-
- // Act - change to search visible
- rerender(
- ,
- );
-
- // Assert - search view is displayed
- expect(queryByText('Predictions')).not.toBeOnTheScreen();
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
- });
-
- it('switches from search view to default view', () => {
- // Arrange
- const { getByTestId, rerender, queryByTestId, getByText } =
- renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert initial state
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
-
- // Act - change to search not visible
- rerender(
- ,
- );
-
- // Assert - default view is displayed
- expect(queryByTestId('search-box-mock')).toBeNull();
- expect(getByText('Predictions')).toBeOnTheScreen();
- });
- });
-
- describe('callback isolation', () => {
- it('does not call onSearch when search toggle button is pressed', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
-
- // Assert
- expect(mockOnSearch).not.toHaveBeenCalled();
- expect(mockOnSearchCancel).not.toHaveBeenCalled();
- });
-
- it('does not call onSearchToggle when SearchBox cancel is pressed', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const cancelButton = getByTestId('mock-cancel-button');
- fireEvent.press(cancelButton);
-
- // Assert
- expect(mockOnSearchToggle).not.toHaveBeenCalled();
- expect(mockOnSearchCancel).toHaveBeenCalledTimes(1);
- });
-
- it('does not call onSearchCancel when search toggle button is pressed', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
-
- // Assert
- expect(mockOnSearchCancel).not.toHaveBeenCalled();
- });
-
- it('does not call onSearchToggle or onSearch when SearchBox cancel is pressed', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const cancelButton = getByTestId('mock-cancel-button');
- fireEvent.press(cancelButton);
-
- // Assert
- expect(mockOnSearchToggle).not.toHaveBeenCalled();
- expect(mockOnSearch).not.toHaveBeenCalled();
- });
- });
-
- describe('prop handling', () => {
- it('handles all callbacks correctly in sequence', () => {
- // Arrange
- const { getByTestId, rerender } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act - toggle search on
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
-
- // Assert - only toggle was called
- expect(mockOnSearchToggle).toHaveBeenCalledTimes(1);
- expect(mockOnSearchCancel).not.toHaveBeenCalled();
- expect(mockOnSearch).not.toHaveBeenCalled();
-
- // Act - rerender with search visible and search
- rerender(
- ,
- );
-
- const searchTrigger = getByTestId('mock-search-trigger');
- fireEvent.press(searchTrigger);
-
- // Assert - search was called
- expect(mockOnSearch).toHaveBeenCalledWith('test');
-
- // Act - cancel search
- const cancelButton = getByTestId('mock-cancel-button');
- fireEvent.press(cancelButton);
-
- // Assert - cancel was called
- expect(mockOnSearchCancel).toHaveBeenCalledTimes(1);
- });
-
- it('accepts different callback functions', () => {
- // Arrange
- const customToggle = jest.fn();
- const customCancel = jest.fn();
- const customSearch = jest.fn();
-
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
-
- // Assert
- expect(customToggle).toHaveBeenCalledTimes(1);
- expect(mockOnSearchToggle).not.toHaveBeenCalled();
- });
- });
-
- describe('multiple interactions', () => {
- it('handles multiple toggle presses', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
- fireEvent.press(searchToggleButton);
- fireEvent.press(searchToggleButton);
-
- // Assert
- expect(mockOnSearchToggle).toHaveBeenCalledTimes(3);
- });
-
- it('handles rapid state changes', () => {
- // Arrange
- const { rerender, queryByTestId, getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act & Assert - toggle visibility multiple times
- for (let i = 0; i < 5; i++) {
- const isVisible = i % 2 !== 0;
- rerender(
- ,
- );
-
- if (isVisible) {
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
- expect(queryByTestId('search-toggle-button')).toBeNull();
- } else {
- expect(getByTestId('search-toggle-button')).toBeOnTheScreen();
- expect(queryByTestId('search-box-mock')).toBeNull();
- }
- }
- });
- });
-
- describe('component structure', () => {
- it('maintains proper layout structure when search is not visible', () => {
- // Arrange & Act
- const { getByText, getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert - both heading and button are present
- expect(getByText('Predictions')).toBeOnTheScreen();
- expect(getByTestId('search-toggle-button')).toBeOnTheScreen();
- });
-
- it('renders only SearchBox when search is visible', () => {
- // Arrange & Act
- const { queryByText, getByTestId, queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert - only SearchBox is present
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
- expect(queryByText('Predictions')).not.toBeOnTheScreen();
- expect(queryByTestId('search-toggle-button')).toBeNull();
- });
- });
-
- describe('edge cases', () => {
- it('handles undefined state gracefully', () => {
- // Arrange & Act
- const { getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert - renders without errors
- expect(getByText('Predictions')).toBeOnTheScreen();
- });
-
- it('renders correctly when isSearchVisible is explicitly false', () => {
- // Arrange & Act
- const { getByText, queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(getByText('Predictions')).toBeOnTheScreen();
- expect(queryByTestId('search-box-mock')).toBeNull();
- });
-
- it('renders correctly when isSearchVisible is explicitly true', () => {
- // Arrange & Act
- const { getByTestId, queryByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(getByTestId('search-box-mock')).toBeOnTheScreen();
- expect(queryByText('Predictions')).toBeNull();
- });
- });
-
- describe('callback execution', () => {
- it('executes onSearchToggle exactly once per press', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchToggleButton = getByTestId('search-toggle-button');
- fireEvent.press(searchToggleButton);
-
- // Assert
- expect(mockOnSearchToggle).toHaveBeenCalledTimes(1);
- expect(mockOnSearchToggle).toHaveBeenCalledWith();
- });
-
- it('passes correct argument to onSearch callback', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const searchTrigger = getByTestId('mock-search-trigger');
- fireEvent.press(searchTrigger);
-
- // Assert
- expect(mockOnSearch).toHaveBeenCalledWith('test');
- expect(mockOnSearch).toHaveBeenCalledTimes(1);
- });
-
- it('executes onSearchCancel exactly once per press', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const cancelButton = getByTestId('mock-cancel-button');
- fireEvent.press(cancelButton);
-
- // Assert
- expect(mockOnSearchCancel).toHaveBeenCalledTimes(1);
- expect(mockOnSearchCancel).toHaveBeenCalledWith();
- });
- });
-
- describe('back button', () => {
- it('displays back button when search is not visible', () => {
- // Arrange & Act
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(getByTestId('back-button')).toBeOnTheScreen();
- });
-
- it('does not display back button when search is visible', () => {
- // Arrange & Act
- const { queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Assert
- expect(queryByTestId('back-button')).toBeNull();
- });
-
- it('calls goBack when back button is pressed and navigation can go back', () => {
- // Arrange
- mockCanGoBack.mockReturnValue(true);
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const backButton = getByTestId('back-button');
- fireEvent.press(backButton);
-
- // Assert
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- expect(mockNavigate).not.toHaveBeenCalled();
- });
-
- it('navigates to Wallet when back button is pressed and navigation cannot go back', () => {
- // Arrange
- mockCanGoBack.mockReturnValue(false);
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const backButton = getByTestId('back-button');
- fireEvent.press(backButton);
-
- // Assert
- expect(mockNavigate).toHaveBeenCalledWith('WalletTabHome', {
- screen: 'WalletTabStackFlow',
- params: {
- screen: 'WalletView',
- },
- });
- expect(mockGoBack).not.toHaveBeenCalled();
- });
-
- it('handles multiple back button presses', () => {
- // Arrange
- mockCanGoBack.mockReturnValue(true);
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const backButton = getByTestId('back-button');
- fireEvent.press(backButton);
- fireEvent.press(backButton);
- fireEvent.press(backButton);
-
- // Assert
- expect(mockGoBack).toHaveBeenCalledTimes(3);
- });
-
- it('does not interfere with search toggle when back button is pressed', () => {
- // Arrange
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Act
- const backButton = getByTestId('back-button');
- fireEvent.press(backButton);
-
- // Assert
- expect(mockOnSearchToggle).not.toHaveBeenCalled();
- expect(mockOnSearchCancel).not.toHaveBeenCalled();
- expect(mockOnSearch).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/app/components/UI/Predict/components/PredictFeedHeader/PredictFeedHeader.tsx b/app/components/UI/Predict/components/PredictFeedHeader/PredictFeedHeader.tsx
deleted file mode 100644
index d482de1163d..00000000000
--- a/app/components/UI/Predict/components/PredictFeedHeader/PredictFeedHeader.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import {
- Box,
- BoxAlignItems,
- BoxFlexDirection,
- BoxJustifyContent,
- Text,
- TextVariant,
-} from '@metamask/design-system-react-native';
-import React from 'react';
-import { Pressable } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
-import Icon, {
- IconName,
- IconSize,
-} from '../../../../../component-library/components/Icons/Icon';
-import { useTheme } from '../../../../../util/theme';
-import Routes from '../../../../../constants/navigation/Routes';
-import SearchBox from '../SearchBox';
-
-interface PredictFeedHeaderProps {
- isSearchVisible: boolean;
- onSearchToggle: () => void;
- onSearchCancel: () => void;
- onSearch: (query: string) => void;
-}
-
-const PredictFeedHeader: React.FC = ({
- isSearchVisible,
- onSearchToggle,
- onSearchCancel,
- onSearch,
-}) => {
- const { colors } = useTheme();
- const navigation = useNavigation();
-
- const handleBackPress = () => {
- if (navigation.canGoBack()) {
- navigation.goBack();
- } else {
- navigation.navigate(Routes.WALLET.HOME, {
- screen: Routes.WALLET.TAB_STACK_FLOW,
- params: {
- screen: Routes.WALLET_VIEW,
- },
- });
- }
- };
-
- return (
- <>
- {!isSearchVisible ? (
-
-
-
-
-
- Predictions
-
-
-
-
-
- ) : (
-
- )}
- >
- );
-};
-
-export default PredictFeedHeader;
diff --git a/app/components/UI/Predict/components/PredictFeedHeader/index.ts b/app/components/UI/Predict/components/PredictFeedHeader/index.ts
deleted file mode 100644
index b262dbf20f0..00000000000
--- a/app/components/UI/Predict/components/PredictFeedHeader/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './PredictFeedHeader';
diff --git a/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx b/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx
deleted file mode 100644
index fbea5f2f5a6..00000000000
--- a/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.test.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react-native';
-import {
- NavigationProp,
- ParamListBase,
- useNavigation,
-} from '@react-navigation/native';
-import PredictMarketList from './PredictMarketList';
-
-jest.mock('@react-navigation/native', () => ({
- useNavigation: jest.fn(),
- useFocusEffect: jest.fn(),
-}));
-
-jest.mock('../../../../Base/TabBar', () => {
- const { View } = jest.requireActual('react-native');
- return function MockTabBar() {
- return ;
- };
-});
-
-jest.mock('../../components/MarketListContent', () => {
- const { View, Text } = jest.requireActual('react-native');
- return function MockMarketListContent({ category }: { category: string }) {
- return (
-
- {category} markets
-
- );
- };
-});
-
-jest.mock('../../hooks/usePredictBalance', () => ({
- usePredictBalance: jest.fn(() => ({
- balance: 100,
- hasNoBalance: false,
- isLoading: false,
- isRefreshing: false,
- error: null,
- loadBalance: jest.fn(),
- })),
-}));
-
-let mockOnChangeTab:
- | ((changeInfo: { i: number; ref: unknown; from?: number }) => void)
- | undefined;
-
-jest.mock('@tommasini/react-native-scrollable-tab-view', () => {
- const { View } = jest.requireActual('react-native');
- return {
- __esModule: true,
- default: function MockScrollableTabView({
- children,
- renderTabBar,
- style,
- onChangeTab,
- }: {
- children?: React.ReactNode;
- renderTabBar?: false | (() => React.ReactNode);
- style?: object;
- onChangeTab?: (changeInfo: {
- i: number;
- ref: unknown;
- from?: number;
- }) => void;
- }) {
- mockOnChangeTab = onChangeTab;
- return (
-
- {renderTabBar && typeof renderTabBar === 'function' && renderTabBar()}
- {children}
-
- );
- },
- };
-});
-
-jest.mock('../../../../../../locales/i18n', () => ({
- strings: jest.fn((key: string) => {
- const translations: Record = {
- 'predict.category.trending': 'Trending',
- 'predict.category.new': 'New',
- 'predict.category.sports': 'Sports',
- 'predict.category.crypto': 'Crypto',
- 'predict.category.politics': 'Politics',
- };
- return translations[key] || key;
- }),
-}));
-
-describe('PredictMarketList', () => {
- const mockNavigation = {
- canGoBack: jest.fn(),
- goBack: jest.fn(),
- dispatch: jest.fn(),
- navigate: jest.fn(),
- reset: jest.fn(),
- isFocused: jest.fn(),
- addListener: jest.fn(),
- removeListener: jest.fn(),
- getState: jest.fn(),
- getId: jest.fn(),
- getParent: jest.fn(),
- setOptions: jest.fn(),
- setParams: jest.fn(),
- };
-
- const mockUseNavigation = useNavigation as jest.MockedFunction<
- typeof useNavigation
- >;
-
- const createMockSharedValue = (initialValue: number) => ({
- value: initialValue,
- get: jest.fn(() => initialValue),
- set: jest.fn(),
- addListener: jest.fn(),
- removeListener: jest.fn(),
- modify: jest.fn(),
- });
-
- const createMockScrollCoordinator = (overrides = {}) => ({
- balanceCardOffset: createMockSharedValue(0),
- balanceCardHeight: createMockSharedValue(0),
- setBalanceCardHeight: jest.fn(),
- setCurrentCategory: jest.fn(),
- getTabScrollPosition: jest.fn(() => 0),
- setTabScrollPosition: jest.fn(),
- getScrollHandler: jest.fn(),
- isBalanceCardHidden: jest.fn(() => false),
- updateBalanceCardHiddenState: jest.fn(),
- ...overrides,
- });
-
- beforeEach(() => {
- jest.clearAllMocks();
- mockOnChangeTab = undefined;
- mockUseNavigation.mockReturnValue(
- mockNavigation as unknown as NavigationProp,
- );
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- describe('default view (no search)', () => {
- it('displays scrollable tab view with all market categories', () => {
- render();
-
- expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen();
- expect(
- screen.getByTestId('market-list-content-trending'),
- ).toBeOnTheScreen();
- expect(screen.getByTestId('market-list-content-new')).toBeOnTheScreen();
- expect(
- screen.getByTestId('market-list-content-sports'),
- ).toBeOnTheScreen();
- expect(
- screen.getByTestId('market-list-content-crypto'),
- ).toBeOnTheScreen();
- expect(
- screen.getByTestId('market-list-content-politics'),
- ).toBeOnTheScreen();
- });
-
- it('displays correct category labels for all tabs', () => {
- render();
-
- expect(screen.getByTestId('category-trending')).toHaveTextContent(
- 'trending markets',
- );
- expect(screen.getByTestId('category-new')).toHaveTextContent(
- 'new markets',
- );
- expect(screen.getByTestId('category-sports')).toHaveTextContent(
- 'sports markets',
- );
- expect(screen.getByTestId('category-crypto')).toHaveTextContent(
- 'crypto markets',
- );
- expect(screen.getByTestId('category-politics')).toHaveTextContent(
- 'politics markets',
- );
- });
- });
-
- describe('search mode', () => {
- it('hides tab view when search is active without query', () => {
- render();
-
- expect(screen.queryByTestId('scrollable-tab-view')).not.toBeOnTheScreen();
- });
-
- it('displays search results when query is provided', () => {
- render();
-
- expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen();
- expect(
- screen.getByTestId('market-list-content-trending'),
- ).toBeOnTheScreen();
- });
- });
-
- describe('callbacks', () => {
- it('calls onTabChange when tab changes', () => {
- const mockOnTabChangeCallback = jest.fn();
-
- render(
- ,
- );
-
- mockOnChangeTab?.({ i: 2, ref: null });
-
- expect(mockOnTabChangeCallback).toHaveBeenCalledWith('sports');
- });
-
- it('updates scrollCoordinator when tab changes', () => {
- const mockSetCurrentCategory = jest.fn();
- const mockScrollCoordinator = createMockScrollCoordinator({
- setCurrentCategory: mockSetCurrentCategory,
- });
-
- render(
- ,
- );
-
- mockOnChangeTab?.({ i: 1, ref: null });
-
- expect(mockSetCurrentCategory).toHaveBeenCalledWith('new');
- });
-
- it('handles tab change with both scrollCoordinator and onTabChange', () => {
- const mockOnTabChangeCallback = jest.fn();
- const mockSetCurrentCategory = jest.fn();
- const mockScrollCoordinator = createMockScrollCoordinator({
- setCurrentCategory: mockSetCurrentCategory,
- });
-
- render(
- ,
- );
-
- mockOnChangeTab?.({ i: 4, ref: null });
-
- expect(mockSetCurrentCategory).toHaveBeenCalledWith('politics');
- expect(mockOnTabChangeCallback).toHaveBeenCalledWith('politics');
- });
-
- it('does not call callbacks when tab index is out of bounds', () => {
- const mockOnTabChangeCallback = jest.fn();
- const mockSetCurrentCategory = jest.fn();
- const mockScrollCoordinator = createMockScrollCoordinator({
- setCurrentCategory: mockSetCurrentCategory,
- });
-
- render(
- ,
- );
-
- mockOnChangeTab?.({ i: 10, ref: null });
-
- expect(mockSetCurrentCategory).not.toHaveBeenCalled();
- expect(mockOnTabChangeCallback).not.toHaveBeenCalled();
- });
- });
-
- describe('with scrollCoordinator', () => {
- it('renders with scrollCoordinator prop', () => {
- const mockScrollCoordinator = createMockScrollCoordinator();
-
- render(
- ,
- );
-
- expect(screen.getByTestId('scrollable-tab-view')).toBeOnTheScreen();
- });
- });
-});
diff --git a/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.tsx b/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.tsx
deleted file mode 100644
index cf629574f57..00000000000
--- a/app/components/UI/Predict/components/PredictMarketList/PredictMarketList.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import ScrollableTabView from '@tommasini/react-native-scrollable-tab-view';
-import React, { useCallback } from 'react';
-import { View } from 'react-native';
-import Animated, { useAnimatedStyle } from 'react-native-reanimated';
-import { PredictMarketListSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors';
-import { strings } from '../../../../../../locales/i18n';
-import TabBar from '../../../../Base/TabBar';
-import { useTheme } from '../../../../../util/theme';
-import { PredictEventValues } from '../../constants/eventNames';
-import MarketListContent from '../MarketListContent';
-import { PredictCategory } from '../../types';
-import { ScrollCoordinator } from '../../types/scrollCoordinator';
-
-interface PredictMarketListProps {
- isSearchVisible: boolean;
- searchQuery: string;
- scrollCoordinator?: ScrollCoordinator;
- onTabChange?: (tab: PredictCategory) => void;
-}
-
-const PredictMarketList: React.FC = ({
- isSearchVisible,
- searchQuery,
- scrollCoordinator,
- onTabChange,
-}) => {
- const tw = useTailwind();
- const { colors } = useTheme();
-
- const handleTabChange = useCallback(
- (changeInfo: { i: number; ref: unknown; from?: number }) => {
- const categories: PredictCategory[] = [
- 'trending',
- 'new',
- 'sports',
- 'crypto',
- 'politics',
- ];
- const category = categories[changeInfo.i];
- if (category) {
- // Update scroll coordinator
- if (scrollCoordinator) {
- scrollCoordinator.setCurrentCategory(category);
- }
- // Notify parent for analytics
- onTabChange?.(category);
- }
- },
- [scrollCoordinator, onTabChange],
- );
-
- const tabsAnimatedStyle = useAnimatedStyle(() => {
- if (!scrollCoordinator) {
- return {};
- }
- const offset = scrollCoordinator.balanceCardOffset.value;
- return {
- transform: [{ translateY: offset }],
- marginBottom: offset,
- };
- }, [scrollCoordinator]);
-
- return (
- <>
- {isSearchVisible && searchQuery.length > 0 && (
-
-
-
-
-
- )}
-
- {!isSearchVisible && (
-
- (
-
- )}
- style={tw.style('flex-1 w-full')}
- initialPage={0}
- onChangeTab={handleTabChange}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
- >
- );
-};
-
-export default PredictMarketList;
diff --git a/app/components/UI/Predict/components/PredictMarketList/index.ts b/app/components/UI/Predict/components/PredictMarketList/index.ts
deleted file mode 100644
index f271a5d3951..00000000000
--- a/app/components/UI/Predict/components/PredictMarketList/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './PredictMarketList';
diff --git a/app/components/UI/Predict/components/SearchBox/SearchBox.test.tsx b/app/components/UI/Predict/components/SearchBox/SearchBox.test.tsx
deleted file mode 100644
index dd8654330a8..00000000000
--- a/app/components/UI/Predict/components/SearchBox/SearchBox.test.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-import React from 'react';
-import { fireEvent } from '@testing-library/react-native';
-import { backgroundState } from '../../../../../util/test/initial-root-state';
-import renderWithProvider from '../../../../../util/test/renderWithProvider';
-import SearchBox from './SearchBox';
-
-// Mock Animated to avoid animation timing issues in tests
-jest.mock('react-native', () => {
- const RN = jest.requireActual('react-native');
- return {
- ...RN,
- Animated: {
- ...RN.Animated,
- timing: jest.fn(() => ({
- start: jest.fn(),
- })),
- parallel: jest.fn(() => ({
- start: jest.fn(),
- })),
- Value: jest.fn(() => ({
- setValue: jest.fn(),
- })),
- },
- };
-});
-
-// Mock the strings
-jest.mock('../../../../../../locales/i18n', () => ({
- strings: (key: string) => {
- const mockStrings: Record = {
- 'predict.search_placeholder': 'Search prediction markets',
- 'predict.search_cancel': 'Cancel',
- };
- return mockStrings[key] || key;
- },
-}));
-
-const initialState = {
- engine: {
- backgroundState,
- },
-};
-
-describe('SearchBox', () => {
- const mockOnSearch = jest.fn();
- const mockOnCancel = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe('Visibility behavior', () => {
- it('renders nothing when isVisible is false', () => {
- const { queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- expect(queryByTestId('search-box-container')).toBeNull();
- });
-
- it('renders search box when isVisible is true', () => {
- const { getByPlaceholderText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- expect(
- getByPlaceholderText('Search prediction markets'),
- ).toBeOnTheScreen();
- });
- });
-
- describe('Search functionality', () => {
- it('calls onSearch when user types in input', () => {
- const { getByPlaceholderText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
-
- expect(mockOnSearch).toHaveBeenCalledWith('bitcoin');
- });
-
- it('updates input value when user types', () => {
- const { getByPlaceholderText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'ethereum');
-
- expect(input.props.value).toBe('ethereum');
- });
-
- it('calls onSearch with empty string when input is cleared', () => {
- const { getByPlaceholderText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
- fireEvent.changeText(input, '');
-
- expect(mockOnSearch).toHaveBeenLastCalledWith('');
- });
- });
-
- describe('Clear functionality', () => {
- it('shows clear button when input has text', () => {
- const { getByPlaceholderText, getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
-
- expect(getByTestId('clear-button')).toBeOnTheScreen();
- });
-
- it('hides clear button when input is empty', () => {
- const { queryByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- // Input starts empty, so clear button should not be visible
- expect(queryByTestId('clear-button')).toBeNull();
- });
-
- it('clears input when clear button is pressed', () => {
- const { getByPlaceholderText, getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
- const clearButton = getByTestId('clear-button');
- fireEvent.press(clearButton);
-
- expect(input.props.value).toBe('');
- expect(mockOnSearch).toHaveBeenLastCalledWith('');
- });
- });
-
- describe('Cancel functionality', () => {
- it('calls onCancel when cancel button is pressed', () => {
- const { getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const cancelButton = getByText('Cancel');
- fireEvent.press(cancelButton);
-
- expect(mockOnCancel).toHaveBeenCalledTimes(1);
- });
-
- it('clears input when cancel button is pressed', () => {
- const { getByPlaceholderText, getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
- const cancelButton = getByText('Cancel');
- fireEvent.press(cancelButton);
-
- expect(input.props.value).toBe('');
- });
- });
-
- describe('Accessibility and UI elements', () => {
- it('displays search icon', () => {
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- expect(getByTestId('search-icon')).toBeOnTheScreen();
- });
-
- it('displays cancel button text', () => {
- const { getByText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- expect(getByText('Cancel')).toBeOnTheScreen();
- });
-
- it('has autoFocus enabled on input', () => {
- const { getByPlaceholderText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- expect(input.props.autoFocus).toBe(true);
- });
- });
-
- describe('Component integration', () => {
- it('handles multiple search queries correctly', () => {
- const { getByPlaceholderText } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
- fireEvent.changeText(input, 'ethereum');
- fireEvent.changeText(input, 'solana');
-
- expect(mockOnSearch).toHaveBeenCalledTimes(3);
- expect(mockOnSearch).toHaveBeenNthCalledWith(1, 'bitcoin');
- expect(mockOnSearch).toHaveBeenNthCalledWith(2, 'ethereum');
- expect(mockOnSearch).toHaveBeenNthCalledWith(3, 'solana');
- });
-
- it('maintains state correctly when visibility changes', () => {
- const { getByPlaceholderText, rerender } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const input = getByPlaceholderText('Search prediction markets');
- fireEvent.changeText(input, 'bitcoin');
-
- rerender(
- ,
- );
-
- rerender(
- ,
- );
-
- const newInput = getByPlaceholderText('Search prediction markets');
- expect(newInput.props.value).toBe('bitcoin');
- });
- });
-});
diff --git a/app/components/UI/Predict/components/SearchBox/SearchBox.tsx b/app/components/UI/Predict/components/SearchBox/SearchBox.tsx
deleted file mode 100644
index 4f9e8340e10..00000000000
--- a/app/components/UI/Predict/components/SearchBox/SearchBox.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { Pressable, TextInput, Animated } from 'react-native';
-import {
- Box,
- BoxFlexDirection,
- BoxAlignItems,
-} from '@metamask/design-system-react-native';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import Text, {
- TextVariant,
-} from '../../../../../component-library/components/Texts/Text';
-import Icon, {
- IconName,
- IconSize,
-} from '../../../../../component-library/components/Icons/Icon';
-import { useTheme } from '../../../../../util/theme';
-import { strings } from '../../../../../../locales/i18n';
-
-interface SearchBoxProps {
- isVisible: boolean;
- onCancel: () => void;
- onSearch: (query: string) => void;
-}
-
-const SearchBox: React.FC = ({
- isVisible,
- onCancel,
- onSearch,
-}) => {
- const tw = useTailwind();
- const { colors } = useTheme();
- const [searchQuery, setSearchQuery] = useState('');
- const [fadeAnim] = useState(new Animated.Value(0));
- const [slideAnim] = useState(new Animated.Value(-50));
-
- useEffect(() => {
- if (isVisible) {
- // Animate in
- Animated.parallel([
- Animated.timing(fadeAnim, {
- toValue: 1,
- duration: 200,
- useNativeDriver: true,
- }),
- Animated.timing(slideAnim, {
- toValue: 0,
- duration: 200,
- useNativeDriver: true,
- }),
- ]).start();
- } else {
- // Animate out
- Animated.parallel([
- Animated.timing(fadeAnim, {
- toValue: 0,
- duration: 150,
- useNativeDriver: true,
- }),
- Animated.timing(slideAnim, {
- toValue: -50,
- duration: 150,
- useNativeDriver: true,
- }),
- ]).start();
- }
- }, [isVisible, fadeAnim, slideAnim]);
-
- const handleSearch = (text: string) => {
- setSearchQuery(text);
- onSearch(text);
- };
-
- const handleCancel = () => {
- setSearchQuery('');
- onCancel();
- };
-
- if (!isVisible) {
- return null;
- }
-
- return (
-
-
-
-
-
- {searchQuery.length > 0 && (
- handleSearch('')}>
-
-
- )}
-
-
-
- {strings('predict.search_cancel')}
-
-
-
-
- );
-};
-
-export default SearchBox;
diff --git a/app/components/UI/Predict/components/SearchBox/index.tsx b/app/components/UI/Predict/components/SearchBox/index.tsx
deleted file mode 100644
index e83cea5c95e..00000000000
--- a/app/components/UI/Predict/components/SearchBox/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './SearchBox';
diff --git a/app/components/UI/Predict/hooks/useFeedScrollManager.test.ts b/app/components/UI/Predict/hooks/useFeedScrollManager.test.ts
new file mode 100644
index 00000000000..e1f5a534495
--- /dev/null
+++ b/app/components/UI/Predict/hooks/useFeedScrollManager.test.ts
@@ -0,0 +1,1148 @@
+import { renderHook, act, waitFor } from '@testing-library/react-native';
+import { Platform } from 'react-native';
+import {
+ useFeedScrollManager,
+ HEADER_ANIMATION_DURATION,
+ SCROLL_THRESHOLD,
+} from './useFeedScrollManager';
+
+const mockSharedValues: { value: unknown }[] = [];
+
+const mockRunOnJS = jest.fn(
+ (fn: (...args: unknown[]) => void) =>
+ (...args: unknown[]) =>
+ fn(...args),
+);
+
+const mockWithTiming = jest.fn((toValue: unknown) => toValue);
+
+jest.mock('react-native-reanimated', () => ({
+ useSharedValue: jest.fn((initialValue: unknown) => {
+ const sharedValue = { value: initialValue };
+ mockSharedValues.push(sharedValue);
+ return sharedValue;
+ }),
+ useAnimatedScrollHandler: jest.fn(
+ (config: { onScroll: (event: { contentOffset: { y: number } }) => void }) =>
+ config,
+ ),
+ withTiming: mockWithTiming,
+ Easing: {
+ out: jest.fn((easing: unknown) => easing),
+ cubic: jest.fn(),
+ },
+ runOnJS: mockRunOnJS,
+}));
+
+describe('useFeedScrollManager', () => {
+ const createMockRef = (height?: number) => ({
+ current:
+ height !== undefined
+ ? {
+ measure: jest.fn((callback) => {
+ callback(0, 0, 375, height);
+ }),
+ }
+ : null,
+ });
+
+ const originalPlatformOS = Platform.OS;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ mockSharedValues.length = 0;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+ Platform.OS = originalPlatformOS;
+ });
+
+ describe('constants', () => {
+ it('exports HEADER_ANIMATION_DURATION as 450ms', () => {
+ expect(HEADER_ANIMATION_DURATION).toBe(450);
+ });
+
+ it('exports SCROLL_THRESHOLD as 250 pixels', () => {
+ expect(SCROLL_THRESHOLD).toBe(250);
+ });
+ });
+
+ describe('initialization', () => {
+ it('initializes with default values', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.headerTranslateY).toBeDefined();
+ expect(result.current.headerTranslateY.value).toBe(0);
+ expect(result.current.headerHidden).toBe(false);
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('provides all required properties', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.headerTranslateY).toBeDefined();
+ expect(typeof result.current.headerHidden).toBe('boolean');
+ expect(typeof result.current.headerHeight).toBe('number');
+ expect(typeof result.current.tabBarHeight).toBe('number');
+ expect(typeof result.current.layoutReady).toBe('boolean');
+ expect(typeof result.current.activeIndex).toBe('number');
+ expect(typeof result.current.setActiveIndex).toBe('function');
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('initializes layoutReady as false before measurements', () => {
+ const headerRef = createMockRef();
+ const tabBarRef = createMockRef();
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.layoutReady).toBe(false);
+ });
+
+ it('initializes heights as 0 before measurement', () => {
+ const headerRef = createMockRef();
+ const tabBarRef = createMockRef();
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.headerHeight).toBe(0);
+ expect(result.current.tabBarHeight).toBe(0);
+ });
+ });
+
+ describe('setActiveIndex', () => {
+ it('updates activeIndex when called', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ result.current.setActiveIndex(2);
+ });
+
+ expect(result.current.activeIndex).toBe(2);
+ });
+
+ it('updates activeIndex to different values sequentially', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ result.current.setActiveIndex(1);
+ });
+ expect(result.current.activeIndex).toBe(1);
+
+ act(() => {
+ result.current.setActiveIndex(4);
+ });
+ expect(result.current.activeIndex).toBe(4);
+
+ act(() => {
+ result.current.setActiveIndex(0);
+ });
+ expect(result.current.activeIndex).toBe(0);
+ });
+
+ it('handles rapid index changes', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ result.current.setActiveIndex(1);
+ result.current.setActiveIndex(2);
+ result.current.setActiveIndex(3);
+ result.current.setActiveIndex(4);
+ });
+
+ expect(result.current.activeIndex).toBe(4);
+ });
+ });
+
+ describe('layout measurement', () => {
+ it('measures header height after mount', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as unknown as React.RefObject,
+ tabBarRef: tabBarRef as unknown as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.headerHeight).toBe(120);
+ });
+ });
+
+ it('measures tabBar height after mount', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as unknown as React.RefObject,
+ tabBarRef: tabBarRef as unknown as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.tabBarHeight).toBe(48);
+ });
+ });
+
+ it('sets layoutReady to true after both measurements complete', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as unknown as React.RefObject,
+ tabBarRef: tabBarRef as unknown as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+ });
+
+ it('retries measurement when initial height is zero', async () => {
+ let callCount = 0;
+ type MeasureCallback = (
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ ) => void;
+ const headerRef = {
+ current: {
+ measure: jest.fn((callback: MeasureCallback) => {
+ callCount++;
+ const height = callCount > 2 ? 120 : 0;
+ callback(0, 0, 375, height);
+ }),
+ },
+ };
+ const tabBarRef = createMockRef(48);
+
+ renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as unknown as React.RefObject,
+ tabBarRef: tabBarRef as unknown as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(200);
+ });
+
+ expect(headerRef.current.measure).toHaveBeenCalled();
+ });
+
+ it('measures on initial mount', async () => {
+ type MeasureCallback = (
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ ) => void;
+ const measureFn = jest.fn((callback: MeasureCallback) => {
+ callback(0, 0, 375, 120);
+ });
+ const headerRef = { current: { measure: measureFn } };
+ const tabBarRef = createMockRef(48);
+
+ renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as unknown as React.RefObject,
+ tabBarRef: tabBarRef as unknown as React.RefObject,
+ }),
+ );
+
+ expect(measureFn).toHaveBeenCalled();
+ });
+ });
+
+ describe('null refs handling', () => {
+ it('handles null headerRef gracefully', () => {
+ const headerRef = { current: null };
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(result.current.headerHeight).toBe(0);
+ });
+
+ it('handles null tabBarRef gracefully', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = { current: null };
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(result.current.tabBarHeight).toBe(0);
+ });
+
+ it('handles both refs being null', () => {
+ const headerRef = { current: null };
+ const tabBarRef = { current: null };
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(result.current.headerHeight).toBe(0);
+ expect(result.current.tabBarHeight).toBe(0);
+ expect(result.current.layoutReady).toBe(false);
+ });
+
+ it('keeps layoutReady false with zero height measurements', () => {
+ const headerRef = createMockRef(0);
+ const tabBarRef = createMockRef(0);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(result.current.layoutReady).toBe(false);
+ });
+ });
+
+ describe('hook stability', () => {
+ it('returns a callable setActiveIndex function after re-render', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const props = {
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ };
+
+ const { result, rerender } = renderHook(
+ (p: typeof props) => useFeedScrollManager(p),
+ { initialProps: props },
+ );
+
+ rerender(props);
+
+ act(() => {
+ result.current.setActiveIndex(2);
+ });
+
+ expect(result.current.activeIndex).toBe(2);
+ });
+
+ it('maintains state across re-renders', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const props = {
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ };
+
+ const { result, rerender } = renderHook(
+ (p: typeof props) => useFeedScrollManager(p),
+ { initialProps: props },
+ );
+
+ act(() => {
+ result.current.setActiveIndex(3);
+ });
+
+ rerender(props);
+
+ expect(result.current.activeIndex).toBe(3);
+ });
+
+ it('provides consistent API across multiple renders', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const props = {
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ };
+
+ const { result, rerender } = renderHook(
+ (p: typeof props) => useFeedScrollManager(p),
+ { initialProps: props },
+ );
+
+ expect(typeof result.current.setActiveIndex).toBe('function');
+
+ rerender(props);
+
+ expect(typeof result.current.setActiveIndex).toBe('function');
+ });
+ });
+
+ describe('cleanup', () => {
+ it('clears timeout on unmount without errors', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { unmount } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ unmount();
+
+ expect(() => {
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ }).not.toThrow();
+ });
+ });
+
+ describe('scrollHandler', () => {
+ interface ScrollHandler {
+ onScroll?: (event: { contentOffset: { y: number } }) => void;
+ }
+
+ it('returns scroll handler object', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('processes scroll event without throwing', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ }).not.toThrow();
+ });
+
+ it('handles scroll down events', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('handles scroll up events', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ handler.onScroll?.({ contentOffset: { y: 500 } });
+ handler.onScroll?.({ contentOffset: { y: 400 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('handles tab switching by skipping first scroll event', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ result.current.setActiveIndex(1);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+
+ expect(result.current.activeIndex).toBe(1);
+ });
+
+ it('handles zero delta scroll events', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('handles at-top scroll position on iOS', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ handler.onScroll?.({ contentOffset: { y: -10 } });
+ handler.onScroll?.({ contentOffset: { y: -5 } });
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('handles direction change during scroll', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 50 } });
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('accumulates scroll delta until threshold', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ for (let i = 0; i <= 300; i += 50) {
+ handler.onScroll?.({ contentOffset: { y: i } });
+ }
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('processes scroll events without errors for extended scrolling', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 400 } });
+ handler.onScroll?.({ contentOffset: { y: 500 } });
+ }).not.toThrow();
+ });
+
+ it('processes scroll up events after scroll down without errors', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 250 } });
+ handler.onScroll?.({ contentOffset: { y: 150 } });
+ handler.onScroll?.({ contentOffset: { y: 50 } });
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ }).not.toThrow();
+ });
+
+ it('handles iOS negative contentOffset at top of list', async () => {
+ Platform.OS = 'ios';
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: -10 } });
+ handler.onScroll?.({ contentOffset: { y: -20 } });
+ }).not.toThrow();
+ });
+
+ it('handles Android contentOffset below content inset at top of list', async () => {
+ Platform.OS = 'android';
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 400 } });
+ handler.onScroll?.({ contentOffset: { y: 500 } });
+ handler.onScroll?.({ contentOffset: { y: 400 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 150 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ }).not.toThrow();
+ });
+
+ it('handles Android direction reversal after scrolling down', async () => {
+ Platform.OS = 'android';
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 400 } });
+ handler.onScroll?.({ contentOffset: { y: 500 } });
+ handler.onScroll?.({ contentOffset: { y: 490 } });
+ }).not.toThrow();
+ });
+
+ it('resets accumulated delta when direction changes', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ act(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 50 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 50 } });
+ });
+
+ expect(result.current.headerHidden).toBe(false);
+ });
+
+ it('does not trigger header change when below threshold', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ act(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 50 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ });
+
+ expect(result.current.headerHidden).toBe(false);
+ });
+
+ it('handles scroll events after tab switch without errors', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ await act(async () => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ });
+
+ await act(async () => {
+ result.current.setActiveIndex(1);
+ });
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 500 } });
+ handler.onScroll?.({ contentOffset: { y: 600 } });
+ }).not.toThrow();
+
+ expect(result.current.activeIndex).toBe(1);
+ });
+
+ it('invokes runOnJS during scroll operations', async () => {
+ mockRunOnJS.mockClear();
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ await act(async () => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 400 } });
+ handler.onScroll?.({ contentOffset: { y: 500 } });
+ });
+
+ expect(result.current.scrollHandler).toBeDefined();
+ });
+
+ it('handles multiple direction changes without errors', async () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.layoutReady).toBe(true);
+ });
+
+ const handler = result.current.scrollHandler as unknown as ScrollHandler;
+
+ expect(() => {
+ handler.onScroll?.({ contentOffset: { y: 0 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 250 } });
+ handler.onScroll?.({ contentOffset: { y: 150 } });
+ handler.onScroll?.({ contentOffset: { y: 200 } });
+ handler.onScroll?.({ contentOffset: { y: 350 } });
+ handler.onScroll?.({ contentOffset: { y: 300 } });
+ handler.onScroll?.({ contentOffset: { y: 100 } });
+ }).not.toThrow();
+ });
+ });
+
+ describe('onLayout callbacks', () => {
+ it('provides onHeaderLayout callback', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(typeof result.current.onHeaderLayout).toBe('function');
+ });
+
+ it('provides onTabBarLayout callback', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(typeof result.current.onTabBarLayout).toBe('function');
+ });
+
+ it('onHeaderLayout can be called without error', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(() => {
+ act(() => {
+ result.current.onHeaderLayout({
+ nativeEvent: { layout: { height: 150, width: 375, x: 0, y: 0 } },
+ } as never);
+ });
+ }).not.toThrow();
+ });
+
+ it('onTabBarLayout can be called without error', () => {
+ const headerRef = createMockRef(120);
+ const tabBarRef = createMockRef(48);
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(() => {
+ act(() => {
+ result.current.onTabBarLayout({
+ nativeEvent: { layout: { height: 56, width: 375, x: 0, y: 0 } },
+ } as never);
+ });
+ }).not.toThrow();
+ });
+
+ it('updates headerHeight when onHeaderLayout called with value > 0', () => {
+ const headerRef = { current: null };
+ const tabBarRef = { current: null };
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.headerHeight).toBe(0);
+
+ act(() => {
+ result.current.onHeaderLayout({
+ nativeEvent: { layout: { height: 150, width: 375, x: 0, y: 0 } },
+ } as never);
+ });
+
+ expect(result.current.headerHeight).toBe(150);
+ });
+
+ it('updates tabBarHeight when onTabBarLayout called with value > 0', () => {
+ const headerRef = { current: null };
+ const tabBarRef = { current: null };
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ expect(result.current.tabBarHeight).toBe(0);
+
+ act(() => {
+ result.current.onTabBarLayout({
+ nativeEvent: { layout: { height: 56, width: 375, x: 0, y: 0 } },
+ } as never);
+ });
+
+ expect(result.current.tabBarHeight).toBe(56);
+ });
+
+ it('ignores zero height in onHeaderLayout', () => {
+ const headerRef = { current: null };
+ const tabBarRef = { current: null };
+
+ const { result } = renderHook(() =>
+ useFeedScrollManager({
+ headerRef: headerRef as React.RefObject,
+ tabBarRef: tabBarRef as React.RefObject,
+ }),
+ );
+
+ act(() => {
+ result.current.onHeaderLayout({
+ nativeEvent: { layout: { height: 100, width: 375, x: 0, y: 0 } },
+ } as never);
+ });
+
+ expect(result.current.headerHeight).toBe(100);
+
+ act(() => {
+ result.current.onHeaderLayout({
+ nativeEvent: { layout: { height: 0, width: 375, x: 0, y: 0 } },
+ } as never);
+ });
+
+ expect(result.current.headerHeight).toBe(100);
+ });
+ });
+});
diff --git a/app/components/UI/Predict/hooks/useFeedScrollManager.ts b/app/components/UI/Predict/hooks/useFeedScrollManager.ts
new file mode 100644
index 00000000000..1510275d600
--- /dev/null
+++ b/app/components/UI/Predict/hooks/useFeedScrollManager.ts
@@ -0,0 +1,246 @@
+import { useState, useCallback, useLayoutEffect } from 'react';
+import { View, Platform, LayoutChangeEvent } from 'react-native';
+import {
+ useSharedValue,
+ useAnimatedScrollHandler,
+ SharedValue,
+ withTiming,
+ Easing,
+ runOnJS,
+} from 'react-native-reanimated';
+
+// =============================================================================
+// TYPES
+// =============================================================================
+
+export interface UseFeedScrollManagerParams {
+ headerRef: React.RefObject;
+ tabBarRef: React.RefObject;
+}
+
+export interface UseFeedScrollManagerReturn {
+ headerTranslateY: SharedValue;
+ headerHidden: boolean;
+ headerHeight: number;
+ tabBarHeight: number;
+ layoutReady: boolean;
+ activeIndex: number;
+ setActiveIndex: (index: number) => void;
+ scrollHandler: ReturnType;
+ onHeaderLayout: (event: LayoutChangeEvent) => void;
+ onTabBarLayout: (event: LayoutChangeEvent) => void;
+}
+
+// =============================================================================
+// CONSTANTS
+// =============================================================================
+
+/**
+ * Duration of header show/hide animation in milliseconds.
+ */
+export const HEADER_ANIMATION_DURATION = 450;
+
+/**
+ * Accumulated scroll distance (in pixels) required before triggering header show/hide.
+ * This prevents accidental triggers from small scroll movements.
+ */
+export const SCROLL_THRESHOLD = 250;
+
+// =============================================================================
+// HOOK
+// =============================================================================
+
+/**
+ * Manages time-based header animation with binary show/hide states.
+ * Scroll direction triggers animated transitions - header is always fully visible or hidden.
+ *
+ * Features:
+ * - Binary state: Header is either fully visible (translateY: 0) or fully hidden (translateY: -headerHeight)
+ * - Threshold-based triggering: Requires SCROLL_THRESHOLD pixels of scroll before toggling
+ * - Direction change reset: Accumulated delta resets when scroll direction changes
+ * - Tab switch handling: Skips first scroll event after tab switch to prevent false triggers
+ * - Platform-aware: Handles iOS contentInset vs Android paddingTop differences
+ *
+ * @param params.headerRef - Ref to the header (balance card) View for height measurement
+ * @param params.tabBarRef - Ref to the tab bar View for height measurement
+ * @returns Scroll state and handlers for coordinating header animation
+ */
+export const useFeedScrollManager = ({
+ headerRef,
+ tabBarRef,
+}: UseFeedScrollManagerParams): UseFeedScrollManagerReturn => {
+ // Header state: 0 = visible, 1 = hidden (binary, not continuous)
+ const isHeaderHidden = useSharedValue(0);
+ // Header translateY: 0 = visible, -headerHeight = hidden
+ const headerTranslateY = useSharedValue(0);
+
+ // Shared values for worklet access
+ const sharedHeaderHeight = useSharedValue(0);
+ const sharedTabBarHeight = useSharedValue(0);
+ const lastScrollY = useSharedValue(0);
+
+ // Flag to skip direction detection on first scroll after tab switch
+ const isTabSwitching = useSharedValue(false);
+
+ // Track accumulated scroll delta for threshold detection
+ const accumulatedDelta = useSharedValue(0);
+ const lastDirection = useSharedValue(0); // 1 = down, -1 = up, 0 = none
+
+ // Layout measurements (JS side)
+ const [headerHeight, setHeaderHeight] = useState(0);
+ const [tabBarHeight, setTabBarHeight] = useState(0);
+ const [layoutReady, setLayoutReady] = useState(false);
+
+ // Tab state
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // React state mirror of isHeaderHidden for reactive UI updates
+ const [headerHidden, setHeaderHidden] = useState(false);
+
+ useLayoutEffect(() => {
+ if (headerRef.current) {
+ headerRef.current.measure((_x, _y, _width, height) => {
+ if (height > 0) {
+ setHeaderHeight(height);
+ sharedHeaderHeight.value = height;
+ }
+ });
+ }
+
+ if (tabBarRef.current) {
+ tabBarRef.current.measure((_x, _y, _width, height) => {
+ if (height > 0) {
+ setTabBarHeight(height);
+ sharedTabBarHeight.value = height;
+ }
+ });
+ }
+ }, [headerRef, tabBarRef, sharedHeaderHeight, sharedTabBarHeight]);
+
+ useLayoutEffect(() => {
+ setLayoutReady(headerHeight > 0 && tabBarHeight > 0);
+ }, [headerHeight, tabBarHeight]);
+
+ const onHeaderLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const { height } = event.nativeEvent.layout;
+ if (height > 0 && height !== sharedHeaderHeight.value) {
+ setHeaderHeight(height);
+ sharedHeaderHeight.value = height;
+ }
+ },
+ [sharedHeaderHeight],
+ );
+
+ const onTabBarLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const { height } = event.nativeEvent.layout;
+ if (height > 0 && height !== sharedTabBarHeight.value) {
+ setTabBarHeight(height);
+ sharedTabBarHeight.value = height;
+ }
+ },
+ [sharedTabBarHeight],
+ );
+
+ const animationConfig = {
+ duration: HEADER_ANIMATION_DURATION,
+ easing: Easing.out(Easing.cubic),
+ };
+
+ // Time-based scroll handler: detect direction, animate to show/hide
+ const scrollHandler = useAnimatedScrollHandler({
+ onScroll: (event) => {
+ 'worklet';
+ const currentY = event.contentOffset.y;
+
+ if (isTabSwitching.value) {
+ isTabSwitching.value = false;
+ lastScrollY.value = currentY;
+ accumulatedDelta.value = 0;
+ lastDirection.value = 0;
+ return;
+ }
+
+ // This fixes an issue on Android where the scroll event fires after the header hides
+ // and was causing the header to be shown again.
+ if (lastDirection.value === 1 && currentY < lastScrollY.value) {
+ lastDirection.value = 0;
+ lastScrollY.value = currentY;
+ return;
+ }
+
+ const delta = currentY - lastScrollY.value;
+ lastScrollY.value = currentY;
+
+ // At top of list - always show header
+ const contentInsetTop =
+ sharedHeaderHeight.value + sharedTabBarHeight.value;
+ const atTop =
+ Platform.OS === 'ios' ? currentY < 0 : currentY < contentInsetTop;
+
+ const currentDirection = delta > 0 ? 1 : delta < 0 ? -1 : 0;
+ if (currentDirection === 0) return;
+
+ if (currentDirection !== lastDirection.value) {
+ lastDirection.value = currentDirection;
+ accumulatedDelta.value = 0;
+ }
+
+ accumulatedDelta.value += Math.abs(delta);
+
+ if (atTop && isHeaderHidden.value === 1 && currentDirection === -1) {
+ isHeaderHidden.value = 0;
+ headerTranslateY.value = withTiming(0, animationConfig);
+ accumulatedDelta.value = 0;
+ lastDirection.value = 0;
+ runOnJS(setHeaderHidden)(false);
+ return;
+ }
+
+ if (accumulatedDelta.value < SCROLL_THRESHOLD) {
+ return;
+ }
+
+ // Scrolling down -> hide header
+ if (currentDirection === 1 && isHeaderHidden.value === 0) {
+ isHeaderHidden.value = 1;
+ headerTranslateY.value = withTiming(
+ -sharedHeaderHeight.value,
+ animationConfig,
+ );
+ accumulatedDelta.value = 0;
+ runOnJS(setHeaderHidden)(true);
+ }
+
+ // Scrolling up -> show header
+ if (currentDirection === -1 && isHeaderHidden.value === 1) {
+ isHeaderHidden.value = 0;
+ headerTranslateY.value = withTiming(0, animationConfig);
+ accumulatedDelta.value = 0;
+ runOnJS(setHeaderHidden)(false);
+ }
+ },
+ });
+
+ const handleSetActiveIndex = useCallback(
+ (index: number) => {
+ isTabSwitching.value = true;
+ setActiveIndex(index);
+ },
+ [isTabSwitching],
+ );
+
+ return {
+ headerTranslateY,
+ headerHidden,
+ headerHeight,
+ tabBarHeight,
+ layoutReady,
+ activeIndex,
+ setActiveIndex: handleSetActiveIndex,
+ scrollHandler,
+ onHeaderLayout,
+ onTabBarLayout,
+ };
+};
diff --git a/app/components/UI/Predict/hooks/useSharedScrollCoordinator.test.ts b/app/components/UI/Predict/hooks/useSharedScrollCoordinator.test.ts
deleted file mode 100644
index d383de11d11..00000000000
--- a/app/components/UI/Predict/hooks/useSharedScrollCoordinator.test.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import { useSharedScrollCoordinator } from './useSharedScrollCoordinator';
-import type { PredictCategory } from '../types';
-
-jest.mock('react-native-reanimated', () => {
- const actualReanimated = jest.requireActual('react-native-reanimated/mock');
- return {
- ...actualReanimated,
- useSharedValue: jest.fn((initialValue) => ({ value: initialValue })),
- useAnimatedScrollHandler: jest.fn((handlers) => handlers),
- runOnJS: jest.fn((fn) => fn),
- };
-});
-
-describe('useSharedScrollCoordinator', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- describe('initialization', () => {
- it('initializes with default values', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- expect(result.current.balanceCardOffset).toBeDefined();
- expect(result.current.balanceCardHeight).toBeDefined();
- expect(result.current.balanceCardOffset.value).toBe(0);
- expect(result.current.balanceCardHeight.value).toBe(0);
- });
-
- it('provides all required methods', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- expect(typeof result.current.setBalanceCardHeight).toBe('function');
- expect(typeof result.current.setCurrentCategory).toBe('function');
- expect(typeof result.current.getTabScrollPosition).toBe('function');
- expect(typeof result.current.setTabScrollPosition).toBe('function');
- expect(typeof result.current.getScrollHandler).toBe('function');
- expect(typeof result.current.isBalanceCardHidden).toBe('function');
- expect(typeof result.current.updateBalanceCardHiddenState).toBe(
- 'function',
- );
- });
- });
-
- describe('setBalanceCardHeight', () => {
- it('updates balance card height', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- act(() => {
- result.current.setBalanceCardHeight(150);
- });
-
- expect(result.current.balanceCardHeight.value).toBe(150);
- });
-
- it('updates with different height values', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- act(() => {
- result.current.setBalanceCardHeight(200);
- });
- expect(result.current.balanceCardHeight.value).toBe(200);
-
- act(() => {
- result.current.setBalanceCardHeight(100);
- });
- expect(result.current.balanceCardHeight.value).toBe(100);
- });
- });
-
- describe('tab scroll position management', () => {
- it('returns 0 for uninitialized tab position', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- const position = result.current.getTabScrollPosition('trending');
-
- expect(position).toBe(0);
- });
-
- it('stores and retrieves tab scroll position', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- act(() => {
- result.current.setTabScrollPosition('trending', 100);
- });
-
- const position = result.current.getTabScrollPosition('trending');
- expect(position).toBe(100);
- });
-
- it('manages independent positions for different tabs', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- act(() => {
- result.current.setTabScrollPosition('trending', 50);
- result.current.setTabScrollPosition('new', 100);
- result.current.setTabScrollPosition('sports', 150);
- });
-
- expect(result.current.getTabScrollPosition('trending')).toBe(50);
- expect(result.current.getTabScrollPosition('new')).toBe(100);
- expect(result.current.getTabScrollPosition('sports')).toBe(150);
- });
-
- it('updates existing tab position', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- act(() => {
- result.current.setTabScrollPosition('crypto', 200);
- });
- expect(result.current.getTabScrollPosition('crypto')).toBe(200);
-
- act(() => {
- result.current.setTabScrollPosition('crypto', 300);
- });
- expect(result.current.getTabScrollPosition('crypto')).toBe(300);
- });
- });
-
- describe('getScrollHandler', () => {
- it('returns scroll handler for each category', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- const trendingHandler = result.current.getScrollHandler('trending');
- const newHandler = result.current.getScrollHandler('new');
- const sportsHandler = result.current.getScrollHandler('sports');
- const cryptoHandler = result.current.getScrollHandler('crypto');
- const politicsHandler = result.current.getScrollHandler('politics');
-
- expect(trendingHandler).toBeDefined();
- expect(newHandler).toBeDefined();
- expect(sportsHandler).toBeDefined();
- expect(cryptoHandler).toBeDefined();
- expect(politicsHandler).toBeDefined();
- });
-
- it('returns trending handler for unknown category', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- const unknownHandler = result.current.getScrollHandler(
- 'unknown' as PredictCategory,
- );
- const trendingHandler = result.current.getScrollHandler('trending');
-
- expect(unknownHandler).toBe(trendingHandler);
- });
-
- it('returns consistent handler for same category', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- const handler1 = result.current.getScrollHandler('new');
- const handler2 = result.current.getScrollHandler('new');
-
- expect(handler1).toBe(handler2);
- });
- });
-
- describe('setCurrentCategory', () => {
- it('does not throw error when called', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- expect(() => {
- act(() => {
- result.current.setCurrentCategory('trending');
- });
- }).not.toThrow();
- });
-
- it('handles all valid categories', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
- const categories = [
- 'trending',
- 'new',
- 'sports',
- 'crypto',
- 'politics',
- ] as const;
-
- categories.forEach((category) => {
- expect(() => {
- act(() => {
- result.current.setCurrentCategory(category);
- });
- }).not.toThrow();
- });
- });
- });
-
- describe('isBalanceCardHidden', () => {
- it('returns false initially', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- expect(result.current.isBalanceCardHidden()).toBe(false);
- });
-
- it('returns boolean value', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- const hidden = result.current.isBalanceCardHidden();
-
- expect(typeof hidden).toBe('boolean');
- });
- });
-
- describe('updateBalanceCardHiddenState', () => {
- it('does not throw error when called', () => {
- const { result } = renderHook(() => useSharedScrollCoordinator());
-
- expect(() => {
- act(() => {
- result.current.updateBalanceCardHiddenState(true);
- });
- }).not.toThrow();
-
- expect(() => {
- act(() => {
- result.current.updateBalanceCardHiddenState(false);
- });
- }).not.toThrow();
- });
- });
-
- describe('hook stability', () => {
- it('maintains function references across renders', () => {
- const { result, rerender } = renderHook(() =>
- useSharedScrollCoordinator(),
- );
-
- const initialGetPosition = result.current.getTabScrollPosition;
- const initialSetPosition = result.current.setTabScrollPosition;
- const initialGetHandler = result.current.getScrollHandler;
-
- rerender();
-
- expect(result.current.getTabScrollPosition).toBe(initialGetPosition);
- expect(result.current.setTabScrollPosition).toBe(initialSetPosition);
- expect(result.current.getScrollHandler).toBe(initialGetHandler);
- });
-
- it('provides consistent API across multiple renders', () => {
- const { result, rerender } = renderHook(() =>
- useSharedScrollCoordinator(),
- );
-
- expect(typeof result.current.setBalanceCardHeight).toBe('function');
- rerender();
- expect(typeof result.current.setBalanceCardHeight).toBe('function');
- });
- });
-});
diff --git a/app/components/UI/Predict/hooks/useSharedScrollCoordinator.ts b/app/components/UI/Predict/hooks/useSharedScrollCoordinator.ts
deleted file mode 100644
index cea2bbd7fbd..00000000000
--- a/app/components/UI/Predict/hooks/useSharedScrollCoordinator.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { useRef, useCallback } from 'react';
-import {
- useSharedValue,
- runOnJS,
- useAnimatedScrollHandler,
-} from 'react-native-reanimated';
-import { PredictCategory } from '../types';
-
-export const useSharedScrollCoordinator = () => {
- const balanceCardOffset = useSharedValue(0);
- const balanceCardHeight = useSharedValue(0);
- const balanceCardHeightRef = useRef(0);
- const tabScrollPositionsRef = useRef