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
8 changes: 3 additions & 5 deletions __mocks__/expo-splash-screen.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export default {
preventAutoHideAsync: jest.fn().mockResolvedValue(undefined),
hide: jest.fn(),
setOptions: jest.fn(),
};
export const preventAutoHideAsync = jest.fn().mockResolvedValue(undefined);
export const hide = jest.fn();
export const setOptions = jest.fn();
3 changes: 1 addition & 2 deletions __mocks__/react-native-mmkv.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
const mmkvMock = (() => {
const data = {};
const MMKV = {
return {
getBoolean: (key: string) => data[key],
getString: (key: string) => data[key],
getNumber: (key: string) => data[key],
set: (key: string, value: boolean | number | string) => (data[key] = value),
getAllKeys: () => Object.keys(data),
delete: (key: string) => (data[key] = undefined),
};
return { MMKV };
})();

export const MMKV = jest.fn().mockImplementation(() => mmkvMock);
42 changes: 42 additions & 0 deletions __mocks__/react-native-purchases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export const PURCHASES_ERROR_CODE = {
UNKNOWN_ERROR: 0,
PURCHASE_CANCELLED_ERROR: 1,
STORE_PROBLEM_ERROR: 2,
PURCHASE_NOT_ALLOWED_ERROR: 3,
PURCHASE_INVALID_ERROR: 4,
PRODUCT_NOT_AVAILABLE_FOR_PURCHASE_ERROR: 5,
PRODUCT_ALREADY_PURCHASED_ERROR: 6,
RECEIPT_ALREADY_IN_USE_ERROR: 7,
INVALID_RECEIPT_ERROR: 8,
MISSING_RECEIPT_FILE_ERROR: 9,
NETWORK_ERROR: 10,
INVALID_CREDENTIALS_ERROR: 11,
UNEXPECTED_BACKEND_RESPONSE_ERROR: 12,
INVALID_APP_USER_ID_ERROR: 14,
OPERATION_ALREADY_IN_PROGRESS_ERROR: 15,
UNKNOWN_BACKEND_ERROR: 16,
};

export const LOG_LEVEL = {
VERBOSE: 'VERBOSE',
DEBUG: 'DEBUG',
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR',
};

const Purchases = {
configureWith: jest.fn(),
getOfferings: jest.fn().mockResolvedValue({ all: {}, current: null }),
getCustomerInfo: jest.fn().mockResolvedValue({}),
purchasePackage: jest.fn().mockResolvedValue({}),
restorePurchases: jest.fn().mockResolvedValue({}),
setLogLevel: jest.fn(),
setDebugLogsEnabled: jest.fn(),
getAppUserID: jest.fn().mockResolvedValue('mock-user-id'),
logIn: jest.fn().mockResolvedValue({}),
logOut: jest.fn().mockResolvedValue({}),
setAttributes: jest.fn().mockResolvedValue(undefined),
};

export default Purchases;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"expo-device": "~8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.9",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
Expand All @@ -98,6 +99,7 @@
"i18next": "25.6.0",
"immer": "10.1.3",
"intl-pluralrules": "2.0.1",
"lodash.debounce": "4.0.8",
"lodash.isequal": "4.5.0",
"lodash.isnil": "4.0.0",
"lodash.memoize": "4.1.2",
Expand Down Expand Up @@ -152,6 +154,7 @@
"@types/imagemin": "8.0.0",
"@types/imagemin-mozjpeg": "8.0.1",
"@types/jest": "29.5.14",
"@types/lodash.debounce": "4.0.9",
"@types/lodash.isequal": "4.5.8",
"@types/lodash.isnil": "4.0.9",
"@types/lodash.memoize": "4.1.9",
Expand Down
61 changes: 61 additions & 0 deletions src/infra/haptics/haptics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as Haptics from 'expo-haptics';

import { IS_ANDROID } from '$domain/constants';

export type HapticFeedbackType = 'light' | 'medium' | 'heavy' | 'selection';
export type HapticNotificationType = 'success' | 'warning' | 'error';

const mapHapticFeedbackTypeToHapticsType: Record<
HapticFeedbackType,
Haptics.ImpactFeedbackStyle
> = {
light: Haptics.ImpactFeedbackStyle.Light,
medium: Haptics.ImpactFeedbackStyle.Medium,
heavy: Haptics.ImpactFeedbackStyle.Heavy,
selection: Haptics.ImpactFeedbackStyle.Light,
};

const mapHapticNotificationTypeToAndroidType: Record<
HapticNotificationType,
Haptics.AndroidHaptics
> = {
success: Haptics.AndroidHaptics.Confirm,
warning: Haptics.AndroidHaptics.Reject,
error: Haptics.AndroidHaptics.Reject,
};
const mapHapticNotificationTypeToHapticsType: Record<
HapticNotificationType,
Haptics.NotificationFeedbackType
> = {
success: Haptics.NotificationFeedbackType.Success,
warning: Haptics.NotificationFeedbackType.Warning,
error: Haptics.NotificationFeedbackType.Error,
};

export const triggerHapticFeedback = (type: HapticFeedbackType = 'medium') => {
if (IS_ANDROID) {
void Haptics.performAndroidHapticsAsync(
Haptics.AndroidHaptics.Context_Click,
);

return;
}

if (type === 'selection') void Haptics.selectionAsync();

void Haptics.impactAsync(mapHapticFeedbackTypeToHapticsType[type]);
};

export const triggerHapticNotification = (
type: HapticNotificationType = 'success',
) => {
if (IS_ANDROID) {
void Haptics.performAndroidHapticsAsync(
mapHapticNotificationTypeToAndroidType[type],
);

return;
}

void Haptics.notificationAsync(mapHapticNotificationTypeToHapticsType[type]);
};
1 change: 1 addition & 0 deletions src/infra/haptics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './haptics';
44 changes: 44 additions & 0 deletions src/shared/components/Pressable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable react/jsx-props-no-spreading */

import React, { useCallback } from 'react';
import {
GestureResponderEvent,
Pressable as RNPressable,
PressableProps,
} from 'react-native';

import { HapticFeedbackType, triggerHapticFeedback } from '$infra/haptics';
import { useDebouncedFunction } from '$shared/hooks';

type CustomPressableProps = PressableProps & {
hapticFeedback?: boolean;
hapticFeedbackType?: HapticFeedbackType;
children: React.ReactNode;
};

export const Pressable = ({
hapticFeedback = false,
hapticFeedbackType = 'light',
children,
onPress,
...props
}: CustomPressableProps) => {
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (hapticFeedback || hapticFeedbackType !== 'light') {
triggerHapticFeedback(hapticFeedbackType);
}

onPress?.(event);
},
[hapticFeedback, hapticFeedbackType, onPress],
);

