Skip to content
Open
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
4 changes: 4 additions & 0 deletions assets/lang/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion src/screens/PhotosScreen/components/PhotoItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions src/screens/PhotosScreen/components/PhotosTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ interface PhotosTimelineProps {
selectedIds?: Set<string>;
ListHeaderComponent?: React.ReactElement;
onEndReached?: () => void;
refreshing?: boolean;
onRefresh?: () => void;
}

const getItemType = (item: FlatItem) => item.type;
Expand All @@ -61,6 +63,8 @@ const PhotosTimeline = ({
selectedIds,
ListHeaderComponent,
onEndReached,
refreshing,
onRefresh,
}: PhotosTimelineProps) => {
const tailwind = useTailwind();
const { items, headerIndices } = useMemo(() => {
Expand Down Expand Up @@ -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}
/>
Expand Down
21 changes: 21 additions & 0 deletions src/screens/PhotosScreen/hooks/useLocalAssets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]]));
Expand Down
5 changes: 4 additions & 1 deletion src/screens/PhotosScreen/hooks/useLocalAssets.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,6 +14,7 @@ export interface LocalAssetsResult {
syncedIds: Set<string>;
uploadingIdSet: Set<string>;
loadNextPage: () => void;
reload: () => Promise<void>;
}

export const useLocalAssets = (): LocalAssetsResult => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 };
};
5 changes: 3 additions & 2 deletions src/screens/PhotosScreen/hooks/usePhotosTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ export interface PhotosTimelineResult {
timelineDateGroups: TimelineDateGroup[];
isLoading: boolean;
loadNextPage: () => void;
reloadLocal: () => Promise<void>;
}

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(
Expand All @@ -35,5 +36,5 @@ export const usePhotosTimeline = (): PhotosTimelineResult => {
})) as TimelineDateGroup[];
}, [mergedGroups, syncStatus, sessionTotalAssets, sessionUploadedAssets, isFetchingCloudHistory]);

return { timelineDateGroups, isLoading, loadNextPage };
return { timelineDateGroups, isLoading, loadNextPage, reloadLocal };
};
37 changes: 35 additions & 2 deletions src/screens/PhotosScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ 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 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';
Expand All @@ -19,7 +22,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<PhotosAccessState>(
() => (enabled ? { type: 'available' } : { type: 'backup-off' }),
Expand All @@ -29,6 +33,33 @@ const PhotosScreen = (): JSX.Element => {
const listHeader =
accessState.type === 'backup-off' ? <BackupDisabledBanner onEnablePress={handleEnableBackup} /> : undefined;

const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
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);
}
}, [dispatch, reloadLocal]);

const handleSelectPress = useCallback(() => undefined, []);
const handleUpgradePress = useCallback(() => undefined, []);

Expand Down Expand Up @@ -60,6 +91,8 @@ const PhotosScreen = (): JSX.Element => {
isLoading={isLoading}
ListHeaderComponent={listHeader}
onEndReached={loadNextPage}
refreshing={refreshing}
onRefresh={handleRefresh}
/>
{accessState.type === 'photos-locked' && <PhotosLockedOverlay onUpgradePress={handleUpgradePress} />}
</View>
Expand Down
3 changes: 3 additions & 0 deletions src/services/NotificationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/services/photos/PhotoCloudBrowser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,27 @@
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)

Check warning on line 375 in src/services/photos/PhotoCloudBrowser.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4HVzp1T2kFk-rKso-d&open=AZ4HVzp1T2kFk-rKso-d&pullRequest=445
.mockResolvedValueOnce({ folders: [year] } as never)

Check warning on line 376 in src/services/photos/PhotoCloudBrowser.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4HVzp1T2kFk-rKso-e&open=AZ4HVzp1T2kFk-rKso-e&pullRequest=445
.mockResolvedValueOnce({ folders: [month] } as never)

Check warning on line 377 in src/services/photos/PhotoCloudBrowser.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4HVzp1T2kFk-rKso-f&open=AZ4HVzp1T2kFk-rKso-f&pullRequest=445
.mockResolvedValueOnce({ folders: [day] } as never);

Check warning on line 378 in src/services/photos/PhotoCloudBrowser.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4HVzp1T2kFk-rKso-g&open=AZ4HVzp1T2kFk-rKso-g&pullRequest=445
mockFolderService.getFolderContentByUuid.mockResolvedValueOnce({ files: [file] } as never);

Check warning on line 379 in src/services/photos/PhotoCloudBrowser.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4HVzp1T2kFk-rKso-h&open=AZ4HVzp1T2kFk-rKso-h&pullRequest=445

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');
Expand Down
38 changes: 31 additions & 7 deletions src/services/photos/PhotoCloudBrowser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -64,13 +65,26 @@
});
}

async syncAllHistory(options: { onMonthFetched?: () => void; isCancelled?: () => boolean }): Promise<void> {
const { onMonthFetched, isCancelled } = options;
async syncAllHistory(options: {
onMonthFetched?: () => void;
isCancelled?: () => boolean;
force?: boolean;
}): Promise<void> {
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;
Expand All @@ -84,22 +98,26 @@
year: target.year,
month: target.month,
onMonthFetched,
force,
});
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
}

private async fetchMonthFromFolder(params: {

Check failure on line 108 in src/services/photos/PhotoCloudBrowser.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ4HVzsTT2kFk-rKso-i&open=AZ4HVzsTT2kFk-rKso-i&pullRequest=445
deviceId: string;
monthFolderUuid: string;
year: number;
month: number;
onMonthFetched?: () => void;
force?: boolean;
}): Promise<number> {
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();
Expand All @@ -115,6 +133,7 @@
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,
Expand All @@ -132,7 +151,12 @@
}

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;
}
Expand Down
Loading
Loading