From ef2573c5eefa0f1dd4739d974ada0a4576616d36 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Fri, 8 May 2026 13:20:09 +0200 Subject: [PATCH 1/2] Added pull to refresh feature --- .../PhotosScreen/components/PhotoItem.tsx | 5 +- .../components/PhotosTimeline.tsx | 6 ++ .../PhotosScreen/hooks/useLocalAssets.spec.ts | 21 ++++ .../PhotosScreen/hooks/useLocalAssets.ts | 5 +- .../PhotosScreen/hooks/usePhotosTimeline.ts | 5 +- src/screens/PhotosScreen/index.tsx | 16 ++- src/services/photos/PhotoCloudBrowser.spec.ts | 21 ++++ src/services/photos/PhotoCloudBrowser.ts | 38 ++++++-- src/store/slices/photos/index.spec.ts | 97 +++++++++++++++++++ src/store/slices/photos/index.ts | 31 +++++- 10 files changed, 230 insertions(+), 15 deletions(-) diff --git a/src/screens/PhotosScreen/components/PhotoItem.tsx b/src/screens/PhotosScreen/components/PhotoItem.tsx index ef5b4a6ec..b19e6de4f 100644 --- a/src/screens/PhotosScreen/components/PhotoItem.tsx +++ b/src/screens/PhotosScreen/components/PhotoItem.tsx @@ -96,7 +96,10 @@ const LocalPhotoCell = memo( const tailwind = useTailwind(); const getColor = useGetColor(); - const handlePress = useCallback(() => onPress?.(item.id), [onPress, item.id]); + const handlePress = useCallback(() => { + console.log('[LocalPhotoCell] press', JSON.stringify(item, null, 2)); + onPress?.(item.id); + }, [onPress, item]); const handleLongPress = useCallback(() => onLongPress?.(item.id), [onLongPress, item.id]); if (item.backupState === 'loading' || !item.uri) { diff --git a/src/screens/PhotosScreen/components/PhotosTimeline.tsx b/src/screens/PhotosScreen/components/PhotosTimeline.tsx index 057d1ff8d..ce07d9c41 100644 --- a/src/screens/PhotosScreen/components/PhotosTimeline.tsx +++ b/src/screens/PhotosScreen/components/PhotosTimeline.tsx @@ -40,6 +40,8 @@ interface PhotosTimelineProps { selectedIds?: Set; ListHeaderComponent?: React.ReactElement; onEndReached?: () => void; + refreshing?: boolean; + onRefresh?: () => void; } const getItemType = (item: FlatItem) => item.type; @@ -61,6 +63,8 @@ const PhotosTimeline = ({ selectedIds, ListHeaderComponent, onEndReached, + refreshing, + onRefresh, }: PhotosTimelineProps) => { const tailwind = useTailwind(); const { items, headerIndices } = useMemo(() => { @@ -124,6 +128,8 @@ const PhotosTimeline = ({ showsVerticalScrollIndicator={false} onEndReached={onEndReached} onEndReachedThreshold={0.5} + refreshing={refreshing} + onRefresh={onRefresh} onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: false })} scrollEventThrottle={16} /> diff --git a/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts b/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts index 918d890a0..9169f1e2b 100644 --- a/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts +++ b/src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts @@ -161,6 +161,27 @@ describe('useLocalAssets', () => { expect(mockMediaLibrary.getAssetsAsync).toHaveBeenCalledTimes(3); }); + test('when reload is called, then the gallery re-paginates from the start with fresh results', async () => { + const firstAssets = [makeAsset('a1'), makeAsset('a2')]; + const reloadAssets = [makeAsset('a3')]; + mockMediaLibrary.getAssetsAsync + .mockResolvedValueOnce(makePage(firstAssets)) + .mockResolvedValueOnce(makePage(reloadAssets)); + + const { result } = renderHook(() => useLocalAssets()); + + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.assets).toEqual(reloadAssets); + expect(mockMediaLibrary.getAssetsAsync).toHaveBeenCalledTimes(2); + }); + test('when sync status changes and there are assets loaded, then synced ids refresh from the database', async () => { mockMediaLibrary.getAssetsAsync.mockResolvedValueOnce(makePage([makeAsset('a1')])); mockPhotosLocalDB.getSyncedEntries.mockResolvedValue(new Map([['a1', { modificationTime: null }]])); diff --git a/src/screens/PhotosScreen/hooks/useLocalAssets.ts b/src/screens/PhotosScreen/hooks/useLocalAssets.ts index 9937a14c9..e562a7c22 100644 --- a/src/screens/PhotosScreen/hooks/useLocalAssets.ts +++ b/src/screens/PhotosScreen/hooks/useLocalAssets.ts @@ -1,6 +1,7 @@ import * as MediaLibrary from 'expo-media-library'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppState } from 'react-native'; +import { logger } from 'src/services/common'; import { photosLocalDB } from 'src/services/photos/database/photosLocalDB'; import { useAppSelector } from 'src/store/hooks'; @@ -13,6 +14,7 @@ export interface LocalAssetsResult { syncedIds: Set; uploadingIdSet: Set; loadNextPage: () => void; + reload: () => Promise; } export const useLocalAssets = (): LocalAssetsResult => { @@ -94,6 +96,7 @@ export const useLocalAssets = (): LocalAssetsResult => { hasMoreRef.current = true; const page = await fetchLocalPage(); applyPage(page, { replace: true }); + logger.info(`[LocalAssets] Reloaded from start — ${page.assets.length} assets (hasNextPage: ${page.hasNextPage})`); }, [fetchLocalPage, applyPage]); useEffect(() => { @@ -128,5 +131,5 @@ export const useLocalAssets = (): LocalAssetsResult => { refreshSyncStatusFromDB(); }, [refreshSyncStatusFromDB, syncStatus, sessionUploadedAssets]); - return { assets, isLoading, syncedIds, uploadingIdSet, loadNextPage }; + return { assets, isLoading, syncedIds, uploadingIdSet, loadNextPage, reload: reloadFromStart }; }; diff --git a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts index 8ccc0e0b7..c2b605ef3 100644 --- a/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts +++ b/src/screens/PhotosScreen/hooks/usePhotosTimeline.ts @@ -9,10 +9,11 @@ export interface PhotosTimelineResult { timelineDateGroups: TimelineDateGroup[]; isLoading: boolean; loadNextPage: () => void; + reloadLocal: () => Promise; } export const usePhotosTimeline = (): PhotosTimelineResult => { - const { assets, isLoading, syncedIds, uploadingIdSet, loadNextPage } = useLocalAssets(); + const { assets, isLoading, syncedIds, uploadingIdSet, loadNextPage, reload: reloadLocal } = useLocalAssets(); const { cloudItems } = useCloudAssets(); const { syncStatus, sessionTotalAssets, sessionUploadedAssets, isFetchingCloudHistory } = useAppSelector( @@ -35,5 +36,5 @@ export const usePhotosTimeline = (): PhotosTimelineResult => { })) as TimelineDateGroup[]; }, [mergedGroups, syncStatus, sessionTotalAssets, sessionUploadedAssets, isFetchingCloudHistory]); - return { timelineDateGroups, isLoading, loadNextPage }; + return { timelineDateGroups, isLoading, loadNextPage, reloadLocal }; }; diff --git a/src/screens/PhotosScreen/index.tsx b/src/screens/PhotosScreen/index.tsx index 6a87c48f9..117717dbe 100644 --- a/src/screens/PhotosScreen/index.tsx +++ b/src/screens/PhotosScreen/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { View } from 'react-native'; import AppScreen from 'src/components/AppScreen'; import { useAppDispatch, useAppSelector } from 'src/store/hooks'; -import { photosActions, runBackupCycleThunk } from 'src/store/slices/photos'; +import { forceRefreshThunk, photosActions, runBackupCycleThunk } from 'src/store/slices/photos'; import { useTailwind } from 'tailwind-rn'; import { photoPermissionService } from '../../services/photos/photoPermissionService'; import BackupDisabledBanner from './components/BackupDisabledBanner'; @@ -19,7 +19,8 @@ const PhotosScreen = (): JSX.Element => { const dispatch = useAppDispatch(); const { enabled, permissionStatus } = useAppSelector((state) => state.photos); const [isSheetOpen, setIsSheetOpen] = useState(false); - const { timelineDateGroups, isLoading, loadNextPage } = usePhotosTimeline(); + const [refreshing, setRefreshing] = useState(false); + const { timelineDateGroups, isLoading, loadNextPage, reloadLocal } = usePhotosTimeline(); const accessState: PhotosAccessState = useMemo( () => (enabled ? { type: 'available' } : { type: 'backup-off' }), @@ -29,6 +30,15 @@ const PhotosScreen = (): JSX.Element => { const listHeader = accessState.type === 'backup-off' ? : undefined; + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + await Promise.all([reloadLocal(), dispatch(forceRefreshThunk()).unwrap()]); + } finally { + setRefreshing(false); + } + }, [dispatch, reloadLocal]); + const handleSelectPress = useCallback(() => undefined, []); const handleUpgradePress = useCallback(() => undefined, []); @@ -60,6 +70,8 @@ const PhotosScreen = (): JSX.Element => { isLoading={isLoading} ListHeaderComponent={listHeader} onEndReached={loadNextPage} + refreshing={refreshing} + onRefresh={handleRefresh} /> {accessState.type === 'photos-locked' && } diff --git a/src/services/photos/PhotoCloudBrowser.spec.ts b/src/services/photos/PhotoCloudBrowser.spec.ts index a8818fbe4..b40349b0d 100644 --- a/src/services/photos/PhotoCloudBrowser.spec.ts +++ b/src/services/photos/PhotoCloudBrowser.spec.ts @@ -362,6 +362,27 @@ describe('PhotoCloudBrowser.syncAllHistory', () => { expect(onMonthFetched).toHaveBeenCalledTimes(6); }); + test('when force is true and cache is fresh, then the month is re-fetched ignoring the TTL', async () => { + mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); + const device = makeFolder('d1-uuid', 'device-1'); + const year = makeFolder('y-uuid', '2024'); + const month = makeFolder('m1-uuid', '06'); + const day = makeFolder('day-uuid', '15'); + const file = makeFile('file-uuid', 'photo.jpg'); + const fresh = Date.now() - 1000; + mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(fresh); + mockFolderService.getFolderFolders + .mockResolvedValueOnce({ folders: [device] } as never) + .mockResolvedValueOnce({ folders: [year] } as never) + .mockResolvedValueOnce({ folders: [month] } as never) + .mockResolvedValueOnce({ folders: [day] } as never); + mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never); + + await photoCloudBrowser.syncAllHistory({ force: true }); + + expect(mockPhotosLocalDB.upsertCloudAsset).toHaveBeenCalledTimes(1); + }); + test('when a discovered month has no files, then onMonthFetched is not invoked for that month', async () => { mockBackupFolders.getRootFolderUuid.mockResolvedValueOnce('root-uuid'); const device = makeFolder('d1-uuid', 'device-1'); diff --git a/src/services/photos/PhotoCloudBrowser.ts b/src/services/photos/PhotoCloudBrowser.ts index 302988fff..2d871a88a 100644 --- a/src/services/photos/PhotoCloudBrowser.ts +++ b/src/services/photos/PhotoCloudBrowser.ts @@ -1,5 +1,6 @@ import { DriveFileData } from '@internxt-mobile/types/drive/file'; import { FetchPaginatedFolder } from '@internxt/sdk/dist/drive/storage/types'; +import { logger } from 'src/services/common'; import { driveFolderService } from 'src/services/drive/folder/driveFolder.service'; import { photosLocalDB } from './database/photosLocalDB'; import { photoBackupFolders } from './PhotoBackupFolders'; @@ -64,13 +65,26 @@ class PhotoCloudBrowserService { }); } - async syncAllHistory(options: { onMonthFetched?: () => void; isCancelled?: () => boolean }): Promise { - const { onMonthFetched, isCancelled } = options; + async syncAllHistory(options: { + onMonthFetched?: () => void; + isCancelled?: () => boolean; + force?: boolean; + }): Promise { + const { onMonthFetched, isCancelled, force } = options; const devices = await this.listDeviceFolders(); - if (devices.length === 0) return; + if (devices.length === 0) { + logger.info('[CloudBrowser] No device folders found — skipping sync'); + return; + } const months = await this.discoverAvailableMonths(devices); - if (months.length === 0) return; + if (months.length === 0) { + logger.info('[CloudBrowser] Discovery found no months — skipping sync'); + return; + } + logger.info( + `[CloudBrowser] Discovered ${months.length} months across ${devices.length} device(s)${force ? ' — TTL bypassed (force refresh)' : ''}`, + ); const CONCURRENCY = 3; let cursor = 0; @@ -84,6 +98,7 @@ class PhotoCloudBrowserService { year: target.year, month: target.month, onMonthFetched, + force, }); } }; @@ -96,10 +111,13 @@ class PhotoCloudBrowserService { year: number; month: number; onMonthFetched?: () => void; + force?: boolean; }): Promise { - const { deviceId, monthFolderUuid, year, month, onMonthFetched } = params; - const cacheAge = await this.localDB.getCloudFetchCacheAge(deviceId, year, month); - if (cacheAge !== null && Date.now() - cacheAge < CACHE_TTL_MS) return 0; + const { deviceId, monthFolderUuid, year, month, onMonthFetched, force } = params; + if (!force) { + const cacheAge = await this.localDB.getCloudFetchCacheAge(deviceId, year, month); + if (cacheAge !== null && Date.now() - cacheAge < CACHE_TTL_MS) return 0; + } const dayFolders = await this.listAllFolders(monthFolderUuid); const now = Date.now(); @@ -115,6 +133,7 @@ class PhotoCloudBrowserService { const fileName = file.type ? `${baseName}.${file.type}` : baseName; const createdAt = folderDate; const thumb = file.thumbnails?.[0] ?? null; + await this.localDB.upsertCloudAsset({ remoteFileId: file.uuid, deviceId, @@ -132,7 +151,12 @@ class PhotoCloudBrowserService { } if (count > 0) { + logger.info( + `[CloudBrowser] Device "${deviceId}" ${year}/${String(month).padStart(2, '0')} — ${count} file(s) upserted`, + ); onMonthFetched?.(); + } else { + logger.info(`[CloudBrowser] Device "${deviceId}" ${year}/${String(month).padStart(2, '0')} — empty`); } return count; } diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts index baebca550..259247528 100644 --- a/src/store/slices/photos/index.spec.ts +++ b/src/store/slices/photos/index.spec.ts @@ -12,6 +12,7 @@ import photosReducer, { checkPermissionRevocationThunk, disableBackupThunk, enableBackupThunk, + forceRefreshThunk, hydratePhotosStateThunk, initDeviceIdThunk, photosSlice, @@ -476,6 +477,102 @@ describe('photos slice', () => { expect(store.getState().photos.isFetchingCloudHistory).toBe(false); }); + test('when force is true, then syncAllHistory is called with force true', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true })); + + await store.dispatch(runFullCloudHistorySyncThunk({ force: true })); + + expect(mockCloudBrowser.syncAllHistory).toHaveBeenCalledWith(expect.objectContaining({ force: true })); + }); + }); + + describe('forceRefreshThunk', () => { + test('when backup is disabled, then the thunk returns without dispatching cloud sync', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: false })); + + await store.dispatch(forceRefreshThunk()); + + expect(mockCloudBrowser.syncAllHistory).not.toHaveBeenCalled(); + }); + + test('when sync is already scanning, then the thunk returns without dispatching cloud sync', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, syncStatus: 'scanning' })); + + await store.dispatch(forceRefreshThunk()); + + expect(mockCloudBrowser.syncAllHistory).not.toHaveBeenCalled(); + }); + + test('when sync is already uploading, then the thunk returns without dispatching cloud sync', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, syncStatus: 'uploading' })); + + await store.dispatch(forceRefreshThunk()); + + expect(mockCloudBrowser.syncAllHistory).not.toHaveBeenCalled(); + }); + + test('when a cloud history sync is already running, then the thunk returns without starting another one', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, isFetchingCloudHistory: true })); + + await store.dispatch(forceRefreshThunk()); + + expect(mockCloudBrowser.syncAllHistory).not.toHaveBeenCalled(); + }); + + test('when the thunk runs, then cloud sync is dispatched with force true and discovery runs', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); + + await store.dispatch(forceRefreshThunk()); + + expect(mockCloudBrowser.syncAllHistory).toHaveBeenCalledWith(expect.objectContaining({ force: true })); + expect(mockScanner.scanAll).toHaveBeenCalled(); + }); + + test('when discovery finds pending assets, then upload runs', async () => { + mockScanner.scanAll.mockResolvedValueOnce([{ id: 'a1' }] as never); + mockDeduplicator.getAssetsToSync.mockResolvedValueOnce({ + newAssets: [{ id: 'a1' }], + editedAssets: [], + } as never); + mockPhotosLocalDB.getPendingAssets.mockResolvedValueOnce([{ assetId: 'a1', status: 'pending' }] as never); + + const store = makeStore(); + store.dispatch( + photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted', deviceId: 'device-1' }), + ); + + await store.dispatch(forceRefreshThunk()); + + expect(mockUploadQueue.start).toHaveBeenCalled(); + }); + + test('when discovery finds no pending assets, then upload does not run', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); + + await store.dispatch(forceRefreshThunk()); + + expect(mockUploadQueue.start).not.toHaveBeenCalled(); + }); + + test('when the thunk completes, then lastSyncTimestamp is updated', async () => { + const store = makeStore(); + store.dispatch(photosSlice.actions.setState({ enabled: true, permissionStatus: 'granted' })); + const before = store.getState().photos.lastSyncTimestamp; + + await store.dispatch(forceRefreshThunk()); + + expect(store.getState().photos.lastSyncTimestamp).toBeGreaterThan(before ?? 0); + }); + }); + + describe('runBackupCycleThunk', () => { test('when the backup cycle runs, then cloud history sync and discovery both run', async () => { mockPhotoDeviceId.getOrCreate.mockResolvedValue('device-id'); diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts index 2807a7600..92a6de946 100644 --- a/src/store/slices/photos/index.ts +++ b/src/store/slices/photos/index.ts @@ -224,14 +224,16 @@ export const runUploadThunk = createAsyncThunk }, ); -export const runFullCloudHistorySyncThunk = createAsyncThunk( +export const runFullCloudHistorySyncThunk = createAsyncThunk( 'photos/runFullCloudHistorySync', - async (_, { getState, dispatch }) => { + async (args, { getState, dispatch }) => { + const force = args?.force ?? false; logger.info('[CloudHistorySync] Starting full cloud history sync'); dispatch(photosSlice.actions.setIsFetchingCloudHistory(true)); try { await photosLocalDB.init(); await photoCloudBrowser.syncAllHistory({ + force, onMonthFetched: () => dispatch(photosSlice.actions.incrementCloudFetchRevision()), isCancelled: () => !getState().photos.enabled, }); @@ -244,6 +246,31 @@ export const runFullCloudHistorySyncThunk = createAsyncThunk( + 'photos/forceRefresh', + async (_, { getState, dispatch }) => { + const { enabled, syncStatus, isFetchingCloudHistory } = getState().photos; + if (!enabled || syncStatus === 'scanning' || syncStatus === 'uploading' || isFetchingCloudHistory) { + logger.info(`[ForceRefresh] Skipped — enabled: ${enabled}, syncStatus: ${syncStatus}, isFetchingCloudHistory: ${isFetchingCloudHistory}`); + return; + } + + logger.info('[ForceRefresh] Starting — dispatching cloud sync (force) + local discovery'); + dispatch(runFullCloudHistorySyncThunk({ force: true })); + + await dispatch(runDiscoveryThunk()).unwrap(); + const pending = getState().photos.pendingBackupAssets; + if (pending > 0) { + logger.info(`[ForceRefresh] Discovery found ${pending} pending assets — starting upload`); + await dispatch(runUploadThunk()).unwrap(); + } else { + logger.info('[ForceRefresh] Discovery complete — no pending assets'); + } + logger.info('[ForceRefresh] Done'); + dispatch(photosSlice.actions.setLastSyncTimestamp(Date.now())); + }, +); + export const runBackupCycleThunk = createAsyncThunk( 'photos/runBackupCycle', async (_, { getState, dispatch }) => { From 4a877a5549f83be1afeb07a627d5646445d2796c Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Mon, 11 May 2026 11:40:12 +0200 Subject: [PATCH 2/2] Handle if refresh fails and added notifications --- assets/lang/strings.ts | 4 ++++ src/screens/PhotosScreen/index.tsx | 23 ++++++++++++++++++++++- src/services/NotificationsService.ts | 3 +++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index 4858603ee..bec36f4cc 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -209,6 +209,8 @@ const translations = { 'Internxt Photos keeps your gallery backed up and lets you share your photos privately with your family and friends', startButton: 'Start using Photos', }, + refreshLocalError: 'Gallery could not be loaded', + refreshCloudError: 'Cloud sync could not be completed. Check your connection and try again.', }, forgot_password: { title: 'Delete account', @@ -1128,6 +1130,8 @@ const translations = { 'Internxt Photos hace copias de seguridad de tu galería y te permite compartir tus fotos de forma privada con tu familia y amigos', startButton: 'Empezar a usar Photos', }, + refreshLocalError: 'No se pudo cargar la galería', + refreshCloudError: 'No se pudo completar la sincronización con la nube. Comprueba tu conexión e inténtalo de nuevo.', }, forgot_password: { title: 'Borrar cuenta', diff --git a/src/screens/PhotosScreen/index.tsx b/src/screens/PhotosScreen/index.tsx index 117717dbe..b354837f2 100644 --- a/src/screens/PhotosScreen/index.tsx +++ b/src/screens/PhotosScreen/index.tsx @@ -5,7 +5,10 @@ import AppScreen from 'src/components/AppScreen'; import { useAppDispatch, useAppSelector } from 'src/store/hooks'; import { forceRefreshThunk, photosActions, runBackupCycleThunk } from 'src/store/slices/photos'; import { useTailwind } from 'tailwind-rn'; +import strings from '../../../assets/lang/strings'; import { photoPermissionService } from '../../services/photos/photoPermissionService'; +import notificationsService from '../../services/NotificationsService'; +import { NotificationType } from '../../types'; import BackupDisabledBanner from './components/BackupDisabledBanner'; import PhotosHeader from './components/PhotosHeader'; import PhotosLockedOverlay from './components/PhotosLockedOverlay'; @@ -33,7 +36,25 @@ const PhotosScreen = (): JSX.Element => { const handleRefresh = useCallback(async () => { setRefreshing(true); try { - await Promise.all([reloadLocal(), dispatch(forceRefreshThunk()).unwrap()]); + const [localResult, cloudResult] = await Promise.allSettled([ + reloadLocal(), + dispatch(forceRefreshThunk()).unwrap(), + ]); + + if (localResult.status === 'rejected') { + notificationsService.show({ + text1: strings.screens.photos.refreshLocalError, + type: NotificationType.Error, + autoHide: false, + }); + } + if (cloudResult.status === 'rejected') { + notificationsService.show({ + text1: strings.screens.photos.refreshCloudError, + type: NotificationType.Error, + autoHide: false, + }); + } } finally { setRefreshing(false); } diff --git a/src/services/NotificationsService.ts b/src/services/NotificationsService.ts index e3dc1b2fc..9cb569bb0 100644 --- a/src/services/NotificationsService.ts +++ b/src/services/NotificationsService.ts @@ -34,10 +34,13 @@ class NotificationsService { text2?: string; type: NotificationType; action?: { text: string; onActionPress: () => void }; + autoHide?: boolean; }) { + const autoHide = options.autoHide ?? true; if (this.notifications.length === 0) { Toast.show({ ...this.defaultShowOptions, + ...(autoHide ? {} : { autoHide: false, visibilityTime: undefined }), text1: options.text1, text2: options.text2, type: options.type,