diff --git a/src/app/drive/services/thumbnail.service.test.ts b/src/app/drive/services/thumbnail.service.test.ts index af6745227..a3e8f48fa 100644 --- a/src/app/drive/services/thumbnail.service.test.ts +++ b/src/app/drive/services/thumbnail.service.test.ts @@ -386,6 +386,17 @@ describe('Thumbnail Service', () => { ); }); + test('When the download is given an abort controller, then it can be cancelled with it', async () => { + const abortController = new AbortController(); + + await downloadThumbnail(mockThumbnail, false, abortController); + + expect(fetchFileBlob).toHaveBeenCalledWith( + expect.objectContaining({ fileId: mockThumbnail.bucket_file }), + expect.objectContaining({ abortController }), + ); + }); + test('When downloading thumbnail in personal context, then it uses personal credentials', async () => { vi.mocked(encryptedStorageService.getUser).mockResolvedValue({ bucket: personalBucket } as UserSettings); diff --git a/src/app/drive/services/thumbnail.service.ts b/src/app/drive/services/thumbnail.service.ts index 701e7f12d..6854451b3 100644 --- a/src/app/drive/services/thumbnail.service.ts +++ b/src/app/drive/services/thumbnail.service.ts @@ -307,11 +307,14 @@ export const generateThumbnailFromFile = async ( return null; }; -export const downloadThumbnail = async (thumbnailToDownload: Thumbnail, isWorkspace: boolean): Promise => { +export const downloadThumbnail = async ( + thumbnailToDownload: Pick, + isWorkspace: boolean, + abortController = new AbortController(), +): Promise => { const updateProgressCallback = () => { return; }; - const abortController = new AbortController(); let useWorkspaceCredentials = isWorkspace; diff --git a/src/testUtils/fixtures/photos.fixtures.ts b/src/testUtils/fixtures/photos.fixtures.ts index 3c5539763..7520c5b23 100644 --- a/src/testUtils/fixtures/photos.fixtures.ts +++ b/src/testUtils/fixtures/photos.fixtures.ts @@ -2,6 +2,7 @@ import { PhotoDevice as SdkPhotoDevice, PhotoFolderFile } from '@internxt/sdk/di import { FetchFolderFoldersCursorResponse } from '@internxt/sdk/dist/drive/storage/types'; type Folder = FetchFolderFoldersCursorResponse['folders'][number]; +type PhotoFolderFileThumbnail = NonNullable[number]; export const getSdkPhotoDevice = (overrides: Partial = {}): SdkPhotoDevice => ({ uuid: 'iphone-uuid', @@ -56,3 +57,19 @@ export const getPhotoFolderFile = (overrides: Partial = {}): Ph thumbnails: [], ...overrides, }) as unknown as PhotoFolderFile; + +export const getPhotoThumbnail = (overrides: Partial = {}): PhotoFolderFileThumbnail => ({ + id: 1, + fileId: 0, + fileUuid: 'file-uuid', + maxWidth: 300, + maxHeight: 300, + type: 'jpg', + size: 20000, + bucketId: 'photos-bucket', + bucketFile: 'thumbnail-bucket-file', + encryptVersion: '03-aes', + createdAt: '2026-11-03T10:00:00.000Z', + updatedAt: '2026-11-03T10:00:00.000Z', + ...overrides, +}); diff --git a/src/views/Photos/PhotosView.test.tsx b/src/views/Photos/PhotosView.test.tsx index 2e10ace04..9ca23cd24 100644 --- a/src/views/Photos/PhotosView.test.tsx +++ b/src/views/Photos/PhotosView.test.tsx @@ -1,8 +1,15 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; +import { getDatabaseFilePreviewData, updateDatabaseFilePreviewData } from 'app/drive/services/database.service'; +import fetchFileBlob from 'app/drive/services/download.service/fetchFileBlob'; import errorService from 'services/error.service'; import { PhotoDevice as SdkPhotoDevice } from '@internxt/sdk/dist/drive/photos'; -import { getPhotoFolder, getPhotoFolderFile, getSdkPhotoDevice } from 'testUtils/fixtures/photos.fixtures'; +import { + getPhotoFolder, + getPhotoFolderFile, + getPhotoThumbnail, + getSdkPhotoDevice, +} from 'testUtils/fixtures/photos.fixtures'; import PhotosView from './PhotosView'; const { photosClient, storageClient } = vi.hoisted(() => ({ @@ -27,6 +34,14 @@ vi.mock('react-helmet-async', () => ({ vi.mock('services/error.service', () => ({ default: { reportError: vi.fn() }, })); +vi.mock('app/drive/services/download.service/fetchFileBlob', () => ({ + default: vi.fn(), +})); +vi.mock('app/drive/services/database.service', async (importOriginal) => ({ + ...(await importOriginal()), + getDatabaseFilePreviewData: vi.fn(), + updateDatabaseFilePreviewData: vi.fn(), +})); type Folder = ReturnType; @@ -78,6 +93,8 @@ describe('Photos view', () => { vi.clearAllMocks(); givenSubfolders({}); givenPhotosInFolders({}); + (getDatabaseFilePreviewData as Mock).mockResolvedValue(undefined); + (updateDatabaseFilePreviewData as Mock).mockResolvedValue(undefined); }); test('When the devices are loading, then the photos grid placeholder is shown under the title', () => { @@ -373,4 +390,105 @@ describe('Photos view', () => { expect(await screen.findByRole('img', { name: 'IMG_A.jpg' })).toBeInTheDocument(); }); }); + + describe('Photo thumbnails', () => { + const iphoneDayOfNovember2026 = { + ...iphonePhotosOfNovember2026, + 'iphone-2026-11': [getPhotoFolder({ uuid: 'iphone-2026-11-03', plainName: '03' })], + }; + + const photoUpdatedAt = '2026-11-03T10:00:00.000Z'; + + const givenPhotoWithThumbnail = () => { + givenDevices([iphone]); + givenSubfolders(iphoneDayOfNovember2026); + givenPhotosInFolders({ + 'iphone-2026-11-03': [ + getPhotoFolderFile({ + uuid: 'IMG_A', + plainName: 'IMG_A', + folderUuid: 'iphone-2026-11-03', + updatedAt: photoUpdatedAt, + thumbnails: [getPhotoThumbnail({ bucketId: 'photos-bucket', bucketFile: 'thumbnail-a' })], + }), + ], + }); + }; + + const thumbnailOf = async (photoName: string) => { + const tile = await screen.findByRole('img', { name: photoName }); + return () => tile.querySelector('img'); + }; + + test('When a photo with a thumbnail is on screen, then its thumbnail is downloaded and shown', async () => { + givenPhotoWithThumbnail(); + (fetchFileBlob as Mock).mockResolvedValue(new Blob(['thumbnail'], { type: 'image/jpeg' })); + + render(); + const thumbnail = await thumbnailOf('IMG_A.jpg'); + + await waitFor(() => expect(thumbnail()?.getAttribute('src')).toMatch(/^blob:/)); + expect(fetchFileBlob).toHaveBeenCalledWith( + { fileId: 'thumbnail-a', bucketId: 'photos-bucket' }, + expect.objectContaining({ isWorkspace: false }), + ); + }); + + test('When a thumbnail was already downloaded, then it is shown without downloading it again', async () => { + givenPhotoWithThumbnail(); + (getDatabaseFilePreviewData as Mock).mockResolvedValue({ + preview: new Blob(['cached'], { type: 'image/jpeg' }), + updatedAt: photoUpdatedAt, + }); + + render(); + const thumbnail = await thumbnailOf('IMG_A.jpg'); + + await waitFor(() => expect(thumbnail()?.getAttribute('src')).toMatch(/^blob:/)); + expect(fetchFileBlob).not.toHaveBeenCalled(); + }); + + test('When the photo changed after its thumbnail was saved, then the new thumbnail is downloaded', async () => { + givenPhotoWithThumbnail(); + (getDatabaseFilePreviewData as Mock).mockResolvedValue({ + preview: new Blob(['outdated'], { type: 'image/jpeg' }), + updatedAt: '2026-11-01T10:00:00.000Z', + }); + (fetchFileBlob as Mock).mockResolvedValue(new Blob(['thumbnail'], { type: 'image/jpeg' })); + + render(); + const thumbnail = await thumbnailOf('IMG_A.jpg'); + + await waitFor(() => expect(thumbnail()?.getAttribute('src')).toMatch(/^blob:/)); + expect(fetchFileBlob).toHaveBeenCalled(); + expect(updateDatabaseFilePreviewData).toHaveBeenCalledWith( + expect.objectContaining({ updatedAt: photoUpdatedAt }), + ); + }); + + test('When the thumbnail cannot be saved, then the error is reported and the thumbnail is still shown', async () => { + givenPhotoWithThumbnail(); + const saveError = new Error('Quota exceeded'); + (fetchFileBlob as Mock).mockResolvedValue(new Blob(['thumbnail'], { type: 'image/jpeg' })); + (updateDatabaseFilePreviewData as Mock).mockRejectedValue(saveError); + + render(); + const thumbnail = await thumbnailOf('IMG_A.jpg'); + + await waitFor(() => expect(thumbnail()?.getAttribute('src')).toMatch(/^blob:/)); + await waitFor(() => expect(errorService.reportError).toHaveBeenCalledWith(saveError)); + }); + + test('When a thumbnail cannot be downloaded, then the file type icon is shown instead', async () => { + givenPhotoWithThumbnail(); + (fetchFileBlob as Mock).mockRejectedValue(new Error('Network error')); + + render(); + const thumbnail = await thumbnailOf('IMG_A.jpg'); + + await waitFor(() => expect(errorService.reportError).toHaveBeenCalled()); + expect(thumbnail()).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'IMG_A.jpg' }).querySelector('svg')).toBeInTheDocument(); + }); + }); }); diff --git a/src/views/Photos/components/PhotoTile.tsx b/src/views/Photos/components/PhotoTile.tsx index 0dd4980e6..e38be7c34 100644 --- a/src/views/Photos/components/PhotoTile.tsx +++ b/src/views/Photos/components/PhotoTile.tsx @@ -1,21 +1,45 @@ import iconService from 'app/drive/services/icon.service'; -import { memo } from 'react'; +import { memo, useRef } from 'react'; +import { useIsNearViewport } from '../hooks/useIsNearViewport'; +import { PhotoThumbnailState, usePhotoThumbnail } from '../hooks/usePhotoThumbnail'; import { Photo } from '../types'; +const THUMBNAIL_PRELOAD_MARGIN = '400px'; + interface PhotoTileProps { photo: Photo; } +const renderTileContent = (photo: Photo, thumbnail: PhotoThumbnailState): JSX.Element => { + switch (thumbnail.status) { + case 'loading': + return
; + case 'loaded': + return {photo.name}; + case 'unavailable': { + const FileIcon = iconService.getItemIcon(false, photo.type); + return ( +
+ +
+ ); + } + } +}; + const PhotoTile = ({ photo }: PhotoTileProps): JSX.Element => { - const FileIcon = iconService.getItemIcon(false, photo.type); + const tileRef = useRef(null); + const isNearViewport = useIsNearViewport(tileRef, THUMBNAIL_PRELOAD_MARGIN); + const thumbnail = usePhotoThumbnail(photo, isNearViewport); return (
- + {renderTileContent(photo, thumbnail)}
); }; diff --git a/src/views/Photos/hooks/useIsNearViewport.ts b/src/views/Photos/hooks/useIsNearViewport.ts new file mode 100644 index 000000000..652d790b2 --- /dev/null +++ b/src/views/Photos/hooks/useIsNearViewport.ts @@ -0,0 +1,19 @@ +import { RefObject, useEffect, useState } from 'react'; + +export const useIsNearViewport = (elementRef: RefObject, margin: string): boolean => { + const [isNearViewport, setIsNearViewport] = useState(false); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + const observer = new IntersectionObserver(([entry]) => setIsNearViewport(entry.isIntersecting), { + rootMargin: margin, + }); + observer.observe(element); + + return () => observer.disconnect(); + }, [elementRef, margin]); + + return isNearViewport; +}; diff --git a/src/views/Photos/hooks/usePhotoThumbnail.ts b/src/views/Photos/hooks/usePhotoThumbnail.ts new file mode 100644 index 000000000..0790ce58a --- /dev/null +++ b/src/views/Photos/hooks/usePhotoThumbnail.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import errorService from 'services/error.service'; +import { PhotoThumbnailsService } from '../services/photoThumbnails.service'; +import { Photo, PhotoThumbnail } from '../types'; + +export type PhotoThumbnailState = { status: 'loading' } | { status: 'unavailable' } | { status: 'loaded'; url: string }; + +export const usePhotoThumbnail = (photo: Photo, isNearViewport: boolean): PhotoThumbnailState => { + const [thumbnail, setThumbnail] = useState( + photo.thumbnail ? { status: 'loading' } : { status: 'unavailable' }, + ); + const shouldLoad = isNearViewport && thumbnail.status === 'loading'; + + useEffect(() => { + if (!shouldLoad || !photo.thumbnail) return; + + const abortController = new AbortController(); + + const loadThumbnail = async (photoThumbnail: PhotoThumbnail) => { + try { + const blob = await PhotoThumbnailsService.instance.getThumbnail(photo, photoThumbnail, abortController); + if (abortController.signal.aborted) return; + setThumbnail({ status: 'loaded', url: URL.createObjectURL(blob) }); + } catch (error) { + if (abortController.signal.aborted) return; + errorService.reportError(error); + setThumbnail({ status: 'unavailable' }); + } + }; + + void loadThumbnail(photo.thumbnail); + + return () => abortController.abort(); + }, [photo, shouldLoad]); + + useEffect(() => { + if (thumbnail.status !== 'loaded') return; + + return () => URL.revokeObjectURL(thumbnail.url); + }, [thumbnail]); + + return thumbnail; +}; diff --git a/src/views/Photos/services/photoFolders.service.test.ts b/src/views/Photos/services/photoFolders.service.test.ts index d32cc73af..d16b81e7a 100644 --- a/src/views/Photos/services/photoFolders.service.test.ts +++ b/src/views/Photos/services/photoFolders.service.test.ts @@ -1,5 +1,5 @@ import { SdkFactory } from 'app/core/factory/sdk'; -import { getPhotoFolder, getPhotoFolderFile } from 'testUtils/fixtures/photos.fixtures'; +import { getPhotoFolder, getPhotoFolderFile, getPhotoThumbnail } from 'testUtils/fixtures/photos.fixtures'; import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; import { PhotoFoldersService } from './photoFolders.service'; @@ -100,20 +100,28 @@ describe('Photo backup folders', () => { expect(photos).toStrictEqual([ { + id: 1, uuid: 'photo-a', + folderId: 10, name: 'IMG_A.heic', type: 'heic', month: '2026-11', day: '2026-11-02', takenAt: '2026-11-02T09:00:00Z', + updatedAt: '2026-11-03T10:00:00.000Z', + thumbnail: null, }, { + id: 1, uuid: 'photo-b', + folderId: 10, name: 'IMG_B.jpg', type: 'jpg', month: '2026-11', day: '2026-11-15', takenAt: '2026-11-15T09:00:00Z', + updatedAt: '2026-11-03T10:00:00.000Z', + thumbnail: null, }, ]); expect(photosClient.getFilesInFolders).toHaveBeenCalledWith({ @@ -123,6 +131,26 @@ describe('Photo backup folders', () => { }); }); + test('When a photo has several thumbnails, then the latest one is used', async () => { + givenSubfolders({ 'november-2026': [getPhotoFolder({ uuid: 'day-2', plainName: '02' })] }); + photosClient.getFilesInFolders.mockResolvedValue({ + files: [ + getPhotoFolderFile({ + folderUuid: 'day-2', + thumbnails: [ + getPhotoThumbnail({ id: 7, bucketId: 'photos-bucket', bucketFile: 'edited-thumbnail' }), + getPhotoThumbnail({ id: 3, bucketId: 'photos-bucket', bucketFile: 'original-thumbnail' }), + ], + }), + ], + nextCursor: null, + }); + + const [photo] = await PhotoFoldersService.instance.getMonthPhotos(november2026); + + expect(photo.thumbnail).toStrictEqual({ bucket_id: 'photos-bucket', bucket_file: 'edited-thumbnail' }); + }); + test('When some photos are trashed or deleted, then they are left out', async () => { givenSubfolders({ 'november-2026': [getPhotoFolder({ uuid: 'day-2', plainName: '2' })] }); photosClient.getFilesInFolders.mockResolvedValue({ diff --git a/src/views/Photos/services/photoFolders.service.ts b/src/views/Photos/services/photoFolders.service.ts index ae4d179d9..49ec69e97 100644 --- a/src/views/Photos/services/photoFolders.service.ts +++ b/src/views/Photos/services/photoFolders.service.ts @@ -2,7 +2,7 @@ import { Photos, Storage } from '@internxt/sdk/dist/drive'; import { PhotoFolderFile } from '@internxt/sdk/dist/drive/photos'; import { FetchFolderFoldersCursorResponse } from '@internxt/sdk/dist/drive/storage/types'; import { SdkFactory } from 'app/core/factory/sdk'; -import { DayKey, MonthKey, Photo, PhotoMonth } from '../types'; +import { DayKey, MonthKey, Photo, PhotoMonth, PhotoThumbnail } from '../types'; type Folder = FetchFolderFoldersCursorResponse['folders'][number]; @@ -12,6 +12,13 @@ const PHOTOS_SEARCH_START = new Date(0).toISOString(); const toTwoDigits = (folderName: string) => folderName.padStart(2, '0'); +const getLatestThumbnail = (thumbnails: PhotoFolderFile['thumbnails']): PhotoThumbnail | null => { + if (!thumbnails?.length) return null; + + const latestThumbnail = thumbnails.reduce((latest, thumbnail) => (thumbnail.id > latest.id ? thumbnail : latest)); + return { bucket_id: latestThumbnail.bucketId, bucket_file: latestThumbnail.bucketFile }; +}; + export class PhotoFoldersService { public static readonly instance: PhotoFoldersService = new PhotoFoldersService(); @@ -42,12 +49,16 @@ export class PhotoFoldersService { return [ { + id: file.id, uuid: file.uuid, + folderId: file.folderId, name: `${file.plainName}.${file.type}`, type: file.type, month: month.key, day, takenAt: file.creationTime, + updatedAt: file.updatedAt, + thumbnail: getLatestThumbnail(file.thumbnails), }, ]; }); diff --git a/src/views/Photos/services/photoThumbnails.service.ts b/src/views/Photos/services/photoThumbnails.service.ts new file mode 100644 index 000000000..09a0c0c1e --- /dev/null +++ b/src/views/Photos/services/photoThumbnails.service.ts @@ -0,0 +1,24 @@ +import { getDatabaseFilePreviewData, updateDatabaseFilePreviewData } from 'app/drive/services/database.service'; +import { downloadThumbnail } from 'app/drive/services/thumbnail.service'; +import errorService from 'services/error.service'; +import { Photo, PhotoThumbnail } from '../types'; + +export class PhotoThumbnailsService { + public static readonly instance: PhotoThumbnailsService = new PhotoThumbnailsService(); + + async getThumbnail(photo: Photo, thumbnail: PhotoThumbnail, abortController: AbortController): Promise { + const cachedPreview = await getDatabaseFilePreviewData({ fileId: photo.id }); + const isCachedPreviewUpToDate = cachedPreview?.updatedAt === photo.updatedAt; + if (cachedPreview?.preview && isCachedPreviewUpToDate) return cachedPreview.preview; + + const thumbnailBlob = await downloadThumbnail(thumbnail, false, abortController); + updateDatabaseFilePreviewData({ + fileId: photo.id, + folderId: photo.folderId, + previewBlob: thumbnailBlob, + updatedAt: photo.updatedAt, + }).catch(errorService.reportError); + + return thumbnailBlob; + } +} diff --git a/src/views/Photos/types.ts b/src/views/Photos/types.ts index df8ee5a14..36e51e27f 100644 --- a/src/views/Photos/types.ts +++ b/src/views/Photos/types.ts @@ -1,3 +1,5 @@ +import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types'; + export interface PhotoDevice { uuid: string; name: string; @@ -13,11 +15,17 @@ export interface PhotoMonth { folderUuid: string; } +export type PhotoThumbnail = Pick; + export interface Photo { + id: number; uuid: string; + folderId: number; name: string; type: string; month: MonthKey; day: DayKey; takenAt: string; + updatedAt: string; + thumbnail: PhotoThumbnail | null; }