diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index 7f3789d79..d53dd4b9e 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -283,6 +283,7 @@ const translations = {
copyError: 'Could not copy item',
saveError: 'Could not save to gallery',
saveErrorNoPermission: 'Gallery permission denied',
+ saveErrorNoPermissionAction: 'Settings',
trashError: 'Could not move to trash',
restoreError: 'Could not start upload',
},
@@ -1283,6 +1284,7 @@ const translations = {
copyError: 'No se pudo copiar el elemento',
saveError: 'No se pudo guardar en la galería',
saveErrorNoPermission: 'Permiso de galería denegado',
+ saveErrorNoPermissionAction: 'Ajustes',
trashError: 'No se pudo mover a la papelera',
restoreError: 'No se pudo iniciar la subida',
},
diff --git a/src/screens/PhotoPreviewScreen/components/PreviewPage.tsx b/src/screens/PhotoPreviewScreen/components/PreviewPage.tsx
index 8b052988e..f1712e49b 100644
--- a/src/screens/PhotoPreviewScreen/components/PreviewPage.tsx
+++ b/src/screens/PhotoPreviewScreen/components/PreviewPage.tsx
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Image, View, useWindowDimensions } from 'react-native';
-import { Gesture, GestureDetector } from 'react-native-gesture-handler';
-import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { useTailwind } from 'tailwind-rn';
import { VideoViewer } from '../../../components/photos/VideoViewer/VideoViewer';
import { ImageViewer } from '../../../components/ui-kit/view/ImageViewer/ImageViewer';
@@ -83,12 +81,10 @@ interface PreviewPageProps {
isScrubbing: boolean;
onTap: () => void;
onZoomChange: (zoomed: boolean) => void;
- onSwipeDown: () => void;
onVideoPlay?: () => void;
onVideoPause?: () => void;
onVideoEnd?: () => void;
videoResetKey?: number;
- hasVideoStarted?: boolean;
}
export const PreviewPage = ({
@@ -97,18 +93,14 @@ export const PreviewPage = ({
isScrubbing,
onTap,
onZoomChange,
- onSwipeDown,
onVideoPlay,
onVideoPause,
onVideoEnd,
videoResetKey,
- hasVideoStarted,
}: PreviewPageProps): JSX.Element => {
const tailwind = useTailwind();
const { width: screenWidth } = useWindowDimensions();
const { uri, thumbnailUri, isLoading } = usePreviewSource(item, isScrubbing);
- const [zoomed, setZoomed] = useState(false);
- const translateY = useSharedValue(0);
const [isVideoActive, setIsVideoActive] = useState(false);
useEffect(() => {
@@ -121,12 +113,10 @@ export const PreviewPage = ({
}, [isActive]);
const handleZoom = useCallback(() => {
- setZoomed(true);
onZoomChange(true);
}, [onZoomChange]);
const handleReset = useCallback(() => {
- setZoomed(false);
onZoomChange(false);
}, [onZoomChange]);
@@ -142,45 +132,22 @@ export const PreviewPage = ({
onVideoEnd?.();
}, [onVideoEnd]);
- const swipeDownGesture = Gesture.Pan()
- .enabled(!zoomed && !hasVideoStarted)
- .activeOffsetY(10)
- .failOffsetX([-15, 15])
- .onUpdate((e) => {
- if (e.translationY > 0) {
- translateY.value = e.translationY;
- }
- })
- .onEnd((e) => {
- if (e.translationY > 100) {
- runOnJS(onSwipeDown)();
- } else {
- translateY.value = withTiming(0, { duration: 150 });
- }
- });
-
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ translateY: translateY.value }],
- }));
-
return (
-
-
-
-
-
+
+
+
);
};
diff --git a/src/screens/PhotoPreviewScreen/components/PreviewPager.tsx b/src/screens/PhotoPreviewScreen/components/PreviewPager.tsx
index e3722b823..dad98bb49 100644
--- a/src/screens/PhotoPreviewScreen/components/PreviewPager.tsx
+++ b/src/screens/PhotoPreviewScreen/components/PreviewPager.tsx
@@ -11,12 +11,10 @@ interface PreviewPagerProps {
onIndexChange: (index: number) => void;
onTap: () => void;
onZoomChange: (zoomed: boolean) => void;
- onSwipeDown: () => void;
onVideoPlay?: () => void;
onVideoPause?: () => void;
onVideoEnd?: () => void;
videoResetKey?: number;
- hasVideoStarted?: boolean;
}
export const PreviewPager = ({
@@ -27,12 +25,10 @@ export const PreviewPager = ({
onIndexChange,
onTap,
onZoomChange,
- onSwipeDown,
onVideoPlay,
onVideoPause,
onVideoEnd,
videoResetKey,
- hasVideoStarted,
}: PreviewPagerProps): JSX.Element => {
const { width: screenWidth } = useWindowDimensions();
const listRef = useRef>(null);
@@ -58,26 +54,13 @@ export const PreviewPager = ({
isScrubbing={isScrubbing}
onTap={onTap}
onZoomChange={onZoomChange}
- onSwipeDown={onSwipeDown}
onVideoPlay={onVideoPlay}
onVideoPause={onVideoPause}
onVideoEnd={onVideoEnd}
videoResetKey={videoResetKey}
- hasVideoStarted={hasVideoStarted}
/>
),
- [
- activeIndex,
- isScrubbing,
- onTap,
- onZoomChange,
- onSwipeDown,
- onVideoPlay,
- onVideoPause,
- onVideoEnd,
- videoResetKey,
- hasVideoStarted,
- ],
+ [activeIndex, isScrubbing, onTap, onZoomChange, onVideoPlay, onVideoPause, onVideoEnd, videoResetKey],
);
const getItemLayout = useCallback(
diff --git a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts
index 7cf113bed..cecb1348c 100644
--- a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts
+++ b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts
@@ -82,6 +82,23 @@ describe('usePreviewSource — uri resolution', () => {
expect(mockFetchPlaybackUri).not.toHaveBeenCalled();
});
+
+ test('when scrubbing ends on the same item that was already loaded, then the uri is not refetched or cleared', async () => {
+ const item = makeCloudItem();
+
+ const { result, rerender } = renderHook(({ isScrubbing }) => usePreviewSource(item, isScrubbing), {
+ initialProps: { isScrubbing: false },
+ });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(mockFetchPlaybackUri).toHaveBeenCalledTimes(1);
+
+ rerender({ isScrubbing: true });
+ rerender({ isScrubbing: false });
+
+ expect(mockFetchPlaybackUri).toHaveBeenCalledTimes(1);
+ expect(result.current.uri).toBe('file:///dcim/photo.jpg');
+ });
});
describe('usePreviewSource — thumbnailUri', () => {
diff --git a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts
index 589a425ee..9d308897f 100644
--- a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts
+++ b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts
@@ -14,9 +14,11 @@ export const usePreviewSource = (item: TimelinePhotoItem, isScrubbing: boolean):
const [uri, setUri] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const abortRef = useRef(null);
+ const loadedItemIdRef = useRef(null);
useEffect(() => {
- if (isScrubbing) {
+ const isAlreadyLoadedItem = loadedItemIdRef.current === item.id;
+ if (isScrubbing || isAlreadyLoadedItem) {
return;
}
@@ -30,6 +32,7 @@ export const usePreviewSource = (item: TimelinePhotoItem, isScrubbing: boolean):
PhotoAssetFetchService.fetchPlaybackUri(item, controller.signal).then((fullUri) => {
if (!controller.signal.aborted) {
+ loadedItemIdRef.current = item.id;
logger.info(`[usePreviewSource] asset ready — id: ${item.id}, uri: ${fullUri ?? 'null'}`);
setUri(fullUri ?? null);
setIsLoading(false);
diff --git a/src/screens/PhotoPreviewScreen/index.tsx b/src/screens/PhotoPreviewScreen/index.tsx
index 41b7408be..409070240 100644
--- a/src/screens/PhotoPreviewScreen/index.tsx
+++ b/src/screens/PhotoPreviewScreen/index.tsx
@@ -2,6 +2,8 @@ import { useNavigation } from '@react-navigation/native';
import strings from 'assets/lang/strings';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { StatusBar, View } from 'react-native';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { ConfirmModal } from 'src/components/modals/ConfirmModal/ConfirmModal';
import { logger } from 'src/services/common';
import { useAppDispatch } from 'src/store/hooks';
@@ -88,6 +90,29 @@ export const PhotoPreviewScreen = ({ route }: Props): JSX.Element => {
const handleScrubStart = useCallback(() => setIsScrubbing(true), []);
const handleScrubEnd = useCallback(() => setIsScrubbing(false), []);
+ const translateY = useSharedValue(0);
+
+ const swipeDownGesture = Gesture.Pan()
+ .enabled(!zoomActive && !hasVideoStarted && !isScrubbing)
+ .activeOffsetY(10)
+ .failOffsetX([-15, 15])
+ .onUpdate((e) => {
+ if (e.translationY > 0) {
+ translateY.value = e.translationY;
+ }
+ })
+ .onEnd((e) => {
+ if (e.translationY > 100) {
+ runOnJS(handleSwipeDown)();
+ } else {
+ translateY.value = withTiming(0, { duration: 150 });
+ }
+ });
+
+ const swipeDownAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }));
+
const currentItem = items[currentIndex];
const actionItems = useMemo(() => (currentItem ? [currentItem] : []), [currentItem]);
@@ -126,43 +151,45 @@ export const PhotoPreviewScreen = ({ route }: Props): JSX.Element => {
return (
-
- setIsMoreActionsOpen(true)}
- />
- {showCarousel && (
- setIsMoreActionsOpen(true)}
- onDelete={handleDeletePress}
- isSynced={isSynced}
- />
- )}
-
+
+
+
+ setIsMoreActionsOpen(true)}
+ />
+ {showCarousel && (
+ setIsMoreActionsOpen(true)}
+ onDelete={handleDeletePress}
+ isSynced={isSynced}
+ />
+ )}
+
+
+
{metadataOpen && currentItem && setMetadataOpen(false)} />}
{
export const GroupHeaderFetching = ({ isSticky }: StickyProps): JSX.Element => {
const tailwind = useTailwind();
- const { statusColor } = useGroupHeaderColors(isSticky);
+ const { statusColor, primaryColor } = useGroupHeaderColors(isSticky);
+ const pulseAnim = useRef(new Animated.Value(1)).current;
+
+ useEffect(() => {
+ const anim = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, { toValue: 0.3, duration: 1200, easing: Easing.linear, useNativeDriver: true }),
+ Animated.timing(pulseAnim, { toValue: 1, duration: 1200, easing: Easing.linear, useNativeDriver: true }),
+ ]),
+ );
+ anim.start();
+ return () => anim.stop();
+ }, []);
+
return (
<>
-
+
+
+
{strings.screens.photos.groupHeader.gettingPhotos}
diff --git a/src/screens/PhotosScreen/components/PhotosTimeline.tsx b/src/screens/PhotosScreen/components/PhotosTimeline.tsx
index 3afe32ae3..a52038c8a 100644
--- a/src/screens/PhotosScreen/components/PhotosTimeline.tsx
+++ b/src/screens/PhotosScreen/components/PhotosTimeline.tsx
@@ -1,6 +1,6 @@
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
-import { Animated, StyleSheet, View } from 'react-native';
+import { Animated, Platform, StyleSheet, View } from 'react-native';
import { GestureDetector } from 'react-native-gesture-handler';
import { useTailwind } from 'tailwind-rn';
import { useDragSelectGesture } from '../hooks/useDragSelectGesture';
@@ -32,7 +32,11 @@ const SKELETON_GROUP: TimelineDateGroup = {
};
const NUM_COLUMNS = 3;
-const HEADER_HEIGHT = 64; // h-16 — matches contentContainerStyle.paddingTop
+const HEADER_HEIGHT = 64;
+const HEADER_FADE_SCROLL_DISTANCE = 24;
+const FLOATING_HEADER_MIN_OPACITY = 0.02;
+const PULL_TO_REFRESH_FADE_START = -10;
+const PULL_TO_REFRESH_FADE_END = -60;
interface PhotosTimelineProps {
assetsGroupsByDate: TimelineDateGroup[];
@@ -91,10 +95,19 @@ const PhotosTimeline = forwardRef(
boundariesRef.current = boundaries;
const scrollY = useRef(new Animated.Value(0)).current;
- // UIKit drops touches below alpha 0.01, so use 0.02 as the floor so pause/resume buttons
- // are always touchable even when the floating layer is nearly invisible at scroll=0.
- const floatingOpacity = scrollY.interpolate({ inputRange: [0, 24], outputRange: [0.02, 1], extrapolate: 'clamp' });
- const solidOpacity = scrollY.interpolate({ inputRange: [0, 24], outputRange: [1, 0], extrapolate: 'clamp' });
+ // UIKit drops touches below alpha 0.01, so use FLOATING_HEADER_MIN_OPACITY as the floor so
+ // pause/resume buttons are always touchable even when the floating layer is nearly invisible
+ // at scroll=0.
+ const floatingOpacity = scrollY.interpolate({
+ inputRange: [0, HEADER_FADE_SCROLL_DISTANCE],
+ outputRange: [FLOATING_HEADER_MIN_OPACITY, 1],
+ extrapolate: 'clamp',
+ });
+ const solidOpacity = scrollY.interpolate({
+ inputRange: [PULL_TO_REFRESH_FADE_END, PULL_TO_REFRESH_FADE_START, 0, HEADER_FADE_SCROLL_DISTANCE],
+ outputRange: [0, 1, 1, 0],
+ extrapolate: 'clamp',
+ });
const { gesture, onContainerLayout, onScroll } = useDragSelectGesture({
isSelectMode: !!isSelectMode,
@@ -192,11 +205,15 @@ const PhotosTimeline = forwardRef(
viewabilityConfig={{ itemVisiblePercentThreshold: 10 }}
onScroll={onScroll}
scrollEventThrottle={16}
+ progressViewOffset={HEADER_HEIGHT}
/>
{!isEmpty && currentBoundary && (
<>
-
+
diff --git a/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.spec.ts b/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.spec.ts
index f8596e7ba..2c9563225 100644
--- a/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.spec.ts
+++ b/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.spec.ts
@@ -1,4 +1,5 @@
import { act, renderHook } from '@testing-library/react-native';
+import { Linking } from 'react-native';
import { notifications } from 'src/services/NotificationsService';
import { SavePermissionDeniedError } from 'src/services/photos/errors';
import { photoActionsService } from 'src/services/photos/PhotoActionsService';
@@ -16,7 +17,7 @@ jest.mock('src/services/photos/PhotoActionsService', () => ({
}));
jest.mock('src/services/NotificationsService', () => ({
- notifications: { success: jest.fn(), error: jest.fn(), info: jest.fn() },
+ notifications: { success: jest.fn(), error: jest.fn(), info: jest.fn(), show: jest.fn() },
}));
jest.mock('src/services/common', () => ({
@@ -97,14 +98,20 @@ describe('handleSave', () => {
expect(mockNotifications.success).toHaveBeenCalledTimes(2);
});
- test('when save throws SavePermissionDeniedError, then the permission-denied toast is shown', async () => {
+ test('when save throws SavePermissionDeniedError, then a toast with a Settings action is shown', async () => {
+ const openSettingsSpy = jest.spyOn(Linking, 'openSettings').mockResolvedValue();
mockService.saveToDevice.mockRejectedValueOnce(new SavePermissionDeniedError());
const { result } = renderHook(() => usePhotoActionHandlers({ items: [makeLocalBacked()] }));
await act(() => result.current.handleSave());
- expect(mockNotifications.error).toHaveBeenCalledTimes(1);
- expect(mockNotifications.error).toHaveBeenCalledWith(expect.stringContaining('permission'));
+ expect(mockNotifications.error).not.toHaveBeenCalled();
+ expect(mockNotifications.show).toHaveBeenCalledTimes(1);
+ const call = mockNotifications.show.mock.calls[0][0];
+ expect(call.action?.text).toBeTruthy();
+
+ call.action?.onActionPress();
+ expect(openSettingsSpy).toHaveBeenCalledTimes(1);
});
test('when save throws a generic error, then the generic save-error toast is shown', async () => {
diff --git a/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.ts b/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.ts
index b080571a5..b7e8ba899 100644
--- a/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.ts
+++ b/src/screens/PhotosScreen/hooks/usePhotoActionHandlers.ts
@@ -1,9 +1,11 @@
import strings from 'assets/lang/strings';
import { useCallback, useEffect, useRef } from 'react';
+import { Linking } from 'react-native';
import { logger } from 'src/services/common';
import { notifications } from 'src/services/NotificationsService';
import { SavePermissionDeniedError } from 'src/services/photos/errors';
import { photoActionsService } from 'src/services/photos/PhotoActionsService';
+import { NotificationType } from 'src/types';
import { TimelinePhotoItem } from '../types';
import { getSavedNotificationMessage, getTrashNotificationMessage } from '../utils/photoUtils';
@@ -69,14 +71,26 @@ export const usePhotoActionHandlers = ({
try {
for (const item of items) {
await photoActionsService.saveToDevice(item, signal);
- if (signal.aborted) break;
+ if (signal.aborted) {
+ break;
+ }
notifications.success(getSavedNotificationMessage(item));
await onAfterSave?.();
}
} catch (error) {
logger.error(`[usePhotoActionHandlers] Save error: ${error}`);
if (error instanceof SavePermissionDeniedError) {
- notifications.error(strings.screens.photos.notifications.saveErrorNoPermission);
+ notifications.show({
+ text1: strings.screens.photos.notifications.saveErrorNoPermission,
+ type: NotificationType.Error,
+ autoHide: false,
+ action: {
+ text: strings.screens.photos.notifications.saveErrorNoPermissionAction,
+ onActionPress: () => {
+ Linking.openSettings();
+ },
+ },
+ });
} else {
notifications.error(strings.screens.photos.notifications.saveError);
}