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
1 change: 1 addition & 0 deletions src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const makeWrapper = (photosState?: Partial<PhotosState>) => {
sessionTotalAssets: 0,
sessionUploadedAssets: 0,
cloudFetchRevision: 0,
isFetchingCloudHistory: false,
...photosState,
},
},
Expand Down
19 changes: 18 additions & 1 deletion src/screens/PhotosScreen/components/PhotoItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ const SelectOverlay = ({
);
};

const localPhotoCellAreEqual = (
prev: CellProps & { item: PhotoItemType },
next: CellProps & { item: PhotoItemType },
) =>
prev.item.id === next.item.id &&
prev.item.backupState === next.item.backupState &&
prev.item.uri === next.item.uri &&
prev.item.uploadProgress === next.item.uploadProgress &&
prev.item.mediaType === next.item.mediaType &&
prev.item.duration === next.item.duration &&
prev.isSelectMode === next.isSelectMode &&
prev.isSelected === next.isSelected &&
prev.onPress === next.onPress &&
prev.onLongPress === next.onLongPress;

const LocalPhotoCell = memo(
({ item, isSelectMode, isSelected, onPress, onLongPress }: CellProps & { item: PhotoItemType }): JSX.Element => {
const tailwind = useTailwind();
Expand All @@ -92,7 +107,8 @@ const LocalPhotoCell = memo(

return (
<TouchableOpacity activeOpacity={0.85} style={containerStyle} onPress={handlePress} onLongPress={handleLongPress}>
<Image source={{ uri: item.uri }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />
{/* key forces Image to remount on cell recycle, preventing the previous photo from flashing */}
<Image key={item.id} source={{ uri: item.uri }} style={StyleSheet.absoluteFillObject} resizeMode="cover" />

{(item.backupState === 'not-backed' || item.backupState === 'uploading') && (
<View style={[tailwind('absolute justify-center items-center'), { bottom: 8, left: 8 }]} pointerEvents="none">
Expand Down Expand Up @@ -130,6 +146,7 @@ const LocalPhotoCell = memo(
</TouchableOpacity>
);
},
localPhotoCellAreEqual,
);

const CloudPhotoCell = memo(
Expand Down
181 changes: 181 additions & 0 deletions src/screens/PhotosScreen/hooks/useCloudAssets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { act, renderHook } from '@testing-library/react-native';
import { photosLocalDB } from 'src/services/photos/database/photosLocalDB';
import { useAppSelector } from 'src/store/hooks';
import { useCloudAssets } from './useCloudAssets';

jest.useFakeTimers();

jest.mock('src/services/photos/database/photosLocalDB', () => ({
photosLocalDB: {
init: jest.fn().mockResolvedValue(undefined),
getAllCloudAssets: jest.fn(),
getSyncedRemoteFileIds: jest.fn(),
},
}));

jest.mock('src/store/hooks', () => ({
useAppSelector: jest.fn(),
}));

const mockPhotosLocalDB = photosLocalDB as jest.Mocked<typeof photosLocalDB>;
const mockUseAppSelector = useAppSelector as jest.Mock;

const makeStoreState = (overrides: { lastSyncTimestamp?: number | null; cloudFetchRevision?: number } = {}) => ({
lastSyncTimestamp: overrides.lastSyncTimestamp ?? null,
cloudFetchRevision: overrides.cloudFetchRevision ?? 0,
});

const flushAsync = async () => {
await Promise.resolve();
await Promise.resolve();
};

beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
mockPhotosLocalDB.getAllCloudAssets.mockResolvedValue([]);
mockPhotosLocalDB.getSyncedRemoteFileIds.mockResolvedValue(new Set());
mockUseAppSelector.mockImplementation((selector: (s: { photos: ReturnType<typeof makeStoreState> }) => unknown) =>
selector({ photos: makeStoreState() }),
);
});

describe('useCloudAssets', () => {
test('when the hook mounts, then cloud items are loaded from the database', async () => {
mockPhotosLocalDB.getAllCloudAssets.mockResolvedValueOnce([
{
remoteFileId: 'r1',
deviceId: 'device-1',
createdAt: 1000,
fileName: 'photo.jpg',
fileSize: null,
thumbnailPath: null,
thumbnailBucketId: null,
thumbnailBucketFile: null,
thumbnailType: null,
discoveredAt: 1000,
},
]);

const { result } = renderHook(() => useCloudAssets());

// Flush the immediate lastSyncTimestamp effect only — do not run timers
// so the debounce from cloudFetchRevision has not yet fired
await act(flushAsync);

expect(result.current.cloudItems).toHaveLength(1);
expect(result.current.cloudItems[0].id).toBe('r1');
});

test('when cloud fetch revision increments, then cloud items reload from the database after debounce', async () => {
mockPhotosLocalDB.getAllCloudAssets
.mockResolvedValueOnce([]) // immediate mount effect
.mockResolvedValueOnce([
{
remoteFileId: 'r2',
deviceId: 'device-1',
createdAt: 2000,
fileName: 'photo2.jpg',
fileSize: null,
thumbnailPath: null,
thumbnailBucketId: null,
thumbnailBucketFile: null,
thumbnailType: null,
discoveredAt: 2000,
},
]);

const { result, rerender } = renderHook(() => useCloudAssets());

await act(flushAsync);
expect(result.current.cloudItems).toHaveLength(0);

mockUseAppSelector.mockImplementation((selector: (s: { photos: ReturnType<typeof makeStoreState> }) => unknown) =>
selector({ photos: makeStoreState({ cloudFetchRevision: 1 }) }),
);

await act(async () => {
rerender({});
// Advance past the 500ms debounce, then flush the async reload
jest.runAllTimers();
await flushAsync();
});

expect(result.current.cloudItems).toHaveLength(1);
expect(result.current.cloudItems[0].id).toBe('r2');
});

test('when last sync timestamp updates, then cloud items reload from the database immediately', async () => {
mockPhotosLocalDB.getAllCloudAssets
.mockResolvedValueOnce([]) // mount
.mockResolvedValueOnce([
{
remoteFileId: 'r3',
deviceId: 'device-1',
createdAt: 3000,
fileName: 'photo3.jpg',
fileSize: null,
thumbnailPath: null,
thumbnailBucketId: null,
thumbnailBucketFile: null,
thumbnailType: null,
discoveredAt: 3000,
},
]);

const { result, rerender } = renderHook(() => useCloudAssets());

await act(flushAsync);
expect(result.current.cloudItems).toHaveLength(0);

mockUseAppSelector.mockImplementation((selector: (s: { photos: ReturnType<typeof makeStoreState> }) => unknown) =>
selector({ photos: makeStoreState({ lastSyncTimestamp: Date.now() }) }),
);

// lastSyncTimestamp effect is immediate — no timer needed
await act(async () => {
rerender({});
await flushAsync();
});

expect(result.current.cloudItems).toHaveLength(1);
expect(result.current.cloudItems[0].id).toBe('r3');
});

test('when synced remote ids overlap with cloud assets, then duplicates are excluded', async () => {
mockPhotosLocalDB.getAllCloudAssets.mockResolvedValueOnce([
{
remoteFileId: 'r1',
deviceId: 'device-1',
createdAt: 1000,
fileName: 'photo.jpg',
fileSize: null,
thumbnailPath: null,
thumbnailBucketId: null,
thumbnailBucketFile: null,
thumbnailType: null,
discoveredAt: 1000,
},
{
remoteFileId: 'r2',
deviceId: 'device-1',
createdAt: 2000,
fileName: 'photo2.jpg',
fileSize: null,
thumbnailPath: null,
thumbnailBucketId: null,
thumbnailBucketFile: null,
thumbnailType: null,
discoveredAt: 2000,
},
]);
mockPhotosLocalDB.getSyncedRemoteFileIds.mockResolvedValueOnce(new Set(['r1']));

const { result } = renderHook(() => useCloudAssets());

await act(flushAsync);

expect(result.current.cloudItems).toHaveLength(1);
expect(result.current.cloudItems[0].id).toBe('r2');
});
});
42 changes: 42 additions & 0 deletions src/screens/PhotosScreen/hooks/useCloudAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { photosLocalDB } from 'src/services/photos/database/photosLocalDB';
import { useAppSelector } from 'src/store/hooks';
import { CloudPhotoItem } from '../types';
import { cloudEntryToPhotoItem } from '../utils/photoTimelineGroups';

export interface CloudAssetsResult {
cloudItems: CloudPhotoItem[];
}

export const useCloudAssets = (): CloudAssetsResult => {
const [cloudItems, setCloudItems] = useState<CloudPhotoItem[]>([]);
const { lastSyncTimestamp, cloudFetchRevision } = useAppSelector((state) => state.photos);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const reloadCloudFromDB = useCallback(async () => {
await photosLocalDB.init();
const [allCloud, syncedRemoteIds] = await Promise.all([
photosLocalDB.getAllCloudAssets(),
photosLocalDB.getSyncedRemoteFileIds(),
]);
const deduplicated = allCloud.filter((cloudEntry) => !syncedRemoteIds.has(cloudEntry.remoteFileId));
setCloudItems(deduplicated.map(cloudEntryToPhotoItem));
}, []);

// Immediate reload when the backup cycle completes
useEffect(() => {
reloadCloudFromDB();
}, [reloadCloudFromDB, lastSyncTimestamp]);

// Debounced reload during cloud history sync — parallel workers can fire many
// rapid increments; coalescing them prevents FlashList from reconciling 2000+
// item lists on every month completion
useEffect(() => {
debounceRef.current = setTimeout(reloadCloudFromDB, 500);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [reloadCloudFromDB, cloudFetchRevision]);

return { cloudItems };
};
Loading
Loading