From 37ccc834de8fbe657c962115dad79dadba853987 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 3 Nov 2026 16:33:56 +0100 Subject: [PATCH 1/3] feat(photos): filter the photos by month --- src/app/i18n/locales/de.json | 3 +- src/app/i18n/locales/en.json | 3 +- src/app/i18n/locales/es.json | 3 +- src/app/i18n/locales/fr.json | 3 +- src/app/i18n/locales/it.json | 3 +- src/app/i18n/locales/pt.json | 3 +- src/app/i18n/locales/ru.json | 3 +- src/app/i18n/locales/tw.json | 3 +- src/app/i18n/locales/zh.json | 3 +- src/testUtils/fixtures/photos.fixtures.ts | 35 ++++ src/views/Photos/PhotosView.test.tsx | 158 ++++++++++++++++-- src/views/Photos/PhotosView.tsx | 37 ++-- src/views/Photos/components/PhotosFilters.tsx | 35 ++++ src/views/Photos/components/PhotosLibrary.tsx | 15 ++ .../Photos/components/PhotosMonthFilter.tsx | 28 ++++ src/views/Photos/hooks/usePhotoDevices.ts | 31 ---- src/views/Photos/hooks/usePhotoFilters.ts | 30 ++++ src/views/Photos/hooks/usePhotoLibrary.ts | 35 ++++ .../services/photoFolders.service.test.ts | 83 +++++++++ .../Photos/services/photoFolders.service.ts | 50 ++++++ src/views/Photos/types.ts | 8 + 21 files changed, 491 insertions(+), 81 deletions(-) create mode 100644 src/testUtils/fixtures/photos.fixtures.ts create mode 100644 src/views/Photos/components/PhotosFilters.tsx create mode 100644 src/views/Photos/components/PhotosLibrary.tsx create mode 100644 src/views/Photos/components/PhotosMonthFilter.tsx delete mode 100644 src/views/Photos/hooks/usePhotoDevices.ts create mode 100644 src/views/Photos/hooks/usePhotoFilters.ts create mode 100644 src/views/Photos/hooks/usePhotoLibrary.ts create mode 100644 src/views/Photos/services/photoFolders.service.test.ts create mode 100644 src/views/Photos/services/photoFolders.service.ts diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 41178d24a..6af3e699e 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -468,7 +468,8 @@ "retry": "Erneut versuchen" }, "filters": { - "allDevices": "Alle Geräte" + "allDevices": "Alle Geräte", + "allDates": "Alle Daten" } }, "favorites": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 38f1ef5d7..a134464f4 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -489,7 +489,8 @@ "retry": "Try again" }, "filters": { - "allDevices": "All devices" + "allDevices": "All devices", + "allDates": "All dates" } }, "favorites": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 80d879403..a137ca2c5 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -468,7 +468,8 @@ "retry": "Reintentar" }, "filters": { - "allDevices": "Todos los dispositivos" + "allDevices": "Todos los dispositivos", + "allDates": "Todas las fechas" } }, "favorites": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 8ad7967e6..91868746c 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -468,7 +468,8 @@ "retry": "Réessayer" }, "filters": { - "allDevices": "Tous les appareils" + "allDevices": "Tous les appareils", + "allDates": "Toutes les dates" } }, "favorites": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index c5e71d952..24cb07308 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -489,7 +489,8 @@ "retry": "Riprova" }, "filters": { - "allDevices": "Tutti i dispositivi" + "allDevices": "Tutti i dispositivi", + "allDates": "Tutte le date" } }, "favorites": { diff --git a/src/app/i18n/locales/pt.json b/src/app/i18n/locales/pt.json index 440ee9100..ea9deb091 100644 --- a/src/app/i18n/locales/pt.json +++ b/src/app/i18n/locales/pt.json @@ -489,7 +489,8 @@ "retry": "Tentar novamente" }, "filters": { - "allDevices": "Todos os dispositivos" + "allDevices": "Todos os dispositivos", + "allDates": "Todas as datas" } }, "favorites": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index b4229cad9..5bb815293 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -468,7 +468,8 @@ "retry": "Повторить" }, "filters": { - "allDevices": "Все устройства" + "allDevices": "Все устройства", + "allDates": "Все даты" } }, "favorites": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 85f961306..50f5923c0 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -489,7 +489,8 @@ "retry": "重試" }, "filters": { - "allDevices": "所有裝置" + "allDevices": "所有裝置", + "allDates": "所有日期" } }, "favorites": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index e5c91f25b..4dc3748f9 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -489,7 +489,8 @@ "retry": "重试" }, "filters": { - "allDevices": "所有设备" + "allDevices": "所有设备", + "allDates": "所有日期" } }, "favorites": { diff --git a/src/testUtils/fixtures/photos.fixtures.ts b/src/testUtils/fixtures/photos.fixtures.ts new file mode 100644 index 000000000..b3aefb3e9 --- /dev/null +++ b/src/testUtils/fixtures/photos.fixtures.ts @@ -0,0 +1,35 @@ +import { PhotoDevice as SdkPhotoDevice } from '@internxt/sdk/dist/drive/photos'; +import { FetchFolderFoldersCursorResponse } from '@internxt/sdk/dist/drive/storage/types'; + +type Folder = FetchFolderFoldersCursorResponse['folders'][number]; + +export const getSdkPhotoDevice = (overrides: Partial = {}): SdkPhotoDevice => ({ + uuid: 'iphone-uuid', + plainName: 'iPhone', + bucket: 'photos-bucket', + status: 'EXISTS', + ...overrides, +}); + +export const getPhotoFolder = (overrides: Partial = {}): Folder => + ({ + type: 'folder', + id: 1, + parentId: 0, + parentUuid: 'parent-uuid', + name: 'encrypted-name', + bucket: 'photos-bucket', + userId: 1, + encryptVersion: '03-aes', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + uuid: 'folder-uuid', + plainName: 'folder', + size: 0, + creationTime: '2026-01-01T00:00:00.000Z', + modificationTime: '2026-01-01T00:00:00.000Z', + status: 'EXISTS', + removed: false, + deleted: false, + ...overrides, + }) as Folder; diff --git a/src/views/Photos/PhotosView.test.tsx b/src/views/Photos/PhotosView.test.tsx index b59e6df15..0155614f7 100644 --- a/src/views/Photos/PhotosView.test.tsx +++ b/src/views/Photos/PhotosView.test.tsx @@ -1,14 +1,22 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import errorService from 'services/error.service'; +import { PhotoDevice as SdkPhotoDevice } from '@internxt/sdk/dist/drive/photos'; +import { getPhotoFolder, getSdkPhotoDevice } from 'testUtils/fixtures/photos.fixtures'; import PhotosView from './PhotosView'; -const { photosClient } = vi.hoisted(() => ({ +const { photosClient, storageClient } = vi.hoisted(() => ({ photosClient: { listDevices: vi.fn() }, + storageClient: { getFolderFoldersByUuidWithCursor: vi.fn() }, })); vi.mock('app/core/factory/sdk', () => ({ - SdkFactory: { getNewApiInstance: () => ({ createPhotosClient: () => photosClient }) }, + SdkFactory: { + getNewApiInstance: () => ({ + createPhotosClient: () => photosClient, + createNewStorageClient: () => storageClient, + }), + }, })); vi.mock('app/i18n/provider/TranslationProvider', () => ({ useTranslationContext: () => ({ translate: (key: string) => key }), @@ -20,16 +28,37 @@ vi.mock('services/error.service', () => ({ default: { reportError: vi.fn() }, })); -const device = (uuid: string, status: 'EXISTS' | 'TRASHED' | 'DELETED') => ({ - uuid, - plainName: uuid, - bucket: 'photos-bucket', - status, -}); +type Folder = ReturnType; + +const givenDevices = (devices: SdkPhotoDevice[]) => photosClient.listDevices.mockResolvedValue(devices); + +const givenSubfolders = (subfoldersByFolder: Record) => { + storageClient.getFolderFoldersByUuidWithCursor.mockImplementation((folderUuid: string) => [ + Promise.resolve({ folders: subfoldersByFolder[folderUuid] ?? [], nextCursor: null }), + { cancel: vi.fn() }, + ]); +}; + +const iphone = getSdkPhotoDevice({ uuid: 'iphone', plainName: 'iPhone' }); +const pixel = getSdkPhotoDevice({ uuid: 'pixel', plainName: 'Pixel' }); + +const iphonePhotosOfNovember2026 = { + iphone: [getPhotoFolder({ uuid: 'iphone-2026', plainName: '2026' })], + 'iphone-2026': [getPhotoFolder({ uuid: 'iphone-2026-11', plainName: '11' })], +}; + +const pixelPhotosOfOctober2026 = { + pixel: [getPhotoFolder({ uuid: 'pixel-2026', plainName: '2026' })], + 'pixel-2026': [getPhotoFolder({ uuid: 'pixel-2026-10', plainName: '10' })], +}; + +const waitForPhotosToLoad = () => + waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument()); describe('Photos view', () => { beforeEach(() => { vi.clearAllMocks(); + givenSubfolders({}); }); test('When the devices are loading, then the photos grid placeholder is shown under the title', () => { @@ -59,19 +88,23 @@ describe('Photos view', () => { }); test('When every device was deleted, then the user is treated as having no devices', async () => { - photosClient.listDevices.mockResolvedValue([device('old-phone', 'TRASHED'), device('tablet', 'DELETED')]); + givenDevices([ + getSdkPhotoDevice({ uuid: 'old-phone', status: 'TRASHED' }), + getSdkPhotoDevice({ uuid: 'tablet', status: 'DELETED' }), + ]); render(); expect(await screen.findByText('views.photos.empty.title')).toBeInTheDocument(); }); - test('When the user has devices, then the empty state is not shown', async () => { - photosClient.listDevices.mockResolvedValue([device('iphone', 'EXISTS')]); + test('When the user has photos, then the empty state is not shown', async () => { + givenDevices([iphone]); + givenSubfolders(iphonePhotosOfNovember2026); render(); - await waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument()); + await waitForPhotosToLoad(); expect(screen.queryByText('views.photos.empty.title')).not.toBeInTheDocument(); expect(screen.queryByText('views.photos.error.title')).not.toBeInTheDocument(); }); @@ -93,16 +126,18 @@ describe('Photos view', () => { }; test('When the user has a single device, then there is no device filter', async () => { - photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS')]); + givenDevices([iphone]); + givenSubfolders(iphonePhotosOfNovember2026); render(); - await waitFor(() => expect(screen.queryByTestId('photos-grid-skeleton')).not.toBeInTheDocument()); + await waitForPhotosToLoad(); expect(screen.queryByRole('button', { name: /views.photos.filters.allDevices/ })).not.toBeInTheDocument(); }); test('When the user has several devices, then they can choose one of them', async () => { - photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS'), device('Pixel', 'EXISTS')]); + givenDevices([iphone, pixel]); + givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 }); render(); await openDeviceFilter(); @@ -119,7 +154,8 @@ describe('Photos view', () => { }); test('When the user chooses a device, then the filter shows it until they go back to all devices', async () => { - photosClient.listDevices.mockResolvedValue([device('iPhone', 'EXISTS'), device('Pixel', 'EXISTS')]); + givenDevices([iphone, pixel]); + givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 }); render(); await openDeviceFilter(); @@ -133,6 +169,94 @@ describe('Photos view', () => { }); }); + describe('Filtering by month', () => { + const openMonthFilter = async () => { + fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDates/ })); + }; + + const offeredMonths = async () => { + const monthMenu = await screen.findByRole('menu', { name: /views.photos.filters.allDates/ }); + return within(monthMenu) + .getAllByRole('menuitem') + .map((option) => option.textContent); + }; + + test('When the user has photos in several months, then the months are offered newest first', async () => { + givenDevices([iphone, pixel]); + givenSubfolders({ + iphone: [ + getPhotoFolder({ uuid: 'iphone-2025', plainName: '2025' }), + getPhotoFolder({ uuid: 'iphone-2026', plainName: '2026' }), + ], + 'iphone-2025': [getPhotoFolder({ uuid: 'iphone-2025-12', plainName: '12' })], + 'iphone-2026': [getPhotoFolder({ uuid: 'iphone-2026-11', plainName: '11' })], + pixel: [getPhotoFolder({ uuid: 'pixel-2026', plainName: '2026' })], + 'pixel-2026': [ + getPhotoFolder({ uuid: 'pixel-2026-11', plainName: '11' }), + getPhotoFolder({ uuid: 'pixel-2026-01', plainName: '01' }), + ], + }); + + render(); + await openMonthFilter(); + + expect(await offeredMonths()).toStrictEqual([ + 'views.photos.filters.allDates', + 'November 2026', + 'January 2026', + 'December 2025', + ]); + }); + + test('When a device is chosen, then only the months with photos on that device are offered', async () => { + givenDevices([iphone, pixel]); + givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 }); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ })); + fireEvent.click(await screen.findByRole('menuitem', { name: /Pixel/ })); + await openMonthFilter(); + + expect(await offeredMonths()).toStrictEqual(['views.photos.filters.allDates', 'October 2026']); + }); + + test('When the chosen month has no photos on the newly chosen device, then all dates are shown again', async () => { + givenDevices([iphone, pixel]); + givenSubfolders({ ...iphonePhotosOfNovember2026, ...pixelPhotosOfOctober2026 }); + + render(); + await openMonthFilter(); + fireEvent.click(await screen.findByRole('menuitem', { name: /November 2026/ })); + fireEvent.click(await screen.findByRole('button', { name: /views.photos.filters.allDevices/ })); + fireEvent.click(await screen.findByRole('menuitem', { name: /Pixel/ })); + + expect(await screen.findByRole('button', { name: /views.photos.filters.allDates/ })).toBeInTheDocument(); + }); + + test('When the devices have no photos yet, then the empty state is shown without filters', async () => { + givenDevices([iphone]); + + render(); + + expect(await screen.findByText('views.photos.empty.title')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /views.photos.filters.allDates/ })).not.toBeInTheDocument(); + }); + + test('When the months cannot be found, then the user can try again', async () => { + givenDevices([iphone]); + givenSubfolders(iphonePhotosOfNovember2026); + storageClient.getFolderFoldersByUuidWithCursor.mockImplementationOnce(() => [ + Promise.reject(new Error('Network error')), + { cancel: vi.fn() }, + ]); + + render(); + fireEvent.click(await screen.findByRole('button', { name: /views.photos.error.retry/ })); + + expect(await screen.findByRole('button', { name: /views.photos.filters.allDates/ })).toBeInTheDocument(); + }); + }); + test('When the devices cannot be loaded, then the error is reported and no empty state is shown', async () => { const networkError = new Error('Network error'); photosClient.listDevices.mockRejectedValue(networkError); diff --git a/src/views/Photos/PhotosView.tsx b/src/views/Photos/PhotosView.tsx index 4239de437..08ffcf123 100644 --- a/src/views/Photos/PhotosView.tsx +++ b/src/views/Photos/PhotosView.tsx @@ -1,39 +1,28 @@ -import { useState } from 'react'; -import PhotosDeviceFilter from './components/PhotosDeviceFilter'; -import PhotosEmptyState from './components/PhotosEmptyState'; import PhotosErrorState from './components/PhotosErrorState'; +import PhotosFilters from './components/PhotosFilters'; import PhotosGridSkeleton from './components/PhotosGridSkeleton'; import PhotosLayout from './components/PhotosLayout'; -import { usePhotoDevices } from './hooks/usePhotoDevices'; +import PhotosLibrary from './components/PhotosLibrary'; +import { usePhotoFilters } from './hooks/usePhotoFilters'; +import { usePhotoLibrary } from './hooks/usePhotoLibrary'; +import { PhotoDevice, PhotoMonth } from './types'; -const PhotosView = (): JSX.Element => { - const { state, retry } = usePhotoDevices(); - const [selectedDeviceUuid, setSelectedDeviceUuid] = useState(null); +const NO_DEVICES: PhotoDevice[] = []; +const NO_MONTHS: PhotoMonth[] = []; - const devices = state.status === 'loaded' ? state.devices : []; - const hasSeveralDevices = devices.length > 1; +const PhotosView = (): JSX.Element => { + const { state, retry } = usePhotoLibrary(); + const devices = state.status === 'loaded' ? state.devices : NO_DEVICES; + const filters = usePhotoFilters(state.status === 'loaded' ? state.months : NO_MONTHS); const content = { loading: , error: , - // TODO: show the photos of the chosen devices when there are devices, once the gallery is added - loaded: devices.length === 0 && , + loaded: , }; return ( - - ) - } - > - {content[state.status]} - + }>{content[state.status]} ); }; diff --git a/src/views/Photos/components/PhotosFilters.tsx b/src/views/Photos/components/PhotosFilters.tsx new file mode 100644 index 000000000..aab0c2b6e --- /dev/null +++ b/src/views/Photos/components/PhotosFilters.tsx @@ -0,0 +1,35 @@ +import { PhotoFilters } from '../hooks/usePhotoFilters'; +import { PhotoDevice } from '../types'; +import PhotosDeviceFilter from './PhotosDeviceFilter'; +import PhotosMonthFilter from './PhotosMonthFilter'; + +interface PhotosFiltersProps { + devices: PhotoDevice[]; + filters: PhotoFilters; +} + +const PhotosFilters = ({ devices, filters }: PhotosFiltersProps): JSX.Element => { + const hasSeveralDevices = devices.length > 1; + const hasMonths = filters.availableMonths.length > 0; + + return ( + <> + {hasSeveralDevices && ( + + )} + {hasMonths && ( + + )} + + ); +}; + +export default PhotosFilters; diff --git a/src/views/Photos/components/PhotosLibrary.tsx b/src/views/Photos/components/PhotosLibrary.tsx new file mode 100644 index 000000000..71838698a --- /dev/null +++ b/src/views/Photos/components/PhotosLibrary.tsx @@ -0,0 +1,15 @@ +import { MonthKey } from '../types'; +import PhotosEmptyState from './PhotosEmptyState'; + +interface PhotosLibraryProps { + months: MonthKey[]; +} + +const PhotosLibrary = ({ months }: PhotosLibraryProps): JSX.Element | null => { + const hasPhotos = months.length > 0; + + if (!hasPhotos) return ; + return null; +}; + +export default PhotosLibrary; diff --git a/src/views/Photos/components/PhotosMonthFilter.tsx b/src/views/Photos/components/PhotosMonthFilter.tsx new file mode 100644 index 000000000..884589719 --- /dev/null +++ b/src/views/Photos/components/PhotosMonthFilter.tsx @@ -0,0 +1,28 @@ +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import dateService from 'services/date.service'; +import { MonthKey } from '../types'; +import PhotosFilterMenu from './PhotosFilterMenu'; + +interface PhotosMonthFilterProps { + months: MonthKey[]; + selectedMonth: MonthKey | null; + onSelect: (month: MonthKey | null) => void; +} + +const PhotosMonthFilter = ({ months, selectedMonth, onSelect }: PhotosMonthFilterProps): JSX.Element => { + const { translate } = useTranslationContext(); + + return ( + ({ + id: month, + label: dateService.format(month, 'MMMM YYYY'), + }))} + selectedId={selectedMonth} + onSelect={onSelect} + /> + ); +}; + +export default PhotosMonthFilter; diff --git a/src/views/Photos/hooks/usePhotoDevices.ts b/src/views/Photos/hooks/usePhotoDevices.ts deleted file mode 100644 index 20b2ab943..000000000 --- a/src/views/Photos/hooks/usePhotoDevices.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import errorService from 'services/error.service'; -import photosDevicesService from '../services/photosDevices.service'; -import { PhotoDevice } from '../types'; - -export type PhotoDevicesState = - | { status: 'loading' } - | { status: 'error' } - | { status: 'loaded'; devices: PhotoDevice[] }; - -export const usePhotoDevices = () => { - const [state, setState] = useState({ status: 'loading' }); - - const loadDevices = useCallback(async () => { - setState({ status: 'loading' }); - - try { - const devices = await photosDevicesService.listDevices(); - setState({ status: 'loaded', devices }); - } catch (error) { - errorService.reportError(error); - setState({ status: 'error' }); - } - }, []); - - useEffect(() => { - void loadDevices(); - }, [loadDevices]); - - return { state, retry: loadDevices }; -}; diff --git a/src/views/Photos/hooks/usePhotoFilters.ts b/src/views/Photos/hooks/usePhotoFilters.ts new file mode 100644 index 000000000..d21363405 --- /dev/null +++ b/src/views/Photos/hooks/usePhotoFilters.ts @@ -0,0 +1,30 @@ +import { useMemo, useState } from 'react'; +import { MonthKey, PhotoMonth } from '../types'; + +export type PhotoFilters = ReturnType; + +export const usePhotoFilters = (months: PhotoMonth[]) => { + const [selectedDeviceUuid, selectDevice] = useState(null); + const [selectedMonth, selectMonth] = useState(null); + + const availableMonths = useMemo(() => { + const monthsOfSelectedDevices = selectedDeviceUuid + ? months.filter((month) => month.deviceUuid === selectedDeviceUuid) + : months; + const uniqueMonthKeys = new Set(monthsOfSelectedDevices.map((month) => month.key)); + + const monthsNewestFirst = [...uniqueMonthKeys].sort((month, otherMonth) => otherMonth.localeCompare(month)); + + return monthsNewestFirst; + }, [months, selectedDeviceUuid]); + + const isSelectedMonthAvailable = selectedMonth !== null && availableMonths.includes(selectedMonth); + + return { + selectedDeviceUuid, + selectDevice, + availableMonths, + selectedMonth: isSelectedMonthAvailable ? selectedMonth : null, + selectMonth, + }; +}; diff --git a/src/views/Photos/hooks/usePhotoLibrary.ts b/src/views/Photos/hooks/usePhotoLibrary.ts new file mode 100644 index 000000000..d24a041d5 --- /dev/null +++ b/src/views/Photos/hooks/usePhotoLibrary.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useState } from 'react'; +import errorService from 'services/error.service'; +import { PhotoFoldersService } from '../services/photoFolders.service'; +import photosDevicesService from '../services/photosDevices.service'; +import { PhotoDevice, PhotoMonth } from '../types'; + +export type PhotoLibraryState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'loaded'; devices: PhotoDevice[]; months: PhotoMonth[] }; + +export const usePhotoLibrary = () => { + const [state, setState] = useState({ status: 'loading' }); + + const loadLibrary = useCallback(async () => { + setState({ status: 'loading' }); + + try { + const devices = await photosDevicesService.listDevices(); + const monthsPerDevice = await Promise.all( + devices.map((device) => PhotoFoldersService.instance.getDeviceMonths(device.uuid)), + ); + setState({ status: 'loaded', devices, months: monthsPerDevice.flat() }); + } catch (error) { + errorService.reportError(error); + setState({ status: 'error' }); + } + }, []); + + useEffect(() => { + void loadLibrary(); + }, [loadLibrary]); + + return { state, retry: loadLibrary }; +}; diff --git a/src/views/Photos/services/photoFolders.service.test.ts b/src/views/Photos/services/photoFolders.service.test.ts new file mode 100644 index 000000000..4369a0f83 --- /dev/null +++ b/src/views/Photos/services/photoFolders.service.test.ts @@ -0,0 +1,83 @@ +import { SdkFactory } from 'app/core/factory/sdk'; +import { getPhotoFolder } from 'testUtils/fixtures/photos.fixtures'; +import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'; +import { PhotoFoldersService } from './photoFolders.service'; + +vi.mock('app/core/factory/sdk', () => ({ + SdkFactory: { getNewApiInstance: vi.fn() }, +})); + +describe('Photo backup folders', () => { + const storageClient = { getFolderFoldersByUuidWithCursor: vi.fn() }; + + const givenSubfolders = (subfoldersByFolder: Record[]>) => { + storageClient.getFolderFoldersByUuidWithCursor.mockImplementation((folderUuid: string) => [ + Promise.resolve({ folders: subfoldersByFolder[folderUuid] ?? [], nextCursor: null }), + { cancel: vi.fn() }, + ]); + }; + + beforeEach(() => { + vi.clearAllMocks(); + (SdkFactory.getNewApiInstance as Mock).mockReturnValue({ createNewStorageClient: () => storageClient }); + }); + + test('When a device has photos in several years, then its months are returned newest first', async () => { + givenSubfolders({ + iphone: [ + getPhotoFolder({ uuid: 'year-2025', plainName: '2025' }), + getPhotoFolder({ uuid: 'year-2026', plainName: '2026' }), + ], + 'year-2025': [getPhotoFolder({ uuid: 'december-2025', plainName: '12' })], + 'year-2026': [ + getPhotoFolder({ uuid: 'january-2026', plainName: '1' }), + getPhotoFolder({ uuid: 'november-2026', plainName: '11' }), + ], + }); + + const months = await PhotoFoldersService.instance.getDeviceMonths('iphone'); + + expect(months).toStrictEqual([ + { key: '2026-11', deviceUuid: 'iphone', folderUuid: 'november-2026' }, + { key: '2026-01', deviceUuid: 'iphone', folderUuid: 'january-2026' }, + { key: '2025-12', deviceUuid: 'iphone', folderUuid: 'december-2025' }, + ]); + }); + + test('When a device has folders that are not a year or a month, then they are ignored', async () => { + givenSubfolders({ + iphone: [ + getPhotoFolder({ uuid: 'sync', plainName: '.sync' }), + getPhotoFolder({ uuid: 'year-2026', plainName: '2026' }), + ], + 'year-2026': [ + getPhotoFolder({ uuid: 'november-2026', plainName: '11' }), + getPhotoFolder({ uuid: 'not-a-month', plainName: '13' }), + getPhotoFolder({ uuid: 'misc', plainName: 'misc' }), + ], + }); + + const months = await PhotoFoldersService.instance.getDeviceMonths('iphone'); + + expect(months).toStrictEqual([{ key: '2026-11', deviceUuid: 'iphone', folderUuid: 'november-2026' }]); + expect(storageClient.getFolderFoldersByUuidWithCursor).not.toHaveBeenCalledWith('sync', expect.anything()); + }); + + test('When the folders come in several pages, then the months of every page are returned', async () => { + storageClient.getFolderFoldersByUuidWithCursor.mockImplementation( + (folderUuid: string, { cursor }: { cursor?: string }) => { + const pages: Record[]; nextCursor: string | null }> = { + iphone: { folders: [getPhotoFolder({ uuid: 'year-2026', plainName: '2026' })], nextCursor: null }, + 'year-2026': cursor + ? { folders: [getPhotoFolder({ uuid: 'january-2026', plainName: '01' })], nextCursor: null } + : { folders: [getPhotoFolder({ uuid: 'february-2026', plainName: '02' })], nextCursor: 'next-page' }, + }; + return [Promise.resolve(pages[folderUuid]), { cancel: vi.fn() }]; + }, + ); + + const months = await PhotoFoldersService.instance.getDeviceMonths('iphone'); + + expect(months.map((month) => month.key)).toStrictEqual(['2026-02', '2026-01']); + }); +}); diff --git a/src/views/Photos/services/photoFolders.service.ts b/src/views/Photos/services/photoFolders.service.ts new file mode 100644 index 000000000..792fea230 --- /dev/null +++ b/src/views/Photos/services/photoFolders.service.ts @@ -0,0 +1,50 @@ +import { FetchFolderFoldersCursorResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { MonthKey, PhotoMonth } from '../types'; + +type Folder = FetchFolderFoldersCursorResponse['folders'][number]; + +const SUBFOLDERS_PAGE_SIZE = 50; +const YEAR_FOLDER_NAME = /^\d{4}$/; +const MONTH_FOLDER_NAME = /^(0?[1-9]|1[0-2])$/; + +export class PhotoFoldersService { + public static readonly instance: PhotoFoldersService = new PhotoFoldersService(); + + async getDeviceMonths(deviceUuid: string): Promise { + const yearFolders = await this.getSubfolders(deviceUuid, YEAR_FOLDER_NAME); + const monthsPerYear = await Promise.all( + yearFolders.map((yearFolder) => this.getYearMonths(deviceUuid, yearFolder)), + ); + + return monthsPerYear.flat().sort((month, otherMonth) => otherMonth.key.localeCompare(month.key)); + } + + private async getYearMonths(deviceUuid: string, yearFolder: Folder): Promise { + const monthFolders = await this.getSubfolders(yearFolder.uuid, MONTH_FOLDER_NAME); + + return monthFolders.map((monthFolder) => ({ + key: `${yearFolder.plainName}-${monthFolder.plainName.padStart(2, '0')}` as MonthKey, + deviceUuid, + folderUuid: monthFolder.uuid, + })); + } + + private async getSubfolders(folderUuid: string, namePattern: RegExp): Promise { + const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); + const subfolders: Folder[] = []; + let cursor: string | undefined; + + do { + const [pageRequest] = storageClient.getFolderFoldersByUuidWithCursor(folderUuid, { + limit: SUBFOLDERS_PAGE_SIZE, + cursor, + }); + const page = await pageRequest; + subfolders.push(...page.folders); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + return subfolders.filter((subfolder) => namePattern.test(subfolder.plainName)); + } +} diff --git a/src/views/Photos/types.ts b/src/views/Photos/types.ts index b03feead7..4129088f6 100644 --- a/src/views/Photos/types.ts +++ b/src/views/Photos/types.ts @@ -2,3 +2,11 @@ export interface PhotoDevice { uuid: string; name: string; } + +export type MonthKey = `${number}-${string}`; + +export interface PhotoMonth { + key: MonthKey; + deviceUuid: string; + folderUuid: string; +} From d5c2a88d4325bd0cd6b934f949d4552b0e26d787 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Tue, 3 Nov 2026 16:35:26 +0100 Subject: [PATCH 2/3] chore(deps): bump @internxt/sdk to 1.26.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 87059ed80..42e82550c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@iconscout/react-unicons": "^2.2.5", "@internxt/css-config": "1.1.1", "@internxt/lib": "=1.5.1", - "@internxt/sdk": "=1.25.0", + "@internxt/sdk": "=1.26.0", "@internxt/ui": "=0.1.21", "@marsidev/react-turnstile": "^1.6.1", "@phosphor-icons/react": "^2.1.10", diff --git a/yarn.lock b/yarn.lock index f07d6d39f..fcb0f139e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1939,10 +1939,10 @@ resolved "https://registry.yarnpkg.com/@internxt/prettier-config/-/prettier-config-2.0.1.tgz#a25e7a16f62a990aed2bd1804e04140cc8c33e77" integrity sha512-sCnOMX7GR7BYDJ5OH1RiV/JaOBLE3uhL6QN6tYhMKi7QXK/cnM/77qmDlIEBlhTNIFEMLFljSkc0vZEvf6m2xA== -"@internxt/sdk@=1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.25.0.tgz#3a66d7b8a8b5daa3bc1980c5820e9863933f083a" - integrity sha512-kpbqz7YmqheBBHJgReJ9dr189Qim312JyfhGw1M3D0m6xPLXFgPi8FEViND70+YnAdWC3CRVXrD0hH3JjM+ZrQ== +"@internxt/sdk@=1.26.0": + version "1.26.0" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.26.0.tgz#cd3a0df82d33346d8a06953e1d9d3e9bc245b111" + integrity sha512-7EwLUmfXFu/AlYLN6GUr6wP76Uz9WM91QSPmTKkB3v7QjsJonGvYTQIoqtY7r+h8+DylzdIwslYV7dLHqPcYWw== dependencies: axios "^1.20.0" From 0e318b0b04239bec27433b4cf35bbae9a02f6eb9 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Thu, 5 Nov 2026 12:01:28 +0100 Subject: [PATCH 3/3] refactor(photos): name state setters and page subfolders recursively --- src/views/Photos/hooks/usePhotoFilters.ts | 8 +++--- .../Photos/services/photoFolders.service.ts | 27 +++++++++---------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/views/Photos/hooks/usePhotoFilters.ts b/src/views/Photos/hooks/usePhotoFilters.ts index d21363405..6822a8fbf 100644 --- a/src/views/Photos/hooks/usePhotoFilters.ts +++ b/src/views/Photos/hooks/usePhotoFilters.ts @@ -4,8 +4,8 @@ import { MonthKey, PhotoMonth } from '../types'; export type PhotoFilters = ReturnType; export const usePhotoFilters = (months: PhotoMonth[]) => { - const [selectedDeviceUuid, selectDevice] = useState(null); - const [selectedMonth, selectMonth] = useState(null); + const [selectedDeviceUuid, setSelectedDeviceUuid] = useState(null); + const [selectedMonth, setSelectedMonth] = useState(null); const availableMonths = useMemo(() => { const monthsOfSelectedDevices = selectedDeviceUuid @@ -22,9 +22,9 @@ export const usePhotoFilters = (months: PhotoMonth[]) => { return { selectedDeviceUuid, - selectDevice, + selectDevice: setSelectedDeviceUuid, availableMonths, selectedMonth: isSelectedMonthAvailable ? selectedMonth : null, - selectMonth, + selectMonth: setSelectedMonth, }; }; diff --git a/src/views/Photos/services/photoFolders.service.ts b/src/views/Photos/services/photoFolders.service.ts index 792fea230..db73eb148 100644 --- a/src/views/Photos/services/photoFolders.service.ts +++ b/src/views/Photos/services/photoFolders.service.ts @@ -30,21 +30,18 @@ export class PhotoFoldersService { })); } - private async getSubfolders(folderUuid: string, namePattern: RegExp): Promise { + private async getSubfolders(folderUuid: string, namePattern: RegExp, cursor?: string): Promise { const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); - const subfolders: Folder[] = []; - let cursor: string | undefined; - - do { - const [pageRequest] = storageClient.getFolderFoldersByUuidWithCursor(folderUuid, { - limit: SUBFOLDERS_PAGE_SIZE, - cursor, - }); - const page = await pageRequest; - subfolders.push(...page.folders); - cursor = page.nextCursor ?? undefined; - } while (cursor); - - return subfolders.filter((subfolder) => namePattern.test(subfolder.plainName)); + const [pageRequest] = storageClient.getFolderFoldersByUuidWithCursor(folderUuid, { + limit: SUBFOLDERS_PAGE_SIZE, + cursor, + }); + const page = await pageRequest; + const matchingFolders = page.folders.filter((subfolder) => namePattern.test(subfolder.plainName)); + + if (!page.nextCursor) return matchingFolders; + + const nextFolders = await this.getSubfolders(folderUuid, namePattern, page.nextCursor); + return [...matchingFolders, ...nextFolders]; } }