const debouncedPress = useDebouncedFunction(handlePress);

return (
<RNPressable disabled={!onPress} onPress={debouncedPress} {...props}>
{children}
</RNPressable>
);
};
1 change: 1 addition & 0 deletions src/shared/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './appUpdateNeeded';
export * from './fullscreenErrorBoundary';
export * from './maintenanceMode';
export * from './navigation';
export * from './Pressable';
export * from './screen';
export * from './splashscreen';
export * from './storeUpdateAvailableBanner';
Expand Down
2 changes: 1 addition & 1 deletion src/shared/components/navigation/components/HeaderLeft.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useRouter } from 'expo-router';
import { Pressable } from 'react-native';
import { useUnistyles } from 'react-native-unistyles';

import { DEFAULT_ICON_SIZE, HIT_SLOP } from '$domain/constants/styling';
import { Pressable } from '$shared/components';
import { Icon } from '$shared/icons';

export const HeaderLeft = () => {
Expand Down
98 changes: 98 additions & 0 deletions src/shared/hooks/__tests__/useDebouncedFunction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { renderHook } from '$domain/testing';

import { useDebouncedFunction } from '../useDebouncedFunction';

describe('useDebouncedFunction', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});

it('should return a stable debounced function', () => {
const callback = jest.fn();
const { result, rerender } = renderHook(() =>
useDebouncedFunction(callback, 500, {}),
);

const firstResult = result.current;

rerender({});

expect(result.current).toBe(firstResult);
});

it('should call the latest version of the callback', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();

const { result, rerender } = renderHook(
(props: { cb: () => void }) => useDebouncedFunction(props.cb, 100),
{ initialProps: { cb: callback1 } },
);

result.current();

jest.advanceTimersByTime(100);

expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).not.toHaveBeenCalled();

rerender({ cb: callback2 });

result.current();

jest.advanceTimersByTime(100);

expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
});

it('should debounce function calls', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useDebouncedFunction(callback, 500, { leading: false, trailing: true }),
);

result.current();
result.current();
result.current();

expect(callback).not.toHaveBeenCalled();

jest.advanceTimersByTime(500);

expect(callback).toHaveBeenCalledTimes(1);
});

it('should pass arguments correctly', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebouncedFunction(callback, 100));

result.current('arg1', 'arg2', 123);

jest.advanceTimersByTime(100);

expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 123);
});

it('should support leading edge option', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useDebouncedFunction(callback, 500, { leading: true, trailing: false }),
);

result.current();
result.current();
result.current();

expect(callback).toHaveBeenCalledTimes(1);

jest.advanceTimersByTime(500);

expect(callback).toHaveBeenCalledTimes(1);
});
});
1 change: 1 addition & 0 deletions src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './useAppState';
export * from './useCheckNetworkStateOnMount';
export * from './useDebouncedFunction';
export * from './useGetSessionState';
export * from './useIsNewStoreVersionAvailable';
export * from './useKeyboard';
Expand Down
26 changes: 26 additions & 0 deletions src/shared/hooks/useDebouncedFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import debounce from 'lodash.debounce';
import { useEffect, useMemo, useRef } from 'react';

type DebounceFn = typeof debounce;

export const useDebouncedFunction: DebounceFn = (
fn,
wait = 500,
{ maxWait, leading = true, trailing = false } = {},
) => {
const fnRef = useRef(fn);

useEffect(() => {
fnRef.current = fn;
}, [fn]);

return useMemo(() => {
const wrappedFn = (...args: Parameters<typeof fn>) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return fnRef.current(...args);
};

// eslint-disable-next-line react-hooks/refs
return debounce(wrappedFn, wait, { leading, maxWait, trailing });
}, [leading, maxWait, trailing, wait]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import {
type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import { useMemo } from 'react';
import { Pressable } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
} from 'react-native-reanimated';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';

import { Pressable } from '$shared/components';

export const AnimatedBottomSheetBackdrop = ({
animatedIndex,
style,
Expand Down
3 changes: 2 additions & 1 deletion src/shared/uiKit/button/BaseButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/immutability */

import type React from 'react';
import { GestureResponderEvent, Pressable, View } from 'react-native';
import { GestureResponderEvent, View } from 'react-native';
import Animated, {
cancelAnimation,
useAnimatedStyle,
Expand All @@ -13,6 +13,7 @@ import type { UnistylesThemes } from 'react-native-unistyles';
import { useUnistyles } from 'react-native-unistyles';

import { HIT_SLOP } from '$domain/constants/styling';
import { Pressable } from '$shared/components/Pressable';

import { buttonVariants, type ButtonVariant } from './buttonVariants';
import type { ButtonProps } from './types/buttonTypes';
Expand Down
Loading