Skip to content
Closed
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
11 changes: 11 additions & 0 deletions src/app/drive/services/thumbnail.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
7 changes: 5 additions & 2 deletions src/app/drive/services/thumbnail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,14 @@ export const generateThumbnailFromFile = async (
return null;
};

export const downloadThumbnail = async (thumbnailToDownload: Thumbnail, isWorkspace: boolean): Promise<Blob> => {
export const downloadThumbnail = async (
thumbnailToDownload: Pick<Thumbnail, 'bucket_id' | 'bucket_file'>,
isWorkspace: boolean,
abortController = new AbortController(),
): Promise<Blob> => {
const updateProgressCallback = () => {
return;
};
const abortController = new AbortController();

let useWorkspaceCredentials = isWorkspace;

Expand Down
17 changes: 17 additions & 0 deletions src/testUtils/fixtures/photos.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhotoFolderFile['thumbnails']>[number];

export const getSdkPhotoDevice = (overrides: Partial<SdkPhotoDevice> = {}): SdkPhotoDevice => ({
uuid: 'iphone-uuid',
Expand Down Expand Up @@ -56,3 +57,19 @@ export const getPhotoFolderFile = (overrides: Partial<PhotoFolderFile> = {}): Ph
thumbnails: [],
...overrides,
}) as unknown as PhotoFolderFile;

export const getPhotoThumbnail = (overrides: Partial<PhotoFolderFileThumbnail> = {}): 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,
});
122 changes: 120 additions & 2 deletions src/views/Photos/PhotosView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand All @@ -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<typeof import('app/drive/services/database.service')>()),
getDatabaseFilePreviewData: vi.fn(),
updateDatabaseFilePreviewData: vi.fn(),
}));

type Folder = ReturnType<typeof getPhotoFolder>;

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<PhotosView />);
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(<PhotosView />);
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(<PhotosView />);
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(<PhotosView />);
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(<PhotosView />);
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();
});
});
});
32 changes: 28 additions & 4 deletions src/views/Photos/components/PhotoTile.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="h-full w-full animate-pulse bg-gray-10 dark:bg-gray-20" />;
case 'loaded':
return <img src={thumbnail.url} alt={photo.name} draggable={false} className="h-full w-full object-cover" />;
case 'unavailable': {
const FileIcon = iconService.getItemIcon(false, photo.type);
return (
<div className="flex h-full w-full items-center justify-center">
<FileIcon className="h-16 w-16 drop-shadow-soft" />
</div>
);
}
}
};

const PhotoTile = ({ photo }: PhotoTileProps): JSX.Element => {
const FileIcon = iconService.getItemIcon(false, photo.type);
const tileRef = useRef<HTMLDivElement>(null);
const isNearViewport = useIsNearViewport(tileRef, THUMBNAIL_PRELOAD_MARGIN);
const thumbnail = usePhotoThumbnail(photo, isNearViewport);

return (
<div
ref={tileRef}
role="img"
aria-label={photo.name}
className="flex aspect-square items-center justify-center overflow-hidden rounded bg-gray-5 dark:bg-gray-10"
className="aspect-square overflow-hidden rounded bg-gray-5 dark:bg-gray-10"
>
<FileIcon className="h-16 w-16 drop-shadow-soft" />
{renderTileContent(photo, thumbnail)}
</div>
);
};
Expand Down
19 changes: 19 additions & 0 deletions src/views/Photos/hooks/useIsNearViewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { RefObject, useEffect, useState } from 'react';

export const useIsNearViewport = (elementRef: RefObject<Element>, 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;
};
43 changes: 43 additions & 0 deletions src/views/Photos/hooks/usePhotoThumbnail.ts
Original file line number Diff line number Diff line change
@@ -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<PhotoThumbnailState>(
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(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thumbnail state is split across two useState + two effects (blob, then derived URL), adding an extra render per load. But PublicSharedItemList.tsx's equivalent creates the object URL directly on download resolution 🤔

if (thumbnail.status !== 'loaded') return;

return () => URL.revokeObjectURL(thumbnail.url);
}, [thumbnail]);

return thumbnail;
};
30 changes: 29 additions & 1 deletion src/views/Photos/services/photoFolders.service.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down
Loading
Loading