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>(new Map()); - - const setBalanceCardHeight = useCallback( - (height: number) => { - balanceCardHeightRef.current = height; - balanceCardHeight.value = height; - }, - [balanceCardHeight], - ); - - const setCurrentCategory = useCallback((_category: PredictCategory) => { - // No-op for now, can be used for future enhancements - }, []); - - const getTabScrollPosition = useCallback( - (category: PredictCategory): number => - tabScrollPositionsRef.current.get(category) || 0, - [], - ); - - const setTabScrollPosition = useCallback( - (category: PredictCategory, position: number) => { - tabScrollPositionsRef.current.set(category, position); - }, - [], - ); - - const updateBalanceCardVisibility = useCallback((_hidden: boolean) => { - // No-op for now, can be used for future enhancements - }, []); - - const trendingScrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const scrollY = event.contentOffset.y; - const cardHeight = balanceCardHeightRef.current; - - if (cardHeight > 0) { - const newOffset = Math.max(-cardHeight, Math.min(0, -scrollY)); - balanceCardOffset.value = newOffset; - } - - runOnJS(setTabScrollPosition)('trending', Math.max(0, scrollY)); - }, - }); - - const newScrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const scrollY = event.contentOffset.y; - const cardHeight = balanceCardHeightRef.current; - - if (cardHeight > 0) { - const newOffset = Math.max(-cardHeight, Math.min(0, -scrollY)); - balanceCardOffset.value = newOffset; - } - - runOnJS(setTabScrollPosition)('new', Math.max(0, scrollY)); - }, - }); - - const sportsScrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const scrollY = event.contentOffset.y; - const cardHeight = balanceCardHeightRef.current; - - if (cardHeight > 0) { - const newOffset = Math.max(-cardHeight, Math.min(0, -scrollY)); - balanceCardOffset.value = newOffset; - } - - runOnJS(setTabScrollPosition)('sports', Math.max(0, scrollY)); - }, - }); - - const cryptoScrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const scrollY = event.contentOffset.y; - const cardHeight = balanceCardHeightRef.current; - - if (cardHeight > 0) { - const newOffset = Math.max(-cardHeight, Math.min(0, -scrollY)); - balanceCardOffset.value = newOffset; - } - - runOnJS(setTabScrollPosition)('crypto', Math.max(0, scrollY)); - }, - }); - - const politicsScrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - const scrollY = event.contentOffset.y; - const cardHeight = balanceCardHeightRef.current; - - if (cardHeight > 0) { - const newOffset = Math.max(-cardHeight, Math.min(0, -scrollY)); - balanceCardOffset.value = newOffset; - } - - runOnJS(setTabScrollPosition)('politics', Math.max(0, scrollY)); - }, - }); - - const getScrollHandler = useCallback( - (category: PredictCategory) => { - switch (category) { - case 'trending': - return trendingScrollHandler; - case 'new': - return newScrollHandler; - case 'sports': - return sportsScrollHandler; - case 'crypto': - return cryptoScrollHandler; - case 'politics': - return politicsScrollHandler; - default: - return trendingScrollHandler; - } - }, - [ - trendingScrollHandler, - newScrollHandler, - sportsScrollHandler, - cryptoScrollHandler, - politicsScrollHandler, - ], - ); - - return { - balanceCardOffset, - balanceCardHeight, - setBalanceCardHeight, - setCurrentCategory, - getTabScrollPosition, - setTabScrollPosition, - getScrollHandler, - isBalanceCardHidden: () => false, - updateBalanceCardHiddenState: updateBalanceCardVisibility, - }; -}; diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx index 459c7fedc40..b007c8d3110 100644 --- a/app/components/UI/Predict/routes/index.tsx +++ b/app/components/UI/Predict/routes/index.tsx @@ -10,7 +10,7 @@ import PredictBuyPreview from '../views/PredictBuyPreview/PredictBuyPreview'; import PredictActivityDetail from '../components/PredictActivityDetail/PredictActivityDetail'; import { PredictNavigationParamList } from '../types/navigation'; import PredictAddFundsModal from '../views/PredictAddFundsModal/PredictAddFundsModal'; -import PredictFeed from '../views/PredictFeed/PredictFeed'; +import PredictFeed from '../views/PredictFeed'; import PredictGTMModal from '../components/PredictGTMModal'; import { Dimensions } from 'react-native'; diff --git a/app/components/UI/Predict/types/scrollCoordinator.ts b/app/components/UI/Predict/types/scrollCoordinator.ts deleted file mode 100644 index 0ab451ab33a..00000000000 --- a/app/components/UI/Predict/types/scrollCoordinator.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SharedValue } from 'react-native-reanimated'; -import { PredictCategory } from './index'; - -export interface ScrollCoordinator { - balanceCardOffset: SharedValue; - balanceCardHeight: SharedValue; - setBalanceCardHeight: (height: number) => void; - setCurrentCategory: (category: PredictCategory) => void; - getTabScrollPosition: (category: PredictCategory) => number; - setTabScrollPosition: (category: PredictCategory, position: number) => void; - getScrollHandler: (category: PredictCategory) => unknown; - isBalanceCardHidden: () => boolean; - updateBalanceCardHiddenState: (hidden: boolean) => void; -} diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx index 3dff8ed59b4..b17e63d7480 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx @@ -3,110 +3,235 @@ import React from 'react'; import { PredictMarketListSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import PredictFeed from './PredictFeed'; -/** - * Mock Strategy: - * - Only mock child components with complex dependencies and external services - * - Do NOT mock: Design system, theme utilities, SafeAreaView, Reanimated - * - Child components are mocked because they have their own test coverage - * and we're testing the parent's state management and component orchestration - */ - -// Mock child components - have their own test coverage -jest.mock('../../components/PredictFeedHeader', () => { - const { View, Pressable } = jest.requireActual('react-native'); +jest.mock('react-native-pager-view', () => { + const MockReact = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: jest.fn( - ({ isSearchVisible, onSearchToggle, onSearchCancel, onSearch }) => ( - - - - onSearch('test query')} - /> - {isSearchVisible && } - - ), - ), + default: jest.fn(({ children, onPageSelected }) => ( + + {MockReact.Children.map( + children, + (child: React.ReactElement, index: number) => + MockReact.cloneElement(child, { + testID: `pager-page-${index}`, + onTouchEnd: () => + onPageSelected?.({ nativeEvent: { position: index } }), + }), + )} + + )), }; }); jest.mock('../../components/PredictBalance', () => { const { View, Text } = jest.requireActual('react-native'); return { - PredictBalance: jest.fn(({ onLayout }) => ( - onLayout?.(100)}> + PredictBalance: jest.fn(() => ( + Balance Component )), }; }); -jest.mock('../../components/PredictMarketList', () => { +jest.mock('../../hooks/usePredictMarketData', () => ({ + usePredictMarketData: jest.fn(), +})); + +import { usePredictMarketData } from '../../hooks/usePredictMarketData'; + +const mockUsePredictMarketData = usePredictMarketData as jest.Mock; + +jest.mock('../../hooks/useFeedScrollManager', () => ({ + useFeedScrollManager: jest.fn(), +})); + +import { useFeedScrollManager } from '../../hooks/useFeedScrollManager'; + +const mockUseFeedScrollManager = useFeedScrollManager as jest.Mock; + +jest.mock('../../components/PredictMarket', () => { const { View, Text } = jest.requireActual('react-native'); return { __esModule: true, - default: jest.fn(({ isSearchVisible, searchQuery }) => ( - - Market List - {isSearchVisible && ( - Searching - )} - {searchQuery && {searchQuery}} + default: jest.fn(({ testID }) => ( + + Market Card )), }; }); -// Mock navigation hooks -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useRoute: jest.fn(() => ({ - params: { - entryPoint: 'homepage_new_prediction', - }, - })), - useFocusEffect: jest.fn((callback) => callback()), -})); - -// Mock session manager - external analytics service -jest.mock('../../services/PredictFeedSessionManager', () => { - const mockInstance = { - startSession: jest.fn(), - endSession: jest.fn(), - trackPageView: jest.fn(), - trackTabChange: jest.fn(), - enableAppStateListener: jest.fn(), - disableAppStateListener: jest.fn(), +jest.mock('../../components/PredictMarketSkeleton', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ testID }) => ), }; +}); +jest.mock('../../components/PredictOffline', () => { + const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: { - getInstance: jest.fn(() => mockInstance), + default: jest.fn(() => ), + }; +}); + +jest.mock('@shopify/flash-list', () => { + const MockReact = jest.requireActual('react'); + const { View, ScrollView } = jest.requireActual('react-native'); + const MockFlashList = MockReact.forwardRef( + ( + { + data, + renderItem, + keyExtractor, + testID, + ListFooterComponent, + }: { + data: { id: string }[]; + renderItem: (info: { + item: { id: string }; + index: number; + }) => React.ReactNode; + keyExtractor: (item: { id: string }) => string; + testID?: string; + ListFooterComponent?: React.ComponentType | React.ReactElement | null; + }, + ref: React.Ref, + ) => { + MockReact.useImperativeHandle(ref, () => ({})); + return ( + + {data?.map((item, index) => ( + + {renderItem({ item, index })} + + ))} + {ListFooterComponent && + (typeof ListFooterComponent === 'function' ? ( + + ) : ( + ListFooterComponent + ))} + + ); }, + ); + return { + FlashList: MockFlashList, + FlashListRef: {}, + FlashListProps: {}, }; }); -// Mock shared scroll coordinator - complex shared state management -jest.mock('../../hooks/useSharedScrollCoordinator', () => ({ - useSharedScrollCoordinator: jest.fn(() => ({ - balanceCardOffset: { value: 0 }, - balanceCardHeight: { value: 0 }, - setBalanceCardHeight: jest.fn(), - setCurrentCategory: jest.fn(), - getTabScrollPosition: jest.fn(() => 0), - setTabScrollPosition: jest.fn(), - getScrollHandler: jest.fn(), - isBalanceCardHidden: jest.fn(() => false), - updateBalanceCardHiddenState: jest.fn(), - })), +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: jest.fn(), + useRoute: jest.fn(), + useFocusEffect: jest.fn(), + }; +}); + +import { + useNavigation, + useRoute, + useFocusEffect, +} from '@react-navigation/native'; + +const mockUseNavigation = useNavigation as jest.Mock; +const mockUseRoute = useRoute as jest.Mock; +const mockUseFocusEffect = useFocusEffect as jest.Mock; + +const mockNavigation = { + canGoBack: jest.fn(() => true), + goBack: jest.fn(), + navigate: jest.fn(), +}; + +jest.mock('../../services/PredictFeedSessionManager', () => ({ + __esModule: true, + default: { + getInstance: jest.fn(), + }, +})); + +import PredictFeedSessionManager from '../../services/PredictFeedSessionManager'; + +const mockGetInstance = PredictFeedSessionManager.getInstance as jest.Mock; + +const mockSessionManager = { + startSession: jest.fn(), + endSession: jest.fn(), + trackPageView: jest.fn(), + trackTabChange: jest.fn(), + enableAppStateListener: jest.fn(), + disableAppStateListener: jest.fn(), +}; + +jest.mock('../../hooks/usePredictMeasurement', () => ({ + usePredictMeasurement: jest.fn(), })); +jest.mock('../../../../../component-library/components-temp/Tabs', () => { + const { View, Pressable, Text } = jest.requireActual('react-native'); + return { + TabsBar: jest.fn(({ tabs, activeIndex, onTabPress, testID }) => ( + + {tabs.map((tab: { key: string; label: string }, index: number) => ( + onTabPress(index)} + > + {tab.label} + + ))} + + + )), + TabItem: {}, + }; +}); + describe('PredictFeed', () => { beforeEach(() => { jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue(mockNavigation); + mockUseRoute.mockReturnValue({ + params: { + entryPoint: 'homepage_new_prediction', + }, + }); + mockUseFocusEffect.mockImplementation((callback: () => void) => callback()); + mockGetInstance.mockReturnValue(mockSessionManager); + mockUseFeedScrollManager.mockReturnValue({ + headerTranslateY: { value: 0 }, + headerHidden: false, + headerHeight: 100, + tabBarHeight: 48, + layoutReady: true, + activeIndex: 0, + setActiveIndex: jest.fn(), + scrollHandler: jest.fn(), + }); + mockUsePredictMarketData.mockReturnValue({ + marketData: [ + { id: '1', title: 'Test Market 1' }, + { id: '2', title: 'Test Market 2' }, + ], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); }); afterEach(() => { @@ -114,121 +239,366 @@ describe('PredictFeed', () => { }); describe('initial render', () => { - it('displays container with feed header, balance, and market list', () => { + it('renders container with top nav, balance, tabs, and market list', () => { const { getByTestId } = render(); expect( getByTestId(PredictMarketListSelectorsIDs.CONTAINER), ).toBeOnTheScreen(); - expect(getByTestId('predict-feed-header-mock')).toBeOnTheScreen(); + expect( + getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON), + ).toBeOnTheScreen(); + expect(getByTestId('predict-search-button')).toBeOnTheScreen(); expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - expect(getByTestId('predict-market-list-mock')).toBeOnTheScreen(); + expect(getByTestId('predict-feed-tabs')).toBeOnTheScreen(); + expect(getByTestId('pager-view-mock')).toBeOnTheScreen(); }); - it('starts with search hidden and empty query', () => { + it('hides search overlay on initial render', () => { const { queryByTestId } = render(); - expect(queryByTestId('search-visible-indicator')).toBeNull(); - expect(queryByTestId('market-list-query')).toBeNull(); + expect(queryByTestId('search-icon')).toBeNull(); }); }); - describe('search toggle functionality', () => { - it('shows search and hides balance when toggle pressed', () => { - const { getByTestId, queryByTestId } = render(); - const toggleButton = getByTestId('mock-search-toggle'); + describe('search functionality', () => { + it('opens search overlay when search button pressed', () => { + const { getByTestId } = render(); - fireEvent.press(toggleButton); + fireEvent.press(getByTestId('predict-search-button')); - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - expect(queryByTestId('predict-balance-mock')).toBeNull(); - expect(getByTestId('market-list-search-mode')).toBeOnTheScreen(); + expect(getByTestId('search-icon')).toBeOnTheScreen(); + }); + + it('closes search overlay when cancel button pressed', () => { + const { getByTestId, getByText, queryByTestId } = render(); + + fireEvent.press(getByTestId('predict-search-button')); + fireEvent.press(getByText('Cancel')); + + expect(queryByTestId('search-icon')).toBeNull(); }); }); - describe('search cancel functionality', () => { - it('hides search, shows balance, and clears query when cancel pressed', () => { - const { getByTestId, queryByTestId } = render(); - const toggleButton = getByTestId('mock-search-toggle'); - const searchInput = getByTestId('mock-search-input'); - fireEvent.press(toggleButton); - fireEvent.press(searchInput); - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - expect(getByTestId('market-list-query')).toBeOnTheScreen(); + describe('tab navigation', () => { + it('renders all five category tabs', () => { + const { getByTestId } = render(); - const cancelButton = getByTestId('mock-search-cancel'); - fireEvent.press(cancelButton); + expect(getByTestId('tab-trending')).toBeOnTheScreen(); + expect(getByTestId('tab-new')).toBeOnTheScreen(); + expect(getByTestId('tab-sports')).toBeOnTheScreen(); + expect(getByTestId('tab-crypto')).toBeOnTheScreen(); + expect(getByTestId('tab-politics')).toBeOnTheScreen(); + }); - expect(queryByTestId('search-visible-indicator')).toBeNull(); - expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - expect(queryByTestId('market-list-query')).toBeNull(); + it('does not track analytics when tab pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('tab-sports')); + + expect(mockSessionManager.trackTabChange).not.toHaveBeenCalled(); }); }); - describe('search functionality', () => { - it('updates and displays search query in market list', () => { - const { getByTestId, getByText } = render(); - const searchInput = getByTestId('mock-search-input'); + describe('session management', () => { + it('starts session and enables app state listener on mount', () => { + render(); - fireEvent.press(searchInput); + expect(mockSessionManager.enableAppStateListener).toHaveBeenCalled(); + expect(mockSessionManager.startSession).toHaveBeenCalledWith( + 'homepage_new_prediction', + 'trending', + ); + }); + + it('ends session and disables app state listener on unmount', () => { + const { unmount } = render(); + + unmount(); + + expect(mockSessionManager.endSession).toHaveBeenCalled(); + expect(mockSessionManager.disableAppStateListener).toHaveBeenCalled(); + }); + + it('tracks page view on screen focus', () => { + render(); - expect(getByText('test query')).toBeOnTheScreen(); - expect(getByTestId('market-list-query')).toBeOnTheScreen(); + expect(mockSessionManager.trackPageView).toHaveBeenCalled(); }); }); - describe('complete search workflow', () => { - it('executes full search cycle from toggle to cancel with all state changes', () => { - const { getByTestId, getByText, queryByTestId } = render(); - const toggleButton = getByTestId('mock-search-toggle'); - const searchInput = getByTestId('mock-search-input'); - const cancelButton = getByTestId('mock-search-cancel'); + describe('navigation', () => { + it('calls goBack when back button pressed and navigation can go back', () => { + const { getByTestId } = render(); - fireEvent.press(toggleButton); - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); - expect(queryByTestId('predict-balance-mock')).toBeNull(); + fireEvent.press(getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON)); - fireEvent.press(searchInput); - expect(getByText('test query')).toBeOnTheScreen(); + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); - fireEvent.press(cancelButton); - expect(queryByTestId('search-visible-indicator')).toBeNull(); - expect(getByTestId('predict-balance-mock')).toBeOnTheScreen(); - expect(queryByTestId('market-list-query')).toBeNull(); + it('navigates to wallet home when back button pressed and navigation cannot go back', () => { + mockNavigation.canGoBack.mockReturnValue(false); + const { getByTestId } = render(); + + fireEvent.press(getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON)); + + expect(mockNavigation.navigate).toHaveBeenCalled(); + }); + }); + + describe('loading states', () => { + it('renders skeleton loaders when fetching initial data', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: true, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId('skeleton-loading-trending-1')).toBeOnTheScreen(); + expect(getByTestId('skeleton-loading-trending-2')).toBeOnTheScreen(); + }); + }); + + describe('error states', () => { + it('renders offline component when fetch error occurs', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: new Error('Network error'), + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId('predict-offline-mock')).toBeOnTheScreen(); + }); + }); + + describe('empty states', () => { + it('renders empty state message when no markets available', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId('predict-empty-state-trending')).toBeOnTheScreen(); + }); + }); + + describe('search overlay interactions', () => { + it('displays search results when query is entered', () => { + const { getByTestId, getByPlaceholderText } = render(); + + fireEvent.press(getByTestId('predict-search-button')); + const searchInput = getByPlaceholderText('Search prediction markets'); + fireEvent.changeText(searchInput, 'bitcoin'); + + expect(getByTestId('predict-search-result-0')).toBeOnTheScreen(); + expect(getByTestId('predict-search-result-1')).toBeOnTheScreen(); + }); + + it('displays skeleton loaders while search is fetching', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: true, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId, getByPlaceholderText } = render(); + + fireEvent.press(getByTestId('predict-search-button')); + const searchInput = getByPlaceholderText('Search prediction markets'); + fireEvent.changeText(searchInput, 'bitcoin'); + + expect(getByTestId('search-skeleton-1')).toBeOnTheScreen(); }); - it('keeps market list visible throughout entire search workflow', () => { + it('clears search query when clear button is pressed', () => { + const { getByTestId, getByPlaceholderText, queryByTestId } = render( + , + ); + + fireEvent.press(getByTestId('predict-search-button')); + const searchInput = getByPlaceholderText('Search prediction markets'); + fireEvent.changeText(searchInput, 'test query'); + fireEvent.press(getByTestId('clear-button')); + + expect(queryByTestId('predict-search-result-0')).toBeNull(); + }); + }); + + describe('pager view interactions', () => { + it('updates active index and tracks analytics when page changes via swipe', () => { + const mockSetActiveIndex = jest.fn(); + mockUseFeedScrollManager.mockReturnValue({ + headerTranslateY: { value: 0 }, + headerHidden: false, + headerHeight: 100, + tabBarHeight: 48, + layoutReady: true, + activeIndex: 0, + setActiveIndex: mockSetActiveIndex, + scrollHandler: jest.fn(), + }); + const { getByTestId } = render(); - const marketList = getByTestId('predict-market-list-mock'); + const page1 = getByTestId('pager-page-1'); - expect(marketList).toBeOnTheScreen(); + fireEvent(page1, 'onTouchEnd'); - fireEvent.press(getByTestId('mock-search-toggle')); - expect(marketList).toBeOnTheScreen(); + expect(mockSetActiveIndex).toHaveBeenCalledWith(1); + expect(mockSessionManager.trackTabChange).toHaveBeenCalledWith('new'); + }); + }); - fireEvent.press(getByTestId('mock-search-input')); - expect(marketList).toBeOnTheScreen(); + describe('layout states', () => { + it('hides pager view when layout is not ready', () => { + mockUseFeedScrollManager.mockReturnValue({ + headerTranslateY: { value: 0 }, + headerHidden: false, + headerHeight: 100, + tabBarHeight: 48, + layoutReady: false, + activeIndex: 0, + setActiveIndex: jest.fn(), + scrollHandler: jest.fn(), + }); - fireEvent.press(getByTestId('mock-search-cancel')); - expect(marketList).toBeOnTheScreen(); + const { queryByTestId } = render(); + + expect(queryByTestId('pager-view-mock')).toBeNull(); }); + }); - it('toggles search visibility multiple times independently of query state', () => { - const { getByTestId, queryByTestId } = render(); - const toggleButton = getByTestId('mock-search-toggle'); - const cancelButton = getByTestId('mock-search-cancel'); + describe('market list rendering', () => { + it('renders market cards with correct testIDs using 1-based indexing', () => { + const { getByTestId } = render(); - fireEvent.press(toggleButton); - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); + expect( + getByTestId('predict-market-list-trending-card-1'), + ).toBeOnTheScreen(); + expect( + getByTestId('predict-market-list-trending-card-2'), + ).toBeOnTheScreen(); + }); + }); + + describe('search empty states', () => { + it('displays no results message when search returns empty', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId, getByPlaceholderText, getByText } = render( + , + ); + + fireEvent.press(getByTestId('predict-search-button')); + const searchInput = getByPlaceholderText('Search prediction markets'); + fireEvent.changeText(searchInput, 'nonexistent'); + + expect(getByText(/No results found/i)).toBeOnTheScreen(); + }); + + it('displays error state in search when fetch fails', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: new Error('Search error'), + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId, getByPlaceholderText, getAllByTestId } = render( + , + ); + + fireEvent.press(getByTestId('predict-search-button')); + const searchInput = getByPlaceholderText('Search prediction markets'); + fireEvent.changeText(searchInput, 'test'); + + const offlineElements = getAllByTestId('predict-offline-mock'); + expect(offlineElements.length).toBeGreaterThan(0); + }); + }); + + describe('pagination', () => { + it('renders footer skeleton when fetching more data', () => { + mockUsePredictMarketData.mockReturnValue({ + marketData: [ + { id: '1', title: 'Test Market 1' }, + { id: '2', title: 'Test Market 2' }, + ], + isFetching: false, + isFetchingMore: true, + error: null, + hasMore: true, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + + const { getByTestId } = render(); + + expect(getByTestId('skeleton-footer-trending-1')).toBeOnTheScreen(); + expect(getByTestId('skeleton-footer-trending-2')).toBeOnTheScreen(); + }); + }); + + describe('route params', () => { + it('starts session with entry point from route params', () => { + mockUseRoute.mockReturnValue({ + params: { + entryPoint: 'wallet_action_button', + }, + }); + + render(); + + expect(mockSessionManager.startSession).toHaveBeenCalledWith( + 'wallet_action_button', + 'trending', + ); + }); - fireEvent.press(cancelButton); - expect(queryByTestId('search-visible-indicator')).toBeNull(); + it('starts session with undefined entry point when not provided', () => { + mockUseRoute.mockReturnValue({ + params: {}, + }); - fireEvent.press(toggleButton); - expect(getByTestId('search-visible-indicator')).toBeOnTheScreen(); + render(); - fireEvent.press(cancelButton); - expect(queryByTestId('search-visible-indicator')).toBeNull(); + expect(mockSessionManager.startSession).toHaveBeenCalledWith( + undefined, + 'trending', + ); }); }); }); diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index b311c13b6f2..cfa7db2dc42 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -1,37 +1,671 @@ +import React, { + useState, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + View, + Pressable, + RefreshControl, + TextInput, + Platform, + LayoutChangeEvent, +} from 'react-native'; +import PagerView from 'react-native-pager-view'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { useState, useEffect, useCallback } from 'react'; -import { View } from 'react-native'; import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; -import { PredictMarketListSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; -import { useTheme } from '../../../../../util/theme'; -import { TraceName } from '../../../../../util/trace'; + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import Animated, { + useAnimatedStyle, + useAnimatedScrollHandler, + SharedValue, + interpolate, + Extrapolation, +} from 'react-native-reanimated'; +import { FlashList, FlashListProps, FlashListRef } from '@shopify/flash-list'; +import { + useNavigation, + useRoute, + RouteProp, + useFocusEffect, +} from '@react-navigation/native'; +import { + PredictMarketListSelectorsIDs, + getPredictMarketListSelector, +} from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import { usePredictMarketData } from '../../hooks/usePredictMarketData'; +import { useFeedScrollManager } from '../../hooks/useFeedScrollManager'; +import { + PredictCategory, + PredictMarket as PredictMarketType, +} from '../../types'; +import { + PredictEntryPoint, + PredictNavigationParamList, +} from '../../types/navigation'; +import { PredictEventValues } from '../../constants/eventNames'; +import PredictMarket from '../../components/PredictMarket'; +import PredictMarketSkeleton from '../../components/PredictMarketSkeleton'; import { PredictBalance } from '../../components/PredictBalance'; -import PredictFeedHeader from '../../components/PredictFeedHeader'; -import PredictMarketList from '../../components/PredictMarketList'; -import { useSharedScrollCoordinator } from '../../hooks/useSharedScrollCoordinator'; -import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; +import PredictOffline from '../../components/PredictOffline'; import PredictFeedSessionManager from '../../services/PredictFeedSessionManager'; -import { PredictNavigationParamList } from '../../types/navigation'; -import type { PredictCategory } from '../../types'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; +import { strings } from '../../../../../../locales/i18n'; +import { useTheme } from '../../../../../util/theme'; +import { TraceName } from '../../../../../util/trace'; +import Routes from '../../../../../constants/navigation/Routes'; +import { + TabItem, + TabsBar, +} from '../../../../../component-library/components-temp/Tabs'; + +interface FeedTab { + key: PredictCategory; + label: string; +} + +type PredictFlashListRef = FlashListRef; +type PredictFlashListProps = FlashListProps & { + ref?: React.Ref; +}; + +const AnimatedFlashList = Animated.createAnimatedComponent( + FlashList as unknown as React.ComponentType, +) as unknown as React.ComponentType; + +const PredictNavBackButton: React.FC = () => { + const navigation = useNavigation(); + + const handleBackPress = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.navigate( + Routes.WALLET.HOME as never, + { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + } as never, + ); + } + }, [navigation]); + + return ( + + + + ); +}; + +const PredictNavTitle: React.FC = () => ( + Predictions +); + +const PredictNavSearchButton: React.FC<{ onPress: () => void }> = ({ + onPress, +}) => ( + + + +); + +interface PredictFeedTopNavProps { + onSearchPress: () => void; +} + +const PredictFeedTopNav: React.FC = ({ + onSearchPress, +}) => ( + + + + + + + +); + +const PredictFeedHeader: React.FC = () => ( + + + +); + +interface PredictFeedTabBarProps { + tabs: FeedTab[]; + activeIndex: number; + onTabPress: (index: number) => void; +} + +const PredictFeedTabBar: React.FC = ({ + tabs, + activeIndex, + onTabPress, +}) => { + const tabItems: TabItem[] = useMemo( + () => + tabs.map((tab) => ({ + key: tab.key, + label: tab.label, + content: null, + })), + [tabs], + ); + + return ( + + ); +}; + +interface AnimatedHeaderProps { + headerTranslateY: SharedValue; + headerHeight: number; + headerRef: React.RefObject; + tabBarRef: React.RefObject; + tabs: FeedTab[]; + activeIndex: number; + onTabPress: (index: number) => void; + onHeaderLayout: (event: LayoutChangeEvent) => void; + onTabBarLayout: (event: LayoutChangeEvent) => void; +} + +const AnimatedHeader: React.FC = ({ + headerTranslateY, + headerHeight, + headerRef, + tabBarRef, + tabs, + activeIndex, + onTabPress, + onHeaderLayout, + onTabBarLayout, +}) => { + const tw = useTailwind(); + const { colors } = useTheme(); + + const animatedContainerStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: headerTranslateY.value }], + })); + + const animatedBalanceStyle = useAnimatedStyle(() => ({ + opacity: + headerHeight > 0 + ? interpolate( + headerTranslateY.value, + [-headerHeight, 0], + [0, 1], + Extrapolation.CLAMP, + ) + : 1, + })); + + return ( + + + + + + + + + ); +}; + +interface PredictMarketListItemProps { + market: PredictMarketType; + entryPoint: PredictEntryPoint; + testID?: string; +} + +const PredictMarketListItem: React.FC = ({ + market, + entryPoint, + testID, +}) => ; + +interface PredictTabContentProps { + category: PredictCategory; + isActive: boolean; + scrollHandler: ReturnType; + headerHeight: number; + tabBarHeight: number; + headerHidden: boolean; +} + +const PredictTabContent: React.FC = ({ + category, + isActive, + scrollHandler, + headerHeight, + tabBarHeight, + headerHidden, +}) => { + const tw = useTailwind(); + const listRef = useRef(null); + + const [hasEverBeenActive, setHasEverBeenActive] = useState(isActive); + useEffect(() => { + if (isActive && !hasEverBeenActive) { + setHasEverBeenActive(true); + } + }, [isActive, hasEverBeenActive]); + + const { + marketData, + isFetching, + error, + hasMore, + refetch, + fetchMore, + isFetchingMore, + } = usePredictMarketData({ category, pageSize: 20 }); + + const [isRefreshing, setIsRefreshing] = useState(false); + + const contentInsetTop = headerHeight + tabBarHeight; + const currentPaddingTop = headerHidden ? tabBarHeight : contentInsetTop; + + const hasFlashListMounted = useRef(false); + const getContentOffset = () => { + if (hasFlashListMounted.current) return undefined; + hasFlashListMounted.current = true; + return Platform.select({ + ios: { x: 0, y: headerHidden ? -tabBarHeight : -contentInsetTop }, + android: undefined, + }); + }; + + const renderItem = useCallback( + (info: { item: PredictMarketType; index: number }) => ( + + ), + [category], + ); + + 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, category]); + + const contentContainerStyle = useMemo( + () => + tw.style( + 'pb-4 px-4', + Platform.select({ + ios: { flexGrow: 1 }, + android: { + flexGrow: 1, + paddingTop: headerHidden ? tabBarHeight : contentInsetTop, + }, + }), + ), + [tw, contentInsetTop, headerHidden, tabBarHeight], + ); + + if (!hasEverBeenActive || (isFetching && !isRefreshing && !isFetchingMore)) { + return ( + + + + + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!marketData || marketData.length === 0) { + return ( + + + {strings('predict.search_empty_state', { category })} + + + ); + } + + return ( + + } + contentContainerStyle={contentContainerStyle} + showsVerticalScrollIndicator={false} + removeClippedSubviews + getItemType={() => 'market'} + /> + ); +}; + +interface PredictFeedTabsProps { + tabs: FeedTab[]; + activeIndex: number; + onPageChange: (index: number) => void; + scrollHandler: ReturnType; + headerHeight: number; + tabBarHeight: number; + headerHidden: boolean; +} + +const PredictFeedTabs: React.FC = ({ + tabs, + activeIndex, + onPageChange, + scrollHandler, + headerHeight, + tabBarHeight, + headerHidden, +}) => { + const tw = useTailwind(); + const pagerRef = useRef(null); + + useEffect(() => { + pagerRef.current?.setPage(activeIndex); + }, [activeIndex]); + + const handlePageSelected = useCallback( + (e: { nativeEvent: { position: number } }) => { + onPageChange(e.nativeEvent.position); + }, + [onPageChange], + ); + + return ( + + {tabs.map((tab, index) => ( + + + + ))} + + ); +}; + +interface PredictSearchOverlayProps { + isVisible: boolean; + onClose: () => void; +} + +const PredictSearchOverlay: React.FC = ({ + isVisible, + onClose, +}) => { + const tw = useTailwind(); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const [searchQuery, setSearchQuery] = useState(''); + + const { marketData, isFetching, error, refetch } = usePredictMarketData({ + category: 'trending', + q: searchQuery, + pageSize: 20, + }); + + const handleSearch = useCallback((text: string) => { + setSearchQuery(text); + }, []); + + const handleCancel = useCallback(() => { + setSearchQuery(''); + onClose(); + }, [onClose]); + + const renderItem = useCallback( + (info: { item: PredictMarketType; index: number }) => ( + + ), + [], + ); + + const keyExtractor = useCallback((item: PredictMarketType) => item.id, []); + + if (!isVisible) { + return null; + } + + return ( + + + + + + {searchQuery.length > 0 && ( + handleSearch('')}> + + + )} + + + + {strings('predict.search_cancel')} + + + + + {searchQuery.length > 0 && ( + + {isFetching ? ( + + + + + + ) : error ? ( + + ) : !marketData || marketData.length === 0 ? ( + + + {strings('predict.search_no_markets_found', { q: searchQuery })} + + + ) : ( + + data={marketData} + renderItem={renderItem} + keyExtractor={keyExtractor} + contentContainerStyle={tw.style('px-4 pt-4 pb-4')} + showsVerticalScrollIndicator={false} + /> + )} + + )} + + ); +}; + +const PredictFeed: React.FC = () => { + // This can't be a constant at the top of the file because it would not + // react to locale changes in the app. + const tabs: FeedTab[] = useMemo( + () => [ + { key: 'trending', label: strings('predict.category.trending') }, + { key: 'new', label: strings('predict.category.new') }, + { key: 'sports', label: strings('predict.category.sports') }, + { key: 'crypto', label: strings('predict.category.crypto') }, + { key: 'politics', label: strings('predict.category.politics') }, + ], + [], + ); -const PredictFeed = () => { const tw = useTailwind(); const { colors } = useTheme(); const insets = useSafeAreaInsets(); const route = useRoute>(); + + const headerRef = useRef(null); + const tabBarRef = useRef(null); + const [isSearchVisible, setIsSearchVisible] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const scrollCoordinator = useSharedScrollCoordinator(); const sessionManager = PredictFeedSessionManager.getInstance(); - // Track screen load performance usePredictMeasurement({ traceName: TraceName.PredictFeedView, conditions: [!isSearchVisible], @@ -41,102 +675,99 @@ const PredictFeed = () => { }, }); - // Initialize session and enable AppState listener on mount useEffect(() => { - // Enable AppState listener to detect app backgrounding sessionManager.enableAppStateListener(); - - // Start session sessionManager.startSession(route.params?.entryPoint, 'trending'); return () => { - // End session and disable listener on unmount sessionManager.endSession(); sessionManager.disableAppStateListener(); }; }, [route.params?.entryPoint, sessionManager]); - // Track page views when returning from market details useFocusEffect( useCallback(() => { - // Note: Return from background is handled by AppState listener - // This only tracks when user navigates back from market details sessionManager.trackPageView(); }, [sessionManager]), ); - const handleBalanceCardLayout = (height: number) => { - scrollCoordinator.setBalanceCardHeight(height); - }; + const { + headerTranslateY, + headerHidden, + headerHeight, + tabBarHeight, + layoutReady, + activeIndex, + setActiveIndex, + scrollHandler, + onHeaderLayout, + onTabBarLayout, + } = useFeedScrollManager({ headerRef, tabBarRef }); - // Handle tab changes - track analytics - const handleTabChange = useCallback( - (tab: PredictCategory) => { - sessionManager.trackTabChange(tab); + const handleTabPress = useCallback( + (index: number) => { + setActiveIndex(index); }, - [sessionManager], + [setActiveIndex], ); - const balanceCardAnimatedStyle = useAnimatedStyle(() => { - const offset = scrollCoordinator.balanceCardOffset.value; - const height = scrollCoordinator.balanceCardHeight.value; - const opacity = height > 0 ? Math.max(0, 1 + offset / height) : 1; - - return { - transform: [{ translateY: offset }], - opacity, - }; - }); - - const handleSearchToggle = () => { - setIsSearchVisible(true); - }; - - const handleSearchCancel = () => { - setIsSearchVisible(false); - setSearchQuery(''); - }; - - const handleSearch = (query: string) => { - setSearchQuery(query); - }; + const handlePageChange = useCallback( + (index: number) => { + setActiveIndex(index); + const category = tabs[index]?.key; + if (category) { + sessionManager.trackTabChange(category); + } + }, + [setActiveIndex, sessionManager, tabs], + ); return ( - - - - + setIsSearchVisible(true)} /> + + + + + + {layoutReady && ( + - - {!isSearchVisible && ( - - - )} - - - + + + setIsSearchVisible(false)} + /> + ); }; diff --git a/bitrise.yml b/bitrise.yml index c3b9791e9c5..4c807410064 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -180,6 +180,30 @@ pipelines: build_ios_main_rc: depends_on: - bump_version_code + exp_builds_to_testflight_pipeline: + workflows: + bump_version_code: {} + build_android_main_exp: + depends_on: + - bump_version_code + build_ios_main_exp: + depends_on: + - bump_version_code + upload_ios_main_to_testflight: + depends_on: + - build_ios_main_exp + rc_builds_to_testflight_pipeline: + workflows: + bump_version_code: {} + build_android_main_rc: + depends_on: + - bump_version_code + build_ios_main_rc: + depends_on: + - bump_version_code + upload_ios_main_to_testflight: + depends_on: + - build_ios_main_rc #Stages reference workflows. Those workflows cannot but utility "_this-is-a-utility" stages: bump_version_stage: @@ -3321,6 +3345,50 @@ workflows: inputs: - deploy_path: browserstack_uploaded_flask_apps.json title: Bitrise Deploy Browserstack Uploaded Flask Apps + upload_ios_main_to_testflight: + before_run: + - code_setup + after_run: + - notify_failure + steps: + - pull-intermediate-files@1: + inputs: + - artifact_sources: .* + title: Pull iOS build artifacts + - script@1: + title: Setup App Store Connect API Key + inputs: + - content: |- + #!/usr/bin/env bash + set -e + + ./scripts/setup-app-store-connect-api-key.sh \ + "$BITRISE_APP_STORE_CONNECT_API_KEY_ISSUER_ID" \ + "$BITRISE_APP_STORE_CONNECT_API_KEY_KEY_ID" \ + "$BITRISE_APP_STORE_CONNECT_API_KEY_KEY_CONTENT" + - script@1: + title: Upload to TestFlight via Fastlane + inputs: + - content: |- + #!/usr/bin/env bash + set -e + + bash ./scripts/upload-to-testflight.sh \ + "${BITRISEIO_PIPELINE_TITLE:-Unknown}" \ + "${BITRISE_GIT_BRANCH:-Unknown}" \ + "${BITRISE_APP_STORE_IPA_PATH:-}" + - script@1: + title: Cleanup API Key + is_always_run: true + inputs: + - content: |- + #!/usr/bin/env bash + rm -f ios/AuthKey.p8 + echo "๐Ÿงน Cleaned up API key file" + meta: + bitrise.io: + stack: osx-xcode-16.3.x + machine_type_id: g2.mac.4large set_main_target_workflow: steps: - share-pipeline-variable@1: @@ -3406,13 +3474,13 @@ app: VERSION_NAME: 7.62.0 - opts: is_expand: false - VERSION_NUMBER: 3168 + VERSION_NUMBER: 3418 - opts: is_expand: false FLASK_VERSION_NAME: 7.62.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3168 + FLASK_VERSION_NUMBER: 3418 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/Gemfile b/ios/Gemfile index 84fa33da56e..f761002d3e0 100644 --- a/ios/Gemfile +++ b/ios/Gemfile @@ -8,3 +8,6 @@ gem 'cocoapods', '1.16.2' # Allow all version updates up to but excluding 7.1.0 gem 'activesupport', '>= 6.1.7.3', '< 7.1.0' + +# Fastlane for iOS automation +gem 'fastlane', '>= 2.220.0' diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index bd93a4d2694..59cfe984b20 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -5,6 +5,7 @@ GEM base64 nkf rexml + abbrev (0.1.2) activesupport (7.0.8.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) @@ -15,8 +16,30 @@ GEM algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) + artifactory (3.0.17) atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1200.0) + aws-sdk-core (3.241.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.119.0) + aws-sdk-core (~> 3, >= 3.241.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.210.0) + aws-sdk-core (~> 3, >= 3.241.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) base64 (0.2.0) + bigdecimal (4.0.1) claide (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) @@ -55,34 +78,206 @@ GEM nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) + colored (1.2) colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) concurrent-ruby (1.2.3) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.230.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) ffi (1.17.2) ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) httpclient (2.8.3) i18n (1.14.4) concurrent-ruby (~> 1.0) + jmespath (1.6.2) json (2.8.1) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) minitest (5.22.3) molinillo (0.8.0) + multi_json (1.19.1) + multipart-post (2.4.1) + mutex_m (0.3.0) nanaimo (0.4.0) nap (1.1.0) + naturally (2.3.0) netrc (0.11.0) nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + plist (3.7.2) public_suffix (4.0.7) + rake (13.3.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) rexml (3.3.9) + rouge (3.28.0) ruby-macho (2.5.1) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) @@ -90,6 +285,10 @@ GEM colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-22 @@ -99,6 +298,7 @@ PLATFORMS DEPENDENCIES activesupport (>= 6.1.7.3, < 7.1.0) cocoapods (= 1.16.2) + fastlane (>= 2.220.0) RUBY VERSION ruby 3.1.6p260 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index cdc205aa31b..09f59a2e605 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3418; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3418; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3418; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3418; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3418; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3168; + CURRENT_PROJECT_VERSION = 3418; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; diff --git a/ios/fastlane/.gitignore b/ios/fastlane/.gitignore new file mode 100644 index 00000000000..0ab4bee0ea0 --- /dev/null +++ b/ios/fastlane/.gitignore @@ -0,0 +1,25 @@ +# Fastlane specific files +*.ipa +*.dSYM.zip +report.xml +*.html +*.junit + +# App Store Connect API Key +*.p8 +AuthKey_*.p8 + +# Sensitive data - Environment variables with API keys +.env +.env.local +.env.*.local +# Note: env.local.example is safe to commit (it's a template) + +# Xcode +*.xcuserstate +*.xcuserdatad + +# Build artifacts +build/ +output/ + diff --git a/ios/fastlane/Appfile b/ios/fastlane/Appfile new file mode 100644 index 00000000000..4054f67463c --- /dev/null +++ b/ios/fastlane/Appfile @@ -0,0 +1,9 @@ +# Appfile for MetaMask Mobile iOS +# More information: https://docs.fastlane.tools/advanced/#appfile + +app_identifier("io.metamask.MetaMask") +team_id("48XVW22RCG") + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile + diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 00000000000..0e68753d26d --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,106 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +default_platform(:ios) + +platform :ios do + # Upload existing IPA to TestFlight (for use in CI/CD) + desc "Upload existing IPA to TestFlight for MetaMask main app" + lane :upload_to_testflight_only do |options| + ipa_path = options[:ipa_path] + # Convert groups parameter to array (Fastlane CLI passes strings, not arrays) + groups_param = options[:groups] || ['MetaMask BETA & Release Candidates'] + groups = groups_param.is_a?(Array) ? groups_param : [groups_param] + notify_external_testers = true + + if ipa_path.nil? || ipa_path.empty? + UI.user_error!("You must provide an ipa_path using ipa_path: '/path/to/app.ipa'") + end + + unless File.exist?(ipa_path) + UI.user_error!("IPA file not found at: #{ipa_path}") + end + + UI.message("Uploading IPA to TestFlight: #{ipa_path}") + + # Get API key from environment variables + api_key_issuer_id = ENV['APP_STORE_CONNECT_API_KEY_ISSUER_ID'] + api_key_key_id = ENV['APP_STORE_CONNECT_API_KEY_KEY_ID'] + api_key_key_filepath = ENV['APP_STORE_CONNECT_API_KEY_KEY_FILEPATH'] + + # Validate each variable individually without exposing values + missing_vars = [] + + if api_key_issuer_id.nil? || api_key_issuer_id.to_s.strip.empty? + missing_vars << "APP_STORE_CONNECT_API_KEY_ISSUER_ID" + else + UI.message("โœ“ APP_STORE_CONNECT_API_KEY_ISSUER_ID is set (length: #{api_key_issuer_id.length})") + end + + if api_key_key_id.nil? || api_key_key_id.to_s.strip.empty? + missing_vars << "APP_STORE_CONNECT_API_KEY_KEY_ID" + else + UI.message("โœ“ APP_STORE_CONNECT_API_KEY_KEY_ID is set (length: #{api_key_key_id.length})") + end + + if api_key_key_filepath.nil? || api_key_key_filepath.to_s.strip.empty? + missing_vars << "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH" + else + UI.message("โœ“ APP_STORE_CONNECT_API_KEY_KEY_FILEPATH is set (length: #{api_key_key_filepath.length})") + UI.message(" Path value: #{api_key_key_filepath}") + + # Convert to absolute path if relative + unless api_key_key_filepath.start_with?('/') + api_key_key_filepath = File.expand_path(api_key_key_filepath) + UI.message(" Resolved to absolute path: #{api_key_key_filepath}") + end + + # Check if file exists + unless File.exist?(api_key_key_filepath) + UI.user_error!("API key file not found at: #{api_key_key_filepath}") + end + + # Check if file is readable + unless File.readable?(api_key_key_filepath) + UI.user_error!("API key file is not readable at: #{api_key_key_filepath}") + end + + UI.message(" File exists and is readable") + end + + # Report all missing variables at once + unless missing_vars.empty? + UI.user_error!("Missing required environment variables: #{missing_vars.join(', ')}") + end + + # Build changelog message from options or environment variables + changelog_message = options[:changelog] || "No changelog provided" + + # Generate API key hash using app_store_connect_api_key action + api_key = app_store_connect_api_key( + key_id: api_key_key_id, + issuer_id: api_key_issuer_id, + key_filepath: api_key_key_filepath, + ) + + # Upload to TestFlight with API key from app_store_connect_api_key action + upload_to_testflight( + api_key: api_key, + ipa: ipa_path, + distribute_external: true, + groups: groups, + notify_external_testers: notify_external_testers, + changelog: changelog_message + ) + end +end + diff --git a/ios/fastlane/README.md b/ios/fastlane/README.md new file mode 100644 index 00000000000..7c0e9305d93 --- /dev/null +++ b/ios/fastlane/README.md @@ -0,0 +1,12 @@ +# Fastlane TestFlight Deployment + +This Fastlane configuration handles uploading iOS builds to TestFlight via Bitrise CI/CD. + +## Overview + +The `upload_to_testflight_only` lane uploads pre-built IPA files to TestFlight and distributes them to external testing groups. + +## Documentation + +- [Fastlane Documentation](https://docs.fastlane.tools) +- [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi) diff --git a/scripts/find-ipa-file.sh b/scripts/find-ipa-file.sh new file mode 100755 index 00000000000..3b53e9a86f8 --- /dev/null +++ b/scripts/find-ipa-file.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# Find IPA file for iOS builds +# This script can be used in both Bitrise and GitHub Actions workflows +# +# Usage: +# ./scripts/find-ipa-file.sh [ipa_path] +# +# Arguments: +# ipa_path - Optional: Direct path to IPA file (if already known) +# +# Environment variables: +# BITRISE_APP_STORE_IPA_PATH - IPA path from previous build step (Bitrise) +# IPA_PATH - IPA path from previous build step (generic) +# +# Output: +# Sets IPA_PATH environment variable with absolute path to IPA file + +set -e + +# Check if IPA path is provided as argument +if [ $# -ge 1 ] && [ -n "$1" ]; then + IPA_PATH="$1" + echo "โœ… Using provided IPA path: $IPA_PATH" +elif [ -n "${BITRISE_APP_STORE_IPA_PATH:-}" ] && [ -f "$BITRISE_APP_STORE_IPA_PATH" ]; then + # Check Bitrise-specific variable first + IPA_PATH="$BITRISE_APP_STORE_IPA_PATH" + echo "โœ… Found IPA from BITRISE_APP_STORE_IPA_PATH: $IPA_PATH" +elif [ -n "${IPA_PATH:-}" ] && [ -f "$IPA_PATH" ]; then + # Check generic IPA_PATH variable + echo "โœ… Found IPA from IPA_PATH: $IPA_PATH" +else + # Fallback: search for IPA in build output directory + IPA_DIR="ios/build/output" + if [ -d "$IPA_DIR" ]; then + # Find the most recent IPA file by modification time + # Use find with -exec to run ls -t on all found files, which sorts by modification time (newest first) + IPA_PATH=$(find "$IPA_DIR" -name "*.ipa" -type f -exec ls -t {} + 2>/dev/null | head -1) + if [ -z "$IPA_PATH" ]; then + echo "โŒ Error: No IPA file found in $IPA_DIR" + echo "Available files:" + ls -la "$IPA_DIR" || true + exit 1 + fi + echo "โœ… Found IPA in build output: $IPA_PATH" + else + echo "โŒ Error: IPA directory not found: $IPA_DIR" + if [ -n "${BITRISE_APP_STORE_IPA_PATH:-}" ]; then + echo "BITRISE_APP_STORE_IPA_PATH: $BITRISE_APP_STORE_IPA_PATH" + fi + if [ -n "${IPA_PATH:-}" ]; then + echo "IPA_PATH: $IPA_PATH" + fi + exit 1 + fi +fi + +# Verify the file exists +if [ ! -f "$IPA_PATH" ]; then + echo "โŒ Error: IPA file not found at: $IPA_PATH" + exit 1 +fi + +# Convert to absolute path if relative +if [[ "$IPA_PATH" != /* ]]; then + IPA_PATH="$(pwd)/$IPA_PATH" +fi + +# Export based on CI environment +if command -v envman &> /dev/null; then + # Bitrise: use envman to persist variable + envman add --key IPA_PATH --value "$IPA_PATH" +elif [ -n "${GITHUB_ENV:-}" ]; then + # GitHub Actions: use GITHUB_ENV + echo "IPA_PATH=$IPA_PATH" >> "$GITHUB_ENV" +else + # Local or other CI: just export + export IPA_PATH="$IPA_PATH" +fi + +echo "IPA_PATH set to: $IPA_PATH" + diff --git a/scripts/setup-app-store-connect-api-key.sh b/scripts/setup-app-store-connect-api-key.sh new file mode 100755 index 00000000000..1330b57aa8c --- /dev/null +++ b/scripts/setup-app-store-connect-api-key.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Setup App Store Connect API Key for Fastlane +# This script can be used in both Bitrise and GitHub Actions workflows +# +# Usage: +# ./scripts/setup-app-store-connect-api-key.sh +# +# Arguments: +# issuer_id - App Store Connect API Key Issuer ID +# key_id - App Store Connect API Key ID +# key_content - App Store Connect API Key content (.p8 file content) + +set -e + +# Check if exactly 3 arguments are provided +if [[ $# -ne 3 ]]; then + echo "Usage: $0 " + exit 1 +fi + +ISSUER_ID="$1" +KEY_ID="$2" +KEY_CONTENT="$3" + +# Validate arguments are not empty +if [ -z "$ISSUER_ID" ]; then + echo "โŒ Error: issuer_id argument is required" + exit 1 +fi + +if [ -z "$KEY_ID" ]; then + echo "โŒ Error: key_id argument is required" + exit 1 +fi + +if [ -z "$KEY_CONTENT" ]; then + echo "โŒ Error: key_content argument is required" + exit 1 +fi + +KEY_FILEPATH="$(pwd)/ios/AuthKey.p8" + +# Write the .p8 key file using printf for safety (handles special characters correctly) +printf '%s' "$KEY_CONTENT" > "$KEY_FILEPATH" +chmod 600 "$KEY_FILEPATH" + +# Export environment variables for Fastlane +export APP_STORE_CONNECT_API_KEY_ISSUER_ID="$ISSUER_ID" +export APP_STORE_CONNECT_API_KEY_KEY_ID="$KEY_ID" +export APP_STORE_CONNECT_API_KEY_KEY_FILEPATH="$KEY_FILEPATH" + +# Persist variables based on CI environment (for passing to subsequent steps) +if [ -n "${BITRISE_BUILD_URL:-}" ] || command -v envman &> /dev/null; then + # Use envman to persist variables in Bitrise + if command -v envman &> /dev/null; then + envman add --key APP_STORE_CONNECT_API_KEY_ISSUER_ID --value "$ISSUER_ID" + envman add --key APP_STORE_CONNECT_API_KEY_KEY_ID --value "$KEY_ID" + envman add --key APP_STORE_CONNECT_API_KEY_KEY_FILEPATH --value "$KEY_FILEPATH" + fi +elif [ -n "${GITHUB_ENV:-}" ]; then + # Use GITHUB_ENV to persist variables in GitHub Actions + echo "APP_STORE_CONNECT_API_KEY_ISSUER_ID=$ISSUER_ID" >> "$GITHUB_ENV" + echo "APP_STORE_CONNECT_API_KEY_KEY_ID=$KEY_ID" >> "$GITHUB_ENV" + echo "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH=$KEY_FILEPATH" >> "$GITHUB_ENV" +fi + +echo "โœ… App Store Connect API Key configured" + diff --git a/scripts/upload-to-testflight.sh b/scripts/upload-to-testflight.sh new file mode 100644 index 00000000000..79a2460bdf5 --- /dev/null +++ b/scripts/upload-to-testflight.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# Upload IPA to TestFlight via Fastlane +# This script can be used in both Bitrise and GitHub Actions workflows +# +# Usage: +# ./scripts/upload-to-testflight.sh [ipa_path] +# +# Arguments: +# pipeline_name - Pipeline or workflow name (required) +# branch - Git branch name (required) +# ipa_path - Optional: Direct path to IPA file (if not provided, uses find-ipa-file.sh) +# +# Environment variables: +# IPA_PATH - IPA path (set by find-ipa-file.sh if not provided as argument) + +set -e + +# Validate required arguments +if [ $# -lt 2 ]; then + echo "Usage: $0 [ipa_path]" + exit 1 +fi + +PIPELINE_NAME="$1" +BRANCH="$2" +LOCAL_IPA_PATH="$3" + +# Get IPA path: use argument if provided, otherwise use find-ipa-file.sh +if [ -n "$LOCAL_IPA_PATH" ]; then + # Use provided IPA path and convert to absolute if relative + IPA_PATH="$LOCAL_IPA_PATH" + if [[ "$IPA_PATH" != /* ]]; then + IPA_PATH="$(pwd)/$IPA_PATH" + fi + if [ ! -f "$IPA_PATH" ]; then + echo "โŒ Error: IPA file not found at: $IPA_PATH" + exit 1 + fi +else + # Use find-ipa-file.sh to locate the IPA + ./scripts/find-ipa-file.sh + # IPA_PATH is now set by find-ipa-file.sh +fi + +echo "๐Ÿš€ Uploading to TestFlight..." +echo "IPA: $IPA_PATH" +echo "Group: MetaMask Fastlane Test" + +# Extract environment from pipeline name (first part before underscore) +ENVIRONMENT=$(echo "$PIPELINE_NAME" | cut -d'_' -f1 | tr '[:lower:]' '[:upper:]') +if [ -z "$ENVIRONMENT" ] || [ "$ENVIRONMENT" = "$PIPELINE_NAME" ]; then + ENVIRONMENT="Unknown" +fi + +CHANGELOG="Pipeline: ${PIPELINE_NAME} | Environment: ${ENVIRONMENT} | Branch: ${BRANCH}" + +echo "Pipeline: $PIPELINE_NAME" +echo "Changelog: $CHANGELOG" + +# Change to ios directory where fastlane folder is located +cd ios + +bundle exec fastlane upload_to_testflight_only \ + ipa_path:"$IPA_PATH" \ + groups:"MetaMask Fastlane Test" \ + changelog:"$CHANGELOG" +