From 12ed2f601eab20894b64075311748ff6815830a6 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Thu, 5 Nov 2026 16:09:54 +0100 Subject: [PATCH 1/2] feat(photos): show the thumbnails of the photos as they scroll into view --- .../drive/services/thumbnail.service.test.ts | 11 +++ src/app/drive/services/thumbnail.service.ts | 7 +- src/testUtils/fixtures/photos.fixtures.ts | 17 ++++ src/views/Photos/PhotosView.test.tsx | 84 ++++++++++++++++++- src/views/Photos/components/PhotoTile.tsx | 24 +++++- src/views/Photos/hooks/useIsNearViewport.ts | 19 +++++ src/views/Photos/hooks/usePhotoThumbnail.ts | 47 +++++++++++ .../services/photoFolders.service.test.ts | 30 ++++++- .../Photos/services/photoFolders.service.ts | 13 ++- .../services/photoThumbnails.service.ts | 22 +++++ src/views/Photos/types.ts | 8 ++ 11 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 src/views/Photos/hooks/useIsNearViewport.ts create mode 100644 src/views/Photos/hooks/usePhotoThumbnail.ts create mode 100644 src/views/Photos/services/photoThumbnails.service.ts diff --git a/src/app/drive/services/thumbnail.service.test.ts b/src/app/drive/services/thumbnail.service.test.ts index af6745227b..a3e8f48fae 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 701e7f12df..6854451b34 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 3c5539763f..7520c5b23a 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 2e10ace04c..7eb3bc89a1 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 } 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,7 @@ describe('Photos view', () => { vi.clearAllMocks(); givenSubfolders({}); givenPhotosInFolders({}); + (getDatabaseFilePreviewData as Mock).mockResolvedValue(undefined); }); test('When the devices are loading, then the photos grid placeholder is shown under the title', () => { @@ -373,4 +389,68 @@ 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 givenPhotoWithThumbnail = () => { + givenDevices([iphone]); + givenSubfolders(iphoneDayOfNovember2026); + givenPhotosInFolders({ + 'iphone-2026-11-03': [ + getPhotoFolderFile({ + uuid: 'IMG_A', + plainName: 'IMG_A', + folderUuid: 'iphone-2026-11-03', + 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' }) }); + + render(); + const thumbnail = await thumbnailOf('IMG_A.jpg'); + + await waitFor(() => expect(thumbnail()?.getAttribute('src')).toMatch(/^blob:/)); + expect(fetchFileBlob).not.toHaveBeenCalled(); + }); + + 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 0dd4980e67..80feba099b 100644 --- a/src/views/Photos/components/PhotoTile.tsx +++ b/src/views/Photos/components/PhotoTile.tsx @@ -1,21 +1,39 @@ import iconService from 'app/drive/services/icon.service'; -import { memo } from 'react'; +import { memo, useRef } from 'react'; +import { useIsNearViewport } from '../hooks/useIsNearViewport'; +import { usePhotoThumbnail } from '../hooks/usePhotoThumbnail'; import { Photo } from '../types'; +const THUMBNAIL_PRELOAD_MARGIN = '400px'; + interface PhotoTileProps { photo: Photo; } const PhotoTile = ({ photo }: PhotoTileProps): JSX.Element => { + const tileRef = useRef(null); + const isNearViewport = useIsNearViewport(tileRef, THUMBNAIL_PRELOAD_MARGIN); + const thumbnail = usePhotoThumbnail(photo, isNearViewport); const FileIcon = iconService.getItemIcon(false, photo.type); + const content = { + loading:
, + unavailable: ( +
+ +
+ ), + loaded: {photo.name}, + }; + return (
- + {content[thumbnail.status]}
); }; diff --git a/src/views/Photos/hooks/useIsNearViewport.ts b/src/views/Photos/hooks/useIsNearViewport.ts new file mode 100644 index 0000000000..652d790b2a --- /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 0000000000..a5c50a342d --- /dev/null +++ b/src/views/Photos/hooks/usePhotoThumbnail.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import errorService from 'services/error.service'; +import { PhotoThumbnailsService } from '../services/photoThumbnails.service'; +import { Photo, PhotoThumbnail } from '../types'; + +type PhotoThumbnailState = { status: 'loading' } | { status: 'unavailable' } | { status: 'loaded'; blob: Blob }; + +export const usePhotoThumbnail = (photo: Photo, isNearViewport: boolean) => { + const [state, setState] = useState( + photo.thumbnail ? { status: 'loading' } : { status: 'unavailable' }, + ); + const [thumbnailUrl, setThumbnailUrl] = useState(); + const shouldLoad = isNearViewport && state.status === 'loading'; + + useEffect(() => { + if (!shouldLoad || !photo.thumbnail) return; + + const abortController = new AbortController(); + + const loadThumbnail = async (thumbnail: PhotoThumbnail) => { + try { + const blob = await PhotoThumbnailsService.instance.getThumbnail(photo, thumbnail, abortController); + if (abortController.signal.aborted) return; + setState({ status: 'loaded', blob }); + } catch (error) { + if (abortController.signal.aborted) return; + errorService.reportError(error); + setState({ status: 'unavailable' }); + } + }; + + void loadThumbnail(photo.thumbnail); + + return () => abortController.abort(); + }, [photo, shouldLoad]); + + useEffect(() => { + if (state.status !== 'loaded') return; + + const blobUrl = URL.createObjectURL(state.blob); + setThumbnailUrl(blobUrl); + + return () => URL.revokeObjectURL(blobUrl); + }, [state]); + + return { status: state.status, url: thumbnailUrl }; +}; diff --git a/src/views/Photos/services/photoFolders.service.test.ts b/src/views/Photos/services/photoFolders.service.test.ts index 8cff6f7371..c65aafa9c5 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 16ea0110b1..5c1e941fed 100644 --- a/src/views/Photos/services/photoFolders.service.ts +++ b/src/views/Photos/services/photoFolders.service.ts @@ -1,7 +1,7 @@ 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]; @@ -11,6 +11,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(); @@ -38,12 +45,16 @@ export class PhotoFoldersService { return files .filter((file) => file.status === 'EXISTS') .map((file) => ({ + id: file.id, uuid: file.uuid, + folderId: file.folderId, name: `${file.plainName}.${file.type}`, type: file.type, month: month.key, day: dayOfFolder.get(file.folderUuid) as DayKey, 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 0000000000..91611191ad --- /dev/null +++ b/src/views/Photos/services/photoThumbnails.service.ts @@ -0,0 +1,22 @@ +import { getDatabaseFilePreviewData, updateDatabaseFilePreviewData } from 'app/drive/services/database.service'; +import { downloadThumbnail } from 'app/drive/services/thumbnail.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 }); + if (cachedPreview?.preview) return cachedPreview.preview; + + const thumbnailBlob = await downloadThumbnail(thumbnail, false, abortController); + void updateDatabaseFilePreviewData({ + fileId: photo.id, + folderId: photo.folderId, + previewBlob: thumbnailBlob, + updatedAt: photo.updatedAt, + }); + + return thumbnailBlob; + } +} diff --git a/src/views/Photos/types.ts b/src/views/Photos/types.ts index df8ee5a141..36e51e27f0 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; } From c4a88cc552860e152e49c08129d54d7c6ec527a3 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Fri, 6 Nov 2026 17:43:57 +0100 Subject: [PATCH 2/2] fix(photos): refresh outdated cached thumbnails and report failed cache writes --- src/views/Photos/PhotosView.test.tsx | 42 ++++++++++++++++++- src/views/Photos/components/PhotoTile.tsx | 32 ++++++++------ src/views/Photos/hooks/usePhotoThumbnail.ts | 28 ++++++------- .../services/photoThumbnails.service.ts | 8 ++-- 4 files changed, 76 insertions(+), 34 deletions(-) diff --git a/src/views/Photos/PhotosView.test.tsx b/src/views/Photos/PhotosView.test.tsx index 7eb3bc89a1..9ca23cd246 100644 --- a/src/views/Photos/PhotosView.test.tsx +++ b/src/views/Photos/PhotosView.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; -import { getDatabaseFilePreviewData } from 'app/drive/services/database.service'; +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'; @@ -94,6 +94,7 @@ describe('Photos view', () => { 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', () => { @@ -396,6 +397,8 @@ describe('Photos view', () => { '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); @@ -405,6 +408,7 @@ describe('Photos view', () => { uuid: 'IMG_A', plainName: 'IMG_A', folderUuid: 'iphone-2026-11-03', + updatedAt: photoUpdatedAt, thumbnails: [getPhotoThumbnail({ bucketId: 'photos-bucket', bucketFile: 'thumbnail-a' })], }), ], @@ -432,7 +436,10 @@ describe('Photos view', () => { 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' }) }); + (getDatabaseFilePreviewData as Mock).mockResolvedValue({ + preview: new Blob(['cached'], { type: 'image/jpeg' }), + updatedAt: photoUpdatedAt, + }); render(); const thumbnail = await thumbnailOf('IMG_A.jpg'); @@ -441,6 +448,37 @@ describe('Photos view', () => { 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')); diff --git a/src/views/Photos/components/PhotoTile.tsx b/src/views/Photos/components/PhotoTile.tsx index 80feba099b..e38be7c346 100644 --- a/src/views/Photos/components/PhotoTile.tsx +++ b/src/views/Photos/components/PhotoTile.tsx @@ -1,7 +1,7 @@ import iconService from 'app/drive/services/icon.service'; import { memo, useRef } from 'react'; import { useIsNearViewport } from '../hooks/useIsNearViewport'; -import { usePhotoThumbnail } from '../hooks/usePhotoThumbnail'; +import { PhotoThumbnailState, usePhotoThumbnail } from '../hooks/usePhotoThumbnail'; import { Photo } from '../types'; const THUMBNAIL_PRELOAD_MARGIN = '400px'; @@ -10,21 +10,27 @@ 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 tileRef = useRef(null); const isNearViewport = useIsNearViewport(tileRef, THUMBNAIL_PRELOAD_MARGIN); const thumbnail = usePhotoThumbnail(photo, isNearViewport); - const FileIcon = iconService.getItemIcon(false, photo.type); - - const content = { - loading:
, - unavailable: ( -
- -
- ), - loaded: {photo.name}, - }; return (
{ aria-label={photo.name} className="aspect-square overflow-hidden rounded bg-gray-5 dark:bg-gray-10" > - {content[thumbnail.status]} + {renderTileContent(photo, thumbnail)}
); }; diff --git a/src/views/Photos/hooks/usePhotoThumbnail.ts b/src/views/Photos/hooks/usePhotoThumbnail.ts index a5c50a342d..0790ce58a6 100644 --- a/src/views/Photos/hooks/usePhotoThumbnail.ts +++ b/src/views/Photos/hooks/usePhotoThumbnail.ts @@ -3,29 +3,28 @@ import errorService from 'services/error.service'; import { PhotoThumbnailsService } from '../services/photoThumbnails.service'; import { Photo, PhotoThumbnail } from '../types'; -type PhotoThumbnailState = { status: 'loading' } | { status: 'unavailable' } | { status: 'loaded'; blob: Blob }; +export type PhotoThumbnailState = { status: 'loading' } | { status: 'unavailable' } | { status: 'loaded'; url: string }; -export const usePhotoThumbnail = (photo: Photo, isNearViewport: boolean) => { - const [state, setState] = useState( +export const usePhotoThumbnail = (photo: Photo, isNearViewport: boolean): PhotoThumbnailState => { + const [thumbnail, setThumbnail] = useState( photo.thumbnail ? { status: 'loading' } : { status: 'unavailable' }, ); - const [thumbnailUrl, setThumbnailUrl] = useState(); - const shouldLoad = isNearViewport && state.status === 'loading'; + const shouldLoad = isNearViewport && thumbnail.status === 'loading'; useEffect(() => { if (!shouldLoad || !photo.thumbnail) return; const abortController = new AbortController(); - const loadThumbnail = async (thumbnail: PhotoThumbnail) => { + const loadThumbnail = async (photoThumbnail: PhotoThumbnail) => { try { - const blob = await PhotoThumbnailsService.instance.getThumbnail(photo, thumbnail, abortController); + const blob = await PhotoThumbnailsService.instance.getThumbnail(photo, photoThumbnail, abortController); if (abortController.signal.aborted) return; - setState({ status: 'loaded', blob }); + setThumbnail({ status: 'loaded', url: URL.createObjectURL(blob) }); } catch (error) { if (abortController.signal.aborted) return; errorService.reportError(error); - setState({ status: 'unavailable' }); + setThumbnail({ status: 'unavailable' }); } }; @@ -35,13 +34,10 @@ export const usePhotoThumbnail = (photo: Photo, isNearViewport: boolean) => { }, [photo, shouldLoad]); useEffect(() => { - if (state.status !== 'loaded') return; + if (thumbnail.status !== 'loaded') return; - const blobUrl = URL.createObjectURL(state.blob); - setThumbnailUrl(blobUrl); + return () => URL.revokeObjectURL(thumbnail.url); + }, [thumbnail]); - return () => URL.revokeObjectURL(blobUrl); - }, [state]); - - return { status: state.status, url: thumbnailUrl }; + return thumbnail; }; diff --git a/src/views/Photos/services/photoThumbnails.service.ts b/src/views/Photos/services/photoThumbnails.service.ts index 91611191ad..09a0c0c1ec 100644 --- a/src/views/Photos/services/photoThumbnails.service.ts +++ b/src/views/Photos/services/photoThumbnails.service.ts @@ -1,5 +1,6 @@ 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 { @@ -7,15 +8,16 @@ export class PhotoThumbnailsService { async getThumbnail(photo: Photo, thumbnail: PhotoThumbnail, abortController: AbortController): Promise { const cachedPreview = await getDatabaseFilePreviewData({ fileId: photo.id }); - if (cachedPreview?.preview) return cachedPreview.preview; + const isCachedPreviewUpToDate = cachedPreview?.updatedAt === photo.updatedAt; + if (cachedPreview?.preview && isCachedPreviewUpToDate) return cachedPreview.preview; const thumbnailBlob = await downloadThumbnail(thumbnail, false, abortController); - void updateDatabaseFilePreviewData({ + updateDatabaseFilePreviewData({ fileId: photo.id, folderId: photo.folderId, previewBlob: thumbnailBlob, updatedAt: photo.updatedAt, - }); + }).catch(errorService.reportError); return thumbnailBlob; }