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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react-native';

import Text from '../../../../../../../../../component-library/components/Texts/Text';
import Text from '../../../../../../component-library/components/Texts/Text';
import AnimatedPulse from './animated-pulse';

describe('AnimatedPulse', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Animated, View, ViewProps } from 'react-native';

interface AnimatedPulseProps extends ViewProps {
children: React.ReactNode;
isPulsing?: boolean;
minCycles?: number;
preventPulse?: boolean;
}

const DURATION = {
fadeIn: 750,
fadeOut: 750,
fadeInFinal: 750,
} as const;

const AnimatedPulse = ({
children,
isPulsing = true,
minCycles = 2, // Default to 2 cycles minimum for a better visual effect
preventPulse = false,
...props
}: AnimatedPulseProps) => {
const opacity = useRef(new Animated.Value(0.3)).current;
const currentAnimationRef = useRef<Animated.CompositeAnimation | null>(null);
const isCurrentlyPulsingRef = useRef(isPulsing);
const cyclesNeededRef = useRef(isPulsing ? minCycles : 0);
const cyclesCompletedRef = useRef(0);

const cleanup = useCallback(() => {
if (currentAnimationRef.current) {
currentAnimationRef.current.stop();
currentAnimationRef.current = null;
}
}, []);

useEffect(() => {
if (isCurrentlyPulsingRef.current && !isPulsing) {
// Set the number of cycles we need to complete
cyclesNeededRef.current = Math.max(
minCycles,
cyclesCompletedRef.current + 1,
);
} else if (!isCurrentlyPulsingRef.current && isPulsing) {
// Reset cycle count when starting to pulse again
cyclesCompletedRef.current = 0;
}
isCurrentlyPulsingRef.current = isPulsing;
}, [isPulsing, minCycles]);

// Start a single pulse cycle and decide what to do next
const runSinglePulseCycle = useCallback(() => {
// Create and store the new animation
const sequence = Animated.sequence([
Animated.timing(opacity, {
toValue: 1,
duration: DURATION.fadeIn,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0.3,
duration: DURATION.fadeOut,
useNativeDriver: true,
}),
]);

currentAnimationRef.current = sequence;

// Start the animation
sequence.start(({ finished }) => {
if (finished) {
// Increment completed cycles
cyclesCompletedRef.current += 1;

// Continue pulsing if:
// 1. isPulsing is true, OR
// 2. We haven't completed the minimum required cycles
if (
isCurrentlyPulsingRef.current ||
cyclesCompletedRef.current < cyclesNeededRef.current
) {
// Schedule the next cycle
runSinglePulseCycle();
} else {
// We're done pulsing and have completed required cycles
// Fade to full opacity
Animated.timing(opacity, {
toValue: 1,
duration: DURATION.fadeInFinal,
useNativeDriver: true,
}).start();

// Clear animation ref
currentAnimationRef.current = null;
}
}
});
}, [opacity]);

// Handle animation lifecycle
useEffect(() => {
// Only start animation if:
// 1. We should be pulsing, OR
// 2. We just stopped pulsing but need to complete cycles
const shouldAnimate =
isPulsing ||
(!isPulsing && cyclesCompletedRef.current < cyclesNeededRef.current);

if (shouldAnimate && !currentAnimationRef.current) {
runSinglePulseCycle();
} else if (!shouldAnimate && !currentAnimationRef.current) {
// No animation needed, just ensure full opacity
Animated.timing(opacity, {
toValue: 1,
duration: DURATION.fadeInFinal,
useNativeDriver: true,
}).start();
}

// Cleanup
return cleanup;
}, [cleanup, isPulsing, opacity, runSinglePulseCycle]);

if (preventPulse) {
return <View>{children}</View>;
}

return (
<Animated.View style={{ opacity }} {...props}>
{children}
</Animated.View>
);
};

export default AnimatedPulse;
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { UnstakeConfirmationViewProps } from '../../../../UI/Stake/Views/Unstake
import useConfirmationAlerts from '../../hooks/alerts/useConfirmationAlerts';
import useApprovalRequest from '../../hooks/useApprovalRequest';
import { AlertsContextProvider } from '../../context/alert-system-context';
import { ConfirmationContextProvider } from '../../context/confirmation-context';
import { LedgerContextProvider } from '../../context/ledger-context';
import { QRHardwareContextProvider } from '../../context/qr-hardware-context';
import { useConfirmActions } from '../../hooks/useConfirmActions';
Expand All @@ -29,22 +30,24 @@ const ConfirmWrapped = ({
const alerts = useConfirmationAlerts();

return (
<AlertsContextProvider alerts={alerts}>
<QRHardwareContextProvider>
<LedgerContextProvider>
<Title />
<ScrollView style={styles.scrollView} nestedScrollEnabled>
<TouchableWithoutFeedback>
<>
<GeneralAlertBanner />
<Info route={route} />
</>
</TouchableWithoutFeedback>
</ScrollView>
<Footer />
</LedgerContextProvider>
</QRHardwareContextProvider>
</AlertsContextProvider>
<ConfirmationContextProvider>
<AlertsContextProvider alerts={alerts}>
<QRHardwareContextProvider>
<LedgerContextProvider>
<Title />
<ScrollView style={styles.scrollView} nestedScrollEnabled>
<TouchableWithoutFeedback>
<>
<GeneralAlertBanner />
<Info route={route} />
</>
</TouchableWithoutFeedback>
</ScrollView>
<Footer />
</LedgerContextProvider>
</QRHardwareContextProvider>
</AlertsContextProvider>
</ConfirmationContextProvider>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import * as QRHardwareHook from '../../context/qr-hardware-context/qr-hardware-context';
import { Footer } from './footer';
import { useAlerts } from '../../context/alert-system-context';
import { useConfirmationContext } from '../../context/confirmation-context';
import { useAlertsConfirmed } from '../../../../hooks/useAlertsConfirmed';
import { Severity } from '../../types/alerts';
import { useConfirmationAlertMetrics } from '../../hooks/metrics/useConfirmationAlertMetrics';
Expand All @@ -37,6 +38,10 @@ jest.mock('../../context/alert-system-context', () => ({
useAlerts: jest.fn(),
}));

jest.mock('../../context/confirmation-context', () => ({
useConfirmationContext: jest.fn(),
}));

jest.mock('../../../../hooks/useAlertsConfirmed', () => ({
useAlertsConfirmed: jest.fn(),
}));
Expand Down Expand Up @@ -72,15 +77,20 @@ const mockAlerts = [
];

describe('Footer', () => {
const mockUseConfirmationContext = jest.mocked(useConfirmationContext);
beforeEach(() => {
jest.clearAllMocks();
mockUseConfirmationContext.mockReturnValue({
isTransactionValueUpdating: false,
setIsTransactionValueUpdating: jest.fn(),
});
(useAlerts as jest.Mock).mockReturnValue({
fieldAlerts: [],
hasDangerAlerts: false,
});
(useAlertsConfirmed as jest.Mock).mockReturnValue({
hasUnconfirmedDangerAlerts: false,
});
jest.clearAllMocks();
});

it('should render correctly', () => {
Expand Down Expand Up @@ -168,6 +178,19 @@ describe('Footer', () => {
).toBe(true);
});

it('disables confirm button if there is a blocker alert', () => {
mockUseConfirmationContext.mockReturnValue({
isTransactionValueUpdating: true,
setIsTransactionValueUpdating: jest.fn(),
});
const { getByTestId } = renderWithProvider(<Footer />, {
state: personalSignatureConfirmationState,
});
expect(
getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON).props.disabled,
).toBe(true);
});

describe('Confirm Alert Modal', () => {
const baseMockUseAlerts = {
alertKey: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ConfirmAlertModal from '../../components/modals/confirm-alert-modal';
import { useConfirmActions } from '../../hooks/useConfirmActions';
import { useConfirmationAlertMetrics } from '../../hooks/metrics/useConfirmationAlertMetrics';
import { useStandaloneConfirmation } from '../../hooks/ui/useStandaloneConfirmation';
import { useConfirmationContext } from '../../context/confirmation-context';
import { useQRHardwareContext } from '../../context/qr-hardware-context/qr-hardware-context';
import { useSecurityAlertResponse } from '../../hooks/alerts/useSecurityAlertResponse';
import { useTransactionMetadataRequest } from '../../hooks/transactions/useTransactionMetadataRequest';
Expand All @@ -45,6 +46,7 @@ export const Footer = () => {
const isStakingConfirmationBool = isStakingConfirmation(
transactionMetadata?.type as string,
);
const { isTransactionValueUpdating } = useConfirmationContext();

const [confirmAlertModalVisible, setConfirmAlertModalVisible] =
useState(false);
Expand Down Expand Up @@ -121,7 +123,10 @@ export const Footer = () => {
isDanger:
securityAlertResponse?.result_type === ResultType.Malicious ||
hasDangerAlerts,
isDisabled: needsCameraPermission || hasBlockingAlerts,
isDisabled:
needsCameraPermission ||
hasBlockingAlerts ||
isTransactionValueUpdating,
label: confirmButtonLabel(),
size: ButtonSize.Lg,
onPress: onSignConfirm,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useClearConfirmationOnBackSwipe from '../../../hooks/ui/useClearConfirmat
import { useConfirmationMetricEvents } from '../../../hooks/metrics/useConfirmationMetricEvents';
import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
import useNavbar from '../../../hooks/ui/useNavbar';
import { useMaxValueRefresher } from '../../../hooks/useMaxValueRefresher';
import FromTo from '../../rows/transactions/from-to';
import GasFeesDetails from '../../rows/transactions/gas-fee-details';
import AdvancedDetailsRow from '../../rows/transactions/advanced-details-row/advanced-details-row';
Expand All @@ -22,7 +23,7 @@ const Transfer = () => {

useClearConfirmationOnBackSwipe();
useNavbar(strings('confirm.review'));

useMaxValueRefresher();
useEffect(trackPageViewedEvent, [trackPageViewedEvent]);

return (
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import BottomModal from '../../../../../UI/bottom-modal';

import styleSheet from './value-display.styles';
import { strings } from '../../../../../../../../../../locales/i18n';
import AnimatedPulse from '../animated-pulse/animated-pulse';
import AnimatedPulse from '../../../../../UI/animated-pulse';
import { selectContractExchangeRatesByChainId } from '../../../../../../../../../selectors/tokenRatesController';
import { RootState } from '../../../../../../../../../reducers';

Expand Down Expand Up @@ -188,6 +188,7 @@ const SimulationValueDisplay: React.FC<SimulationValueDisplayParams> = ({
{showValueButtonPill && (
<AnimatedPulse
isPulsing={isPendingTokenDetails}
minCycles={0}
testID="simulation-value-display-loader"
>
<ButtonPill
Expand Down
Loading
Loading