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 (