From 95e8e474b8cc823c86f01df677dbe555c143e290 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 2 Feb 2026 11:12:08 -0500 Subject: [PATCH] fix(web): enable asset viewer navigation across memory boundaries (#25741) --- e2e/src/generators/memory.ts | 2 + e2e/src/generators/memory/model-objects.ts | 84 +++++ e2e/src/mock-network/memory-network.ts | 65 ++++ .../web/specs/memory/memory-viewer.ui-spec.ts | 289 ++++++++++++++++++ e2e/src/web/specs/memory/utils.ts | 123 ++++++++ .../memory-page/memory-viewer.svelte | 6 +- 6 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 e2e/src/generators/memory.ts create mode 100644 e2e/src/generators/memory/model-objects.ts create mode 100644 e2e/src/mock-network/memory-network.ts create mode 100644 e2e/src/web/specs/memory/memory-viewer.ui-spec.ts create mode 100644 e2e/src/web/specs/memory/utils.ts diff --git a/e2e/src/generators/memory.ts b/e2e/src/generators/memory.ts new file mode 100644 index 0000000000..c17b4aa476 --- /dev/null +++ b/e2e/src/generators/memory.ts @@ -0,0 +1,2 @@ +export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects'; +export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects'; diff --git a/e2e/src/generators/memory/model-objects.ts b/e2e/src/generators/memory/model-objects.ts new file mode 100644 index 0000000000..1bcc703ed8 --- /dev/null +++ b/e2e/src/generators/memory/model-objects.ts @@ -0,0 +1,84 @@ +import { faker } from '@faker-js/faker'; +import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { toAssetResponseDto } from 'src/generators/timeline/rest-response'; +import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config'; +import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils'; + +export type MemoryConfig = { + id?: string; + ownerId: string; + year: number; + memoryAt: string; + isSaved?: boolean; +}; + +export type MemoryYearConfig = { + year: number; + assetCount: number; +}; + +export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto { + const now = new Date().toISOString(); + const memoryId = config.id ?? faker.string.uuid(); + + return { + id: memoryId, + assets: assets.map((asset) => toAssetResponseDto(asset)), + data: { year: config.year } as OnThisDayDto, + memoryAt: config.memoryAt, + createdAt: now, + updatedAt: now, + isSaved: config.isSaved ?? false, + ownerId: config.ownerId, + type: MemoryType.OnThisDay, + }; +} + +export function generateMemoriesFromTimeline( + timelineAssets: MockTimelineAsset[], + ownerId: string, + memoryConfigs: MemoryYearConfig[], + seed: number = 42, +): MemoryResponseDto[] { + const rng = new SeededRandom(seed); + const memories: MemoryResponseDto[] = []; + const usedAssetIds = new Set(); + + for (const config of memoryConfigs) { + const yearAssets = timelineAssets.filter((asset) => { + const assetYear = DateTime.fromISO(asset.fileCreatedAt).year; + return assetYear === config.year && !usedAssetIds.has(asset.id); + }); + + if (yearAssets.length === 0) { + continue; + } + + const countToSelect = Math.min(config.assetCount, yearAssets.length); + const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng); + + for (const asset of selectedAssets) { + usedAssetIds.add(asset.id); + } + + selectedAssets.sort( + (a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds, + ); + + const memoryAt = DateTime.now().set({ year: config.year }).toISO()!; + + memories.push( + generateMemory( + { + ownerId, + year: config.year, + memoryAt, + }, + selectedAssets, + ), + ); + } + + return memories; +} diff --git a/e2e/src/mock-network/memory-network.ts b/e2e/src/mock-network/memory-network.ts new file mode 100644 index 0000000000..9a3a9e6555 --- /dev/null +++ b/e2e/src/mock-network/memory-network.ts @@ -0,0 +1,65 @@ +import type { MemoryResponseDto } from '@immich/sdk'; +import { BrowserContext } from '@playwright/test'; + +export type MemoryChanges = { + memoryDeletions: string[]; + assetRemovals: Map; +}; + +export const setupMemoryMockApiRoutes = async ( + context: BrowserContext, + memories: MemoryResponseDto[], + changes: MemoryChanges, +) => { + await context.route('**/api/memories*', async (route, request) => { + const url = new URL(request.url()); + const pathname = url.pathname; + + if (pathname === '/api/memories' && request.method() === 'GET') { + const activeMemories = memories + .filter((memory) => !changes.memoryDeletions.includes(memory.id)) + .map((memory) => { + const removedAssets = changes.assetRemovals.get(memory.id) ?? []; + return { + ...memory, + assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)), + }; + }) + .filter((memory) => memory.assets.length > 0); + + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: activeMemories, + }); + } + + const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/); + if (memoryMatch && request.method() === 'GET') { + const memoryId = memoryMatch[1]; + const memory = memories.find((m) => m.id === memoryId); + + if (!memory || changes.memoryDeletions.includes(memoryId)) { + return route.fulfill({ status: 404 }); + } + + const removedAssets = changes.assetRemovals.get(memoryId) ?? []; + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + ...memory, + assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)), + }, + }); + } + + if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') { + const memoryId = pathname.split('/').pop()!; + changes.memoryDeletions.push(memoryId); + return route.fulfill({ status: 204 }); + } + + await route.fallback(); + }); +}; diff --git a/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts new file mode 100644 index 0000000000..11e73fbe25 --- /dev/null +++ b/e2e/src/web/specs/memory/memory-viewer.ui-spec.ts @@ -0,0 +1,289 @@ +import { faker } from '@faker-js/faker'; +import type { MemoryResponseDto } from '@immich/sdk'; +import { test } from '@playwright/test'; +import { generateMemoriesFromTimeline } from 'src/generators/memory'; +import { + Changes, + createDefaultTimelineConfig, + generateTimelineData, + TimelineAssetConfig, + TimelineData, +} from 'src/generators/timeline'; +import { setupBaseMockApiRoutes } from 'src/mock-network/base-network'; +import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network'; +import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network'; +import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils'; + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => { + let adminUserId: string; + let timelineRestData: TimelineData; + let memories: MemoryResponseDto[]; + const assets: TimelineAssetConfig[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + const memoryChanges: MemoryChanges = { + memoryDeletions: [], + assetRemovals: new Map(), + }; + + test.beforeAll(async () => { + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + + timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: adminUserId, + }); + + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + + memories = generateMemoriesFromTimeline( + assets, + adminUserId, + [ + { year: 2024, assetCount: 3 }, + { year: 2023, assetCount: 2 }, + { year: 2022, assetCount: 4 }, + ], + 42, + ); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + await setupMemoryMockApiRoutes(context, memories, memoryChanges); + }); + + test.afterEach(() => { + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + memoryChanges.memoryDeletions = []; + memoryChanges.assetRemovals.clear(); + }); + + test.describe('Asset viewer navigation from gallery', () => { + test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => { + const firstMemory = memories[0]; + const middleAsset = firstMemory.assets[1]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id); + await memoryGalleryUtils.clickThumbnail(page, middleAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => { + const firstMemory = memories[0]; + const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory); + + await memoryAssetViewerUtils.expectNextButtonVisible(page); + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + }); + + test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => { + const lastMemory = memories.at(-1)!; + const firstAssetOfLastMemory = lastMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + + await memoryAssetViewerUtils.clickNextButton(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id); + }); + + test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await memoryAssetViewerUtils.clickPreviousButton(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + }); + + test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => { + const firstMemory = memories[0]; + const veryFirstAsset = firstMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id); + await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset); + + await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); + + test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => { + const lastMemory = memories.at(-1)!; + const veryLastAsset = lastMemory.assets.at(-1)!; + + await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id); + await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset); + + await memoryAssetViewerUtils.expectNextButtonNotVisible(page); + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + }); + }); + + test.describe('Keyboard navigation', () => { + test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id); + await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + + await page.keyboard.press('ArrowLeft'); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + }); + + test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => { + const firstMemory = memories[0]; + const secondMemory = memories[1]; + const lastAssetOfFirst = firstMemory.assets.at(-1)!; + const firstAssetOfSecond = secondMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id); + await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst); + + await page.keyboard.press('ArrowRight'); + await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond); + }); + }); +}); + +test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => { + let adminUserId: string; + let timelineRestData: TimelineData; + let memories: MemoryResponseDto[]; + const assets: TimelineAssetConfig[] = []; + const testContext = new TimelineTestContext(); + const changes: Changes = { + albumAdditions: [], + assetDeletions: [], + assetArchivals: [], + assetFavorites: [], + }; + const memoryChanges: MemoryChanges = { + memoryDeletions: [], + assetRemovals: new Map(), + }; + + test.beforeAll(async () => { + adminUserId = faker.string.uuid(); + testContext.adminId = adminUserId; + + timelineRestData = generateTimelineData({ + ...createDefaultTimelineConfig(), + ownerId: adminUserId, + }); + + for (const timeBucket of timelineRestData.buckets.values()) { + assets.push(...timeBucket); + } + + memories = generateMemoriesFromTimeline( + assets, + adminUserId, + [ + { year: 2024, assetCount: 2 }, + { year: 2023, assetCount: 1 }, + { year: 2022, assetCount: 2 }, + ], + 123, + ); + }); + + test.beforeEach(async ({ context }) => { + await setupBaseMockApiRoutes(context, adminUserId); + await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext); + await setupMemoryMockApiRoutes(context, memories, memoryChanges); + }); + + test.afterEach(() => { + testContext.slowBucket = false; + changes.albumAdditions = []; + changes.assetDeletions = []; + changes.assetArchivals = []; + changes.assetFavorites = []; + memoryChanges.memoryDeletions = []; + memoryChanges.assetRemovals.clear(); + }); + + test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => { + const singleAssetMemory = memories[1]; + const singleAsset = singleAssetMemory.assets[0]; + + await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id); + await memoryGalleryUtils.clickThumbnail(page, singleAsset.id); + + await memoryAssetViewerUtils.waitForViewerOpen(page); + await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset); + + await memoryAssetViewerUtils.expectPreviousButtonVisible(page); + await memoryAssetViewerUtils.expectNextButtonVisible(page); + }); +}); diff --git a/e2e/src/web/specs/memory/utils.ts b/e2e/src/web/specs/memory/utils.ts new file mode 100644 index 0000000000..cf99033e7e --- /dev/null +++ b/e2e/src/web/specs/memory/utils.ts @@ -0,0 +1,123 @@ +import type { AssetResponseDto } from '@immich/sdk'; +import { expect, Page } from '@playwright/test'; + +function getAssetIdFromUrl(url: URL): string | null { + const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/); + if (pathMatch) { + return pathMatch[1]; + } + return url.searchParams.get('id'); +} + +export const memoryViewerUtils = { + locator(page: Page) { + return page.locator('#memory-viewer'); + }, + + async waitForMemoryLoad(page: Page) { + await expect(this.locator(page)).toBeVisible(); + await expect(page.locator('#memory-viewer img').first()).toBeVisible(); + }, + + async openMemoryPage(page: Page) { + await page.goto('/memory'); + await this.waitForMemoryLoad(page); + }, + + async openMemoryPageWithAsset(page: Page, assetId: string) { + await page.goto(`/memory?id=${assetId}`); + await this.waitForMemoryLoad(page); + }, +}; + +export const memoryGalleryUtils = { + locator(page: Page) { + return page.locator('#gallery-memory'); + }, + + thumbnailWithAssetId(page: Page, assetId: string) { + return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`); + }, + + async scrollToGallery(page: Page) { + const showGalleryButton = page.getByLabel('Show gallery'); + if (await showGalleryButton.isVisible()) { + await showGalleryButton.click(); + } + await expect(this.locator(page)).toBeInViewport(); + }, + + async clickThumbnail(page: Page, assetId: string) { + await this.scrollToGallery(page); + await this.thumbnailWithAssetId(page, assetId).click(); + }, + + async getAllThumbnails(page: Page) { + await this.scrollToGallery(page); + return page.locator('#gallery-memory [data-thumbnail-focus-container]'); + }, +}; + +export const memoryAssetViewerUtils = { + locator(page: Page) { + return page.locator('#immich-asset-viewer'); + }, + + async waitForViewerOpen(page: Page) { + await expect(this.locator(page)).toBeVisible(); + }, + + async waitForAssetLoad(page: Page, asset: AssetResponseDto) { + const viewer = this.locator(page); + const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`); + const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`); + + await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 }); + }, + + nextButton(page: Page) { + return page.getByLabel('View next asset'); + }, + + previousButton(page: Page) { + return page.getByLabel('View previous asset'); + }, + + async expectNextButtonVisible(page: Page) { + await expect(this.nextButton(page)).toBeVisible(); + }, + + async expectNextButtonNotVisible(page: Page) { + await expect(this.nextButton(page)).toHaveCount(0); + }, + + async expectPreviousButtonVisible(page: Page) { + await expect(this.previousButton(page)).toBeVisible(); + }, + + async expectPreviousButtonNotVisible(page: Page) { + await expect(this.previousButton(page)).toHaveCount(0); + }, + + async clickNextButton(page: Page) { + await this.nextButton(page).click(); + }, + + async clickPreviousButton(page: Page) { + await this.previousButton(page).click(); + }, + + async closeViewer(page: Page) { + await page.keyboard.press('Escape'); + await expect(this.locator(page)).not.toBeVisible(); + }, + + getCurrentAssetId(page: Page): string | null { + const url = new URL(page.url()); + return getAssetIdFromUrl(url); + }, + + async expectCurrentAssetId(page: Page, expectedAssetId: string) { + await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId); + }, +}; diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 3c7ec4b0db..304b5b278e 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -68,7 +68,11 @@ let currentMemoryAssetFull = $derived.by(async () => current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, ); - let currentTimelineAssets = $derived(current?.memory.assets || []); + let currentTimelineAssets = $derived([ + ...(current?.previousMemory?.assets ?? []), + ...(current?.memory.assets ?? []), + ...(current?.nextMemory?.assets ?? []), + ]); let isSaved = $derived(current?.memory.isSaved); let viewerHeight = $state(0);