Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 52 additions & 14 deletions app/component-library/components-temp/Tabs/TabsBar/TabsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ const TabsBar: React.FC<TabsBarProps> = ({
const underlineWidthAnimated = useRef(new Animated.Value(0)).current;
const tabLayouts = useRef<{ x: number; width: number }[]>([]);
const currentAnimation = useRef<Animated.CompositeAnimation | null>(null);
const rafCallbackId = useRef<number | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [layoutsReady, setLayoutsReady] = useState(false);
const activeIndexRef = useRef(activeIndex);

// State for automatic overflow detection
const [scrollEnabled, setScrollEnabled] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);

// Keep activeIndexRef in sync with activeIndex
useEffect(() => {
activeIndexRef.current = activeIndex;
}, [activeIndex]);

// Reset layout data when tabs change structurally (count or content)
const tabKeys = useMemo(() => tabs.map((tab) => tab.key).join(','), [tabs]);
const prevTabKeys = useRef<string>('');
Expand Down Expand Up @@ -176,7 +183,8 @@ const TabsBar: React.FC<TabsBarProps> = ({
const gapsWidth = (tabs.length - 1) * 24; // Account for gaps between tabs
const calculatedContentWidth = totalTabsWidth + gapsWidth;

const shouldScroll = calculatedContentWidth > containerWidth;
// Account for container's px-4 padding (16px * 2 = 32px)
const shouldScroll = calculatedContentWidth > containerWidth - 32;
setScrollEnabled(shouldScroll);
}
}
Expand All @@ -197,6 +205,13 @@ const TabsBar: React.FC<TabsBarProps> = ({
return;
}

// Check if this is a significant change (more than 1px difference)
const previousLayout = tabLayouts.current[index];
const hasSignificantChange =
!previousLayout ||
Math.abs(previousLayout.width - width) > 1 ||
Math.abs(previousLayout.x - x) > 1;

// Store layout data
tabLayouts.current[index] = { x, width };

Expand All @@ -205,22 +220,41 @@ const TabsBar: React.FC<TabsBarProps> = ({
(layout, i) => i >= tabs.length || (layout && layout.width > 0),
);

if (allLayoutsReady && !layoutsReady) {
setLayoutsReady(true);

// Update scroll detection
if (containerWidth > 0) {
const totalWidth = tabLayouts.current.reduce(
(sum, layout) => sum + (layout?.width || 0),
0,
);
const gapsWidth = (tabs.length - 1) * 24;
const shouldScroll = totalWidth + gapsWidth > containerWidth;
setScrollEnabled(shouldScroll);
if (allLayoutsReady) {
// Recalculate scroll detection on initial load OR when any layout changes significantly
if (!layoutsReady || hasSignificantChange) {
if (!layoutsReady) {
setLayoutsReady(true);
}

// If layouts were already ready and any tab changed, re-animate the active tab
// This ensures re-animation triggers regardless of which tab's callback fires last
if (layoutsReady && hasSignificantChange) {
// Cancel any pending RAF to avoid multiple callbacks
if (rafCallbackId.current !== null) {
cancelAnimationFrame(rafCallbackId.current);
}
rafCallbackId.current = requestAnimationFrame(() => {
rafCallbackId.current = null;
animateToTab(activeIndexRef.current);
});
}

// Update scroll detection
if (containerWidth > 0) {
const totalWidth = tabLayouts.current.reduce(
(sum, layout) => sum + (layout?.width || 0),
0,
);
const gapsWidth = (tabs.length - 1) * 24;
// Account for container's px-4 padding (16px * 2 = 32px)
const shouldScroll = totalWidth + gapsWidth > containerWidth - 32;
setScrollEnabled(shouldScroll);
}
}
}
},
[tabs.length, layoutsReady, containerWidth],
[tabs.length, layoutsReady, containerWidth, animateToTab],
);

// Cleanup effect
Expand All @@ -230,6 +264,10 @@ const TabsBar: React.FC<TabsBarProps> = ({
currentAnimation.current.stop();
currentAnimation.current = null;
}
if (rafCallbackId.current !== null) {
cancelAnimationFrame(rafCallbackId.current);
rafCallbackId.current = null;
}
},
[],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,21 @@ describe('ListItemSelect', () => {
const component = getByTestId('list-item-select');
expect(component).toBeOnTheScreen();
});

it('passes through custom listItemProps', () => {
const { getByTestId } = render(
<ListItemSelect
onPress={() => null}
listItemProps={{
accessibilityHint: 'Custom Hint',
testID: 'nested-list-item',
}}
>
<View testID="test-content">Test Content</View>
</ListItemSelect>,
);

const component = getByTestId('nested-list-item');
expect(component.props.accessibilityHint).toBe('Custom Hint');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const ListItemSelect: React.FC<ListItemSelectProps> = ({
onLongPress,
gap = DEFAULT_SELECTITEM_GAP,
verticalAlignment,
listItemProps,
...props
}) => {
const { styles } = useStyles(styleSheet, { style, isDisabled });
Expand All @@ -34,7 +35,7 @@ const ListItemSelect: React.FC<ListItemSelectProps> = ({
onLongPress={onLongPress}
{...props}
>
<ListItem gap={gap} style={styles.listItem}>
<ListItem gap={gap} style={styles.listItem} {...listItemProps}>
{children}
</ListItem>
{isSelected && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface ListItemSelectProps
* Optional prop to determine if the item is disabled.
*/
isDisabled?: boolean;
/**
* Optional prop to configure the prop of nested ListItem
*/
listItemProps?: Partial<ListItemProps>;
}

/**
Expand Down
10 changes: 4 additions & 6 deletions app/component-library/components/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,10 @@ const Toast = forwardRef((_, ref: React.ForwardedRef<ToastRef>) => {
{ translateY: translateYProgress.value - TAB_BAR_HEIGHT - customOffset },
],
}));
const baseStyle: StyleProp<Animated.AnimateStyle<StyleProp<ViewStyle>>> =
useMemo(
() => [styles.base, animatedStyle],
/* eslint-disable-next-line */
[],
);
const baseStyle: StyleProp<ViewStyle> = useMemo(
() => [styles.base, animatedStyle],
[styles.base, animatedStyle],
);

const resetState = () => setToastOptions(undefined);

Expand Down
37 changes: 33 additions & 4 deletions app/components/UI/Carousel/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
renderHook,
} from '@testing-library/react-native';
import { useSelector, useDispatch } from 'react-redux';
import { Linking } from 'react-native';
import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager';
import AppConstants from '../../../core/AppConstants';
import Carousel, { useFetchCarouselSlides } from './';
Expand Down Expand Up @@ -70,6 +71,10 @@ jest.mock('../../../core/DeeplinkManager/SharedDeeplinkManager', () => ({
parse: jest.fn(() => Promise.resolve()),
}));

jest.mock('react-native/Libraries/Linking/Linking', () => ({
openURL: jest.fn(() => Promise.resolve()),
}));

jest.mock('./fetchCarouselSlidesFromContentful', () => ({
...jest.requireActual('./fetchCarouselSlidesFromContentful'),
fetchCarouselSlidesFromContentful: jest.fn(),
Expand Down Expand Up @@ -200,8 +205,10 @@ describe('Carousel Slide Filtering', () => {

describe('Carousel Navigation', () => {
it('opens external URLs when slide is clicked', async () => {
const slideID = 'deeplink-slide';
const slideTestID = `carousel-slide-${slideID}`;
const urlSlide = createMockSlide({
id: 'url-slide',
id: slideID,
navigation: { type: 'url', href: 'https://metamask.io' },
});
mockFetchCarouselSlides.mockResolvedValue({
Expand All @@ -210,14 +217,36 @@ describe('Carousel Navigation', () => {
});

const { findByTestId } = render(<Carousel />);
const slide = await findByTestId('carousel-slide-url-slide');
const slide = await findByTestId(slideTestID);
fireEvent.press(slide);

expect(Linking.openURL).toHaveBeenCalledWith('https://metamask.io');
expect(SharedDeeplinkManager.parse).not.toHaveBeenCalled();
});

it('handles internal deeplinks through SharedDeeplinkManager', async () => {
const slideID = 'deeplink-slide';
const slideTestID = `carousel-slide-${slideID}`;
const deeplinkSlide = createMockSlide({
id: slideID,
navigation: { type: 'url', href: 'https://link.metamask.io/swap' },
});
mockFetchCarouselSlides.mockResolvedValue({
prioritySlides: [],
regularSlides: [deeplinkSlide],
});

const { findByTestId } = render(<Carousel />);
const slide = await findByTestId(slideTestID);
fireEvent.press(slide);

expect(SharedDeeplinkManager.parse).toHaveBeenCalledWith(
'https://metamask.io',
'https://link.metamask.io/swap',
{
origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK,
origin: AppConstants.DEEPLINKS.ORIGIN_CAROUSEL,
},
);
expect(Linking.openURL).not.toHaveBeenCalled();
});

it('navigates to buy flow for fund slides', async () => {
Expand Down
32 changes: 23 additions & 9 deletions app/components/UI/Carousel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import { Dimensions, Animated } from 'react-native';
import { Dimensions, Animated, Linking } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { CarouselProps, CarouselSlide, NavigationAction } from './types';
Expand Down Expand Up @@ -42,8 +42,9 @@ import { selectContentfulCarouselEnabledFlag } from './selectors/featureFlags';
import { createBuyNavigationDetails } from '../Ramp/Aggregator/routes/utils';
import Routes from '../../../constants/navigation/Routes';
import { subscribeToContentPreviewToken } from '../../../actions/notification/helpers';
import AppConstants from '../../../core/AppConstants';
import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkManager';
import { isInternalDeepLink } from '../../../util/deeplinks';
import AppConstants from '../../../core/AppConstants';

const MAX_CAROUSEL_SLIDES = 8;

Expand Down Expand Up @@ -362,13 +363,26 @@ const CarouselComponent: FC<CarouselProps> = ({ style, onEmptyState }) => {

const openUrl =
(href: string): (() => Promise<boolean>) =>
() =>
SharedDeeplinkManager.parse(href, {
origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK,
}).catch((error) => {
console.error('Failed to open URL:', error);
return false;
});
() => {
// Check if this is an internal MetaMask deeplink
if (isInternalDeepLink(href)) {
// Handle internal deeplinks through SharedDeeplinkManager
return SharedDeeplinkManager.parse(href, {
origin: AppConstants.DEEPLINKS.ORIGIN_CAROUSEL,
}).catch((error) => {
console.error('Failed to handle internal deeplink:', error);
return false;
});
}

// For external URLs, use the OS linking system
return Linking.openURL(href)
.then(() => true)
.catch((error) => {
console.error('Failed to open external URL:', error);
return false;
});
};

const handleSlideClick = useCallback(
(slideId: string, navigation: NavigationAction) => {
Expand Down
Loading
Loading