From 6e78d6e131bafb6e9c59fa36333c6d43bef563af Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 28 May 2026 14:11:15 -0700 Subject: [PATCH 01/31] wip web memories view --- web/src/lib/managers/memory-manager.svelte.ts | 44 +++++++++- web/src/lib/utils.ts | 11 ++- web/src/routes/(user)/explore/+page.svelte | 35 ++++++-- web/src/routes/(user)/explore/+page.ts | 16 +++- .../[[assetId=id]]/+page.svelte | 80 ++++++++++++++++++- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 15 +++- .../[[assetId=id]]/MemoryViewer.svelte | 11 ++- .../(user)/photos/[[assetId=id]]/+page.svelte | 6 ++ 8 files changed, 195 insertions(+), 23 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 25f998ec82..d617364caa 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -1,9 +1,16 @@ -import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk'; +import { + deleteMemory, + type MemoryResponseDto, + removeMemoryAssets, + searchMemories, + updateMemory, + MemorySearchOrder, + MemoryType, +} from '@immich/sdk'; import { DateTime } from 'luxon'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { asLocalTimeISO } from '$lib/utils/date-time'; import { toTimelineAsset } from '$lib/utils/timeline-util'; type MemoryIndex = { @@ -22,8 +29,21 @@ export type MemoryAsset = MemoryIndex & { class MemoryManager { #loading: Promise | undefined; + #filters: + | { + $for?: string; + isSaved?: boolean; + isTrashed?: boolean; + order?: MemorySearchOrder; + page?: number; + size?: number; + $type?: MemoryType; + } + | undefined; constructor() { + this.#filters = undefined; + eventManager.on({ AuthLogout: () => this.clearCache(), AuthUserLoaded: () => this.initialize(), @@ -37,6 +57,20 @@ class MemoryManager { this.scheduleHourlyRefresh(); } + get filters() { + return this.#filters; + } + + set filters(filters) { + this.#filters = filters; + this.clearCache(); + if (this.#loading === undefined) { + this.#loading = this.load(); + } else { + void this.#loading.then(() => (this.#loading = this.load())); + } + } + ready() { return this.initialize(); } @@ -131,8 +165,10 @@ class MemoryManager { } private async load() { - const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); - this.memories = memories.filter((memory) => memory.assets.length > 0); + if (this.#filters !== undefined) { + const memories = await searchMemories(this.#filters); + this.memories = memories.filter((memory) => memory.assets.length > 0); + } } private scheduleHourlyRefresh() { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 397e32e136..f0c2270532 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -20,12 +20,13 @@ import { type UserResponseDto, } from '@immich/sdk'; import { toastManager, type ActionItem, type IfLike } from '@immich/ui'; +import { DateTime } from 'luxon'; import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; import { defaultLang, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; -import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store'; +import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { langs } from '$lib/utils/i18n'; @@ -358,9 +359,13 @@ export const handlePromiseError = (promise: Promise): void => { export const memoryLaneTitle = derived(t, ($t) => { return (memory: MemoryResponseDto) => { - const now = new Date(); if (memory.type === MemoryType.OnThisDay) { - return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } }); + const now = new Date(); + const memoryDate = new Date(memory.memoryAt); + + return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth() + ? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } }) + : DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) }); } return $t('unknown'); diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index fa688d7e8b..f7d32affcc 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -6,10 +6,10 @@ import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { Route } from '$lib/route'; - import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils'; import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import { Icon } from '@immich/ui'; + import { Icon, ImageCarousel } from '@immich/ui'; import { mdiHeart } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -28,13 +28,22 @@ return targetField?.items || []; }; - let places = $derived(getFieldItems(data.items, 'exifInfo.city')); + let places = $derived(getFieldItems(data.explore, 'exifInfo.city')); let recents = $derived( - getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()), + getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()), + ); + let people = $state(data.people.people); + let memories = $derived( + data.memories.map((memory) => ({ + id: memory.id, + title: $memoryLaneTitle(memory), + href: Route.memories({ id: memory.assets[0].id }), + alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }), + src: getAssetMediaUrl({ id: memory.assets[0].id }), + })), ); - let people = $state(data.response.people); - let hasPeople = $derived(data.response.total > 0); + let hasPeople = $derived(data.people.total > 0); const onPersonThumbnailReady = ({ id }: { id: string }) => { for (const person of people) { @@ -124,6 +133,20 @@ {/if} + {#if memories.length > 0} +
+
+

{$t('memories')}

+ {$t('view_all')} +
+ +
+ {/if} + {#if recents.length > 0}
diff --git a/web/src/routes/(user)/explore/+page.ts b/web/src/routes/(user)/explore/+page.ts index 543c9ce707..7c2a7f4eaf 100644 --- a/web/src/routes/(user)/explore/+page.ts +++ b/web/src/routes/(user)/explore/+page.ts @@ -1,16 +1,24 @@ -import { getAllPeople, getExploreData } from '@immich/sdk'; +import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk'; +import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]); + memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc }; + + const [explore, people] = await Promise.all([ + getExploreData(), + getAllPeople({ withHidden: false }), + memoryManager.ready(), + ]); const $t = await getFormatter(); return { - items, - response, + explore, + people, + memories: memoryManager.memories, meta: { title: $t('explore'), }, diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index a4d8f34c48..6531b6684e 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,81 @@ - - +{#if page.url.searchParams.has(QueryParameter.ID)} + +{:else} + + {#snippet buttons()} +
+ +
+ {/snippet} + {#if memories.length > 0} +
+ {#each memories as memory (memory.id)} + + {#if memory.isSaved} +
+ +
+ {/if} + {$getAltText(toTimelineAsset(memory.assets[0]))} + + {$memoryLaneTitle(memory)} + +
+ {/each} +
+ {:else}{/if} +
+{/if} diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts index 784eeeb6bf..d6511c1da7 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,3 +1,6 @@ +import { isEqual } from 'lodash-es'; +import { QueryParameter } from '$lib/constants'; +import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; @@ -6,10 +9,20 @@ export const load = (async ({ url }) => { const user = await authenticate(url); const $t = await getFormatter(); + const filters = url.searchParams.get('favorites') === 'true' ? { isSaved: true } : {}; + if ( + !(url.searchParams.has(QueryParameter.ID) && memoryManager.memories.length > 0) && + !isEqual(memoryManager.filters, filters) + ) { + memoryManager.filters = filters; + await memoryManager.ready(); + } + return { user, + memories: memoryManager.memories, meta: { - title: $t('memory'), + title: $t('memories'), }, }; }) satisfies PageLoad; diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index ef671c399a..bed631b4f6 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -82,6 +82,7 @@ let progressBarController: Tween | undefined = $state(undefined); let videoPlayer: HTMLVideoElement | undefined = $state(); const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`; + let previousPage = $state(Route.memories()); const handleNavigate = async (asset?: { id: string }) => { if (assetViewerManager.isViewing) { @@ -106,7 +107,7 @@ const handlePreviousAsset = () => handleNavigate(current?.previous?.asset); const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); - const handleEscape = async () => goto(Route.photos()); + const handleEscape = async () => goto(previousPage); const handleSelectAll = () => assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []); @@ -249,7 +250,7 @@ const init = (target: Page | NavigationTarget | null) => { if (memoryManager.memories.length === 0) { - return handlePromiseError(goto(Route.photos())); + return handlePromiseError(goto(previousPage)); } current = loadFromParams(target); @@ -281,6 +282,10 @@ }; afterNavigate(({ from, to }) => { + if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) { + previousPage = from!.url.toString(); + } + memoryManager.ready().then( () => { let target; @@ -381,7 +386,7 @@ icon={mdiClose} aria-label={$t('close')} size="large" - onclick={() => goto(Route.photos())} + onclick={() => goto(previousPage)} />

{$memoryLaneTitle(current.memory)} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 44c1ac4e65..96a74158fb 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -33,12 +33,14 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; + import { asLocalTimeISO } from '$lib/utils/date-time'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; let timelineManager = $state() as TimelineManager; @@ -81,6 +83,10 @@ assetMultiSelectManager.clear(); }; + if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) { + memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) }; + } + const items = $derived( memoryManager.memories.map((memory) => ({ id: memory.id, From 3f1b8e1d9b6467bd0b7eb45536eceba0711815fb Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 28 May 2026 23:54:14 -0700 Subject: [PATCH 02/31] paginate searchMemories --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/memories_api.dart | 37 ++++-- mobile/openapi/lib/api_client.dart | 2 + .../lib/model/memory_search_response_dto.dart | 120 ++++++++++++++++++ open-api/immich-openapi-specs.json | 53 +++++++- packages/sdk/src/fetch-client.ts | 17 ++- server/src/controllers/memory.controller.ts | 3 +- server/src/dtos/memory.dto.ts | 10 ++ server/src/repositories/memory.repository.ts | 10 +- server/src/services/memory.service.spec.ts | 19 ++- server/src/services/memory.service.ts | 12 +- .../specs/services/memory.service.spec.ts | 12 +- web/src/lib/managers/memory-manager.svelte.ts | 50 ++++++-- .../[[assetId=id]]/+page.svelte | 37 +++++- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 1 - 16 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 mobile/openapi/lib/model/memory_search_response_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 23987073dd..0ffd076e74 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -450,6 +450,7 @@ Class | Method | HTTP request | Description - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemorySearchOrder](doc//MemorySearchOrder.md) + - [MemorySearchResponseDto](doc//MemorySearchResponseDto.md) - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d5a6f483dc..3305710053 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -195,6 +195,7 @@ part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_search_order.dart'; +part 'model/memory_search_response_dto.dart'; part 'model/memory_statistics_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 0cd96ac442..b6b769404c 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -261,11 +261,14 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { + Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories/statistics'; @@ -288,6 +291,9 @@ class MemoriesApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } if (size != null) { queryParams.addAll(_queryParams('', 'size', size)); } @@ -326,12 +332,15 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { - final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, ); + Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { + final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -428,11 +437,14 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { + Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories'; @@ -455,6 +467,9 @@ class MemoriesApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } if (size != null) { queryParams.addAll(_queryParams('', 'size', size)); } @@ -493,12 +508,15 @@ class MemoriesApi { /// /// * [MemorySearchOrder] order: /// + /// * [int] page: + /// Page number + /// /// * [int] size: /// Number of memories to return /// /// * [MemoryType] type: - Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async { - final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, ); + Future searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async { + final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -506,11 +524,8 @@ class MemoriesApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto; + } return null; } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a3c2369c1d..58503e0ca1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -436,6 +436,8 @@ class ApiClient { return MemoryResponseDto.fromJson(value); case 'MemorySearchOrder': return MemorySearchOrderTypeTransformer().decode(value); + case 'MemorySearchResponseDto': + return MemorySearchResponseDto.fromJson(value); case 'MemoryStatisticsResponseDto': return MemoryStatisticsResponseDto.fromJson(value); case 'MemoryType': diff --git a/mobile/openapi/lib/model/memory_search_response_dto.dart b/mobile/openapi/lib/model/memory_search_response_dto.dart new file mode 100644 index 0000000000..82e04564cf --- /dev/null +++ b/mobile/openapi/lib/model/memory_search_response_dto.dart @@ -0,0 +1,120 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MemorySearchResponseDto { + /// Returns a new [MemorySearchResponseDto] instance. + MemorySearchResponseDto({ + required this.hasNextPage, + this.items = const [], + required this.total, + }); + + /// Whether there are more pages + bool hasNextPage; + + List items; + + /// Total number of matching memories + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int total; + + @override + bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto && + other.hasNextPage == hasNextPage && + _deepEquality.equals(other.items, items) && + other.total == total; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (hasNextPage.hashCode) + + (items.hashCode) + + (total.hashCode); + + @override + String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]'; + + Map toJson() { + final json = {}; + json[r'hasNextPage'] = this.hasNextPage; + json[r'items'] = this.items; + json[r'total'] = this.total; + return json; + } + + /// Returns a new [MemorySearchResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MemorySearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemorySearchResponseDto"); + if (value is Map) { + final json = value.cast(); + + return MemorySearchResponseDto( + hasNextPage: mapValueOfType(json, r'hasNextPage')!, + items: MemoryResponseDto.listFromJson(json[r'items']), + total: mapValueOfType(json, r'total')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MemorySearchResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MemorySearchResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'hasNextPage', + 'items', + 'total', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9fda205b9a..74b11e5c8b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6363,6 +6363,17 @@ "$ref": "#/components/schemas/MemorySearchOrder" } }, + { + "name": "page", + "required": false, + "in": "query", + "description": "Page number", + "schema": { + "minimum": 1, + "maximum": 9007199254740991, + "type": "integer" + } + }, { "name": "size", "required": false, @@ -6388,10 +6399,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/MemoryResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/MemorySearchResponseDto" } } }, @@ -6532,6 +6540,17 @@ "$ref": "#/components/schemas/MemorySearchOrder" } }, + { + "name": "page", + "required": false, + "in": "query", + "description": "Page number", + "schema": { + "minimum": 1, + "maximum": 9007199254740991, + "type": "integer" + } + }, { "name": "size", "required": false, @@ -18807,6 +18826,32 @@ ], "type": "string" }, + "MemorySearchResponseDto": { + "properties": { + "hasNextPage": { + "description": "Whether there are more pages", + "type": "boolean" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MemoryResponseDto" + }, + "type": "array" + }, + "total": { + "description": "Total number of matching memories", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "hasNextPage", + "items", + "total" + ], + "type": "object" + }, "MemoryStatisticsResponseDto": { "properties": { "total": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3f328088ee..1ad39a0d54 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1316,6 +1316,13 @@ export type MemoryResponseDto = { /** Last update date */ updatedAt: string; }; +export type MemorySearchResponseDto = { + /** Whether there are more pages */ + hasNextPage: boolean; + items: MemoryResponseDto[]; + /** Total number of matching memories */ + total: number; +}; export type MemoryCreateDto = { /** Asset IDs to associate with memory */ assetIds?: string[]; @@ -4678,22 +4685,24 @@ export function reverseGeocode({ lat, lon }: { /** * Retrieve memories */ -export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: { +export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: MemoryResponseDto[]; + data: MemorySearchResponseDto; }>(`/memories${QS.query(QS.explode({ "for": $for, isSaved, isTrashed, order, + page, size, "type": $type }))}`, { @@ -4718,11 +4727,12 @@ export function createMemory({ memoryCreateDto }: { /** * Retrieve memories statistics */ -export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: { +export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: { $for?: string; isSaved?: boolean; isTrashed?: boolean; order?: MemorySearchOrder; + page?: number; size?: number; $type?: MemoryType; }, opts?: Oazapfts.RequestOpts) { @@ -4734,6 +4744,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ isSaved, isTrashed, order, + page, size, "type": $type }))}`, { diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index cbf86199bb..8de20a66c2 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -7,6 +7,7 @@ import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, + MemorySearchResponseDto, MemoryStatisticsResponseDto, MemoryUpdateDto, } from 'src/dtos/memory.dto'; @@ -28,7 +29,7 @@ export class MemoryController { 'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.search(auth, dto); } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index ce2e9fda6c..a0c8e192b4 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -14,6 +14,7 @@ const MemorySearchSchema = z isTrashed: stringToBool.optional().describe('Include trashed memories'), isSaved: stringToBool.optional().describe('Filter by saved status'), size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'), + page: z.coerce.number().int().min(1).optional().describe('Page number'), order: AssetOrderWithRandomSchema.optional(), }) .meta({ id: 'MemorySearchDto' }); @@ -75,11 +76,20 @@ const MemoryResponseSchema = z }) .meta({ id: 'MemoryResponseDto' }); +const MemorySearchResponseSchema = z + .object({ + total: z.int().min(0).describe('Total number of matching memories'), + items: z.array(MemoryResponseSchema), + hasNextPage: z.boolean().describe('Whether there are more pages'), + }) + .meta({ id: 'MemorySearchResponseDto' }); + export class MemorySearchDto extends createZodDto(MemorySearchSchema) {} export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {} export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {} export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {} export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {} +export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {} export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => { return { diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880..b95cb495ad 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -9,6 +9,7 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { IBulkAsset } from 'src/types'; +import { paginationHelper } from 'src/utils/pagination'; @Injectable() export class MemoryRepository implements IBulkAsset { @@ -57,8 +58,8 @@ export class MemoryRepository implements IBulkAsset { { params: [DummyValue.UUID, {}] }, { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, ) - search(ownerId: string, dto: MemorySearchDto) { - return this.searchBuilder(ownerId, dto) + async search(ownerId: string, dto: MemorySearchDto) { + const items = await this.searchBuilder(ownerId, dto) .select((eb) => jsonArrayFrom( eb @@ -89,8 +90,11 @@ export class MemoryRepository implements IBulkAsset { ? qb.orderBy(sql`RANDOM()`) : qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection), ) - .$if(dto.size !== undefined, (qb) => qb.limit(dto.size!)) + .$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1)) + .$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!)) .execute(); + + return paginationHelper(items, dto.size ?? items.length); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 9976189e39..cda4184e1f 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -34,21 +34,28 @@ describe(MemoryService.name, () => { const asset = AssetFactory.create(); const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); const memory2 = MemoryFactory.create({ ownerId: userId }); - mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); + mocks.memory.search.mockResolvedValue({ + items: [getForMemory(memory1), getForMemory(memory2)], + hasNextPage: false, + }); + mocks.memory.statistics.mockResolvedValue({ total: 2 }); - await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( - expect.arrayContaining([ + await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({ + items: expect.arrayContaining([ expect.objectContaining({ id: memory1.id, assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]), }), ]), - ); + hasNextPage: false, + total: 2, + }); }); it('should map empty result', async () => { - mocks.memory.search.mockResolvedValue([]); - await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); + mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.memory.statistics.mockResolvedValue({ total: 0 }); + await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 }); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index ac8f88ad87..0fef2c9b5c 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -71,10 +71,14 @@ export class MemoryService extends BaseService { } async search(auth: AuthDto, dto: MemorySearchDto) { - const memories = await this.memoryRepository.search(auth.user.id, dto); - return memories - .filter((memory: Memory) => memory.assets && memory.assets.length > 0) - .map((memory: Memory) => mapMemory(memory, auth)); + const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto); + const { total } = await this.memoryRepository.statistics(auth.user.id, dto); + + return { + total, + items: items.map((memory: Memory) => mapMemory(memory, auth)), + hasNextPage, + }; } statistics(auth: AuthDto, dto: MemorySearchDto) { diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index b3a3da6010..a134e0735d 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -133,8 +133,8 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.length).toBe(1); - expect(memories[0]).toEqual( + expect(memories.items.length).toBe(1); + expect(memories.items[0]).toEqual( expect.objectContaining({ id: expect.any(String), createdAt: expect.any(Date), @@ -173,8 +173,8 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.length).toBe(1); - expect(memories[0]).toEqual( + expect(memories.items.length).toBe(1); + expect(memories.items[0]).toEqual( expect.objectContaining({ id: expect.any(String), createdAt: expect.any(Date), @@ -228,12 +228,12 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.length).toBe(1); + expect(memories.items.length).toBe(1); await sut.onMemoriesCreate(); const memoriesAfter = await memoryRepo.search(user.id, {}); - expect(memoriesAfter.length).toBe(1); + expect(memoriesAfter.items.length).toBe(1); }); }); diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index d617364caa..b1e14ca5a2 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -27,6 +27,8 @@ export type MemoryAsset = MemoryIndex & { nextMemory?: MemoryResponseDto; }; +const PAGE_SIZE = 250; + class MemoryManager { #loading: Promise | undefined; #filters: @@ -40,9 +42,15 @@ class MemoryManager { $type?: MemoryType; } | undefined; + #hasNextPage: boolean; + #page: number; + #total: number; constructor() { this.#filters = undefined; + this.#hasNextPage = true; + this.#page = 1; + this.#total = 0; eventManager.on({ AuthLogout: () => this.clearCache(), @@ -64,11 +72,7 @@ class MemoryManager { set filters(filters) { this.#filters = filters; this.clearCache(); - if (this.#loading === undefined) { - this.#loading = this.load(); - } else { - void this.#loading.then(() => (this.#loading = this.load())); - } + void this.loadNextPage(); } ready() { @@ -151,23 +155,45 @@ class MemoryManager { } } + loadNextPage() { + if (this.#hasNextPage) { + if (this.#loading === undefined) { + this.#loading = this.load(this.#page++); + } else { + void this.#loading.then(() => (this.#loading = this.load(this.#page++))); + } + } + } + + get hasNextPage() { + return this.#hasNextPage; + } + + get total() { + return this.#total; + } + private clearCache() { this.#loading = undefined; + this.#hasNextPage = true; + this.#page = 1; this.memories = []; } private initialize() { if (!this.#loading) { - this.#loading = this.load(); + this.#loading = this.load(this.#page++); } return this.#loading; } - private async load() { + private async load(page: number) { if (this.#filters !== undefined) { - const memories = await searchMemories(this.#filters); - this.memories = memories.filter((memory) => memory.assets.length > 0); + const { items, hasNextPage, total } = await searchMemories({ ...this.#filters, page, size: PAGE_SIZE }); + this.memories.push(...items); + this.#hasNextPage = hasNextPage; + this.#total = total; } } @@ -182,12 +208,14 @@ class MemoryManager { const initialDelay = nextEvent.diff(now).as('milliseconds'); setTimeout(() => { - this.#loading = this.load(); + this.clearCache(); + this.#loading = this.load(0); // Schedule subsequent events hourly setInterval( () => { - this.#loading = this.load(); + this.clearCache(); + this.#loading = this.load(0); }, 60 * 60 * 1000, ); diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6531b6684e..526981426c 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -20,15 +20,13 @@ } let { data }: Props = $props(); - - let memories = $derived(data.memories); let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true'); + let lastElement: HTMLElement | undefined = $state(); const toggleFavorites = async () => { onlyFavorites = !onlyFavorites; memoryManager.filters = onlyFavorites ? { isSaved: true } : {}; await memoryManager.ready(); - memories = memoryManager.memories; if (onlyFavorites) { void setQueryValue('favorites', 'true'); @@ -36,12 +34,26 @@ void clearQueryParam('favorites', page.url); } }; + + const intersectionObserver = new IntersectionObserver((entries) => { + const entry = entries.find((entry) => entry.target === lastElement); + if (entry?.isIntersecting && memoryManager.hasNextPage) { + void memoryManager.loadNextPage(); + } + }); + + $effect(() => { + if (lastElement) { + intersectionObserver.disconnect(); + intersectionObserver.observe(lastElement); + } + }); {#if page.url.searchParams.has(QueryParameter.ID)} {:else} - + {#snippet buttons()}

{/snippet} - {#if memories.length > 0} + {#if memoryManager.memories.length > 0}
- {#each memories as memory (memory.id)} - + {#each memoryManager.memories as memory, index (memory.id)} + (index === memoryManager.memories.length - 1 ? lastElement : null), + (e) => { + if (index === memoryManager.memories.length - 1) { + lastElement = e; + } + } + } + > {#if memory.isSaved}
diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts index d6511c1da7..7e36420e25 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -20,7 +20,6 @@ export const load = (async ({ url }) => { return { user, - memories: memoryManager.memories, meta: { title: $t('memories'), }, From bb8bfcdf1eef1790fcdd033f5e10e1a96240f0a1 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Sun, 31 May 2026 18:24:18 -0700 Subject: [PATCH 03/31] improve memories ui --- web/src/lib/managers/memory-manager.svelte.ts | 6 +++--- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index b1e14ca5a2..65f2e778f9 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -190,7 +190,7 @@ class MemoryManager { private async load(page: number) { if (this.#filters !== undefined) { - const { items, hasNextPage, total } = await searchMemories({ ...this.#filters, page, size: PAGE_SIZE }); + const { items, hasNextPage, total } = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page }); this.memories.push(...items); this.#hasNextPage = hasNextPage; this.#total = total; @@ -209,13 +209,13 @@ class MemoryManager { setTimeout(() => { this.clearCache(); - this.#loading = this.load(0); + this.loadNextPage(); // Schedule subsequent events hourly setInterval( () => { this.clearCache(); - this.#loading = this.load(0); + this.loadNextPage(); }, 60 * 60 * 1000, ); diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index 526981426c..a6ea1374e5 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -66,11 +66,11 @@
{/snippet} {#if memoryManager.memories.length > 0} -
+ - {:else}{/if} + {/if} {/if} From eee20881dddd07bed8149a206f3749828378ea32 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 1 Jun 2026 23:05:49 -0700 Subject: [PATCH 04/31] feat(mobile): memories view --- .../lib/domain/services/memory.service.dart | 4 + .../repositories/memory.repository.dart | 20 ++-- mobile/lib/pages/common/tab_shell.page.dart | 2 +- .../pages/dev/main_timeline.page.dart | 2 +- .../pages/drift_library.page.dart | 79 ++++++++++++- .../pages/drift_memory_list.page.dart | 104 ++++++++++++++++++ .../widgets/memory/memory_lane.widget.dart | 2 +- .../infrastructure/memory.provider.dart | 12 +- mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 16 +++ 10 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/presentation/pages/drift_memory_list.page.dart diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart index ead613370f..a1fc1b2797 100644 --- a/mobile/lib/domain/services/memory.service.dart +++ b/mobile/lib/domain/services/memory.service.dart @@ -13,6 +13,10 @@ class DriftMemoryService { return _repository.getAll(ownerId); } + Future> getAllMemories(String ownerId) { + return _repository.getAll(ownerId, onlyToday: false); + } + Future get(String memoryId) { return _repository.get(memoryId); } diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 0dcf7200cc..351bf113de 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -9,10 +9,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository { final Drift _db; const DriftMemoryRepository(this._db) : super(_db); - Future> getAll(String ownerId) async { - final now = DateTime.now(); - final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); - + Future> getAll(String ownerId, {bool onlyToday = true}) async { final query = _db.select(_db.memoryEntity).join([ innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), @@ -24,10 +21,17 @@ class DriftMemoryRepository extends DriftDatabaseRepository { ), ]) ..where(_db.memoryEntity.ownerId.equals(ownerId)) - ..where(_db.memoryEntity.deletedAt.isNull()) - ..where(_db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc)) - ..where(_db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc)) - ..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); + ..where(_db.memoryEntity.deletedAt.isNull()); + + if (onlyToday) { + final now = DateTime.now(); + final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); + + query.where(_db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc)); + query.where(_db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc)); + } + + query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); final rows = await query.get(); if (rows.isEmpty) { diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 2fdcec4054..4a954f2d49 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -112,7 +112,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { } if (index == kPhotoTabIndex) { - ref.invalidate(driftMemoryFutureProvider); + ref.invalidate(driftMemoryLaneProvider); } if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) { diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 5ec946858d..fb83cbdddc 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -11,7 +11,7 @@ class MainTimelinePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); + final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false)); return Timeline( topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), topSliverWidgetHeight: hasMemories ? 200 : 0, diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 4708b5e615..d78567066c 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -7,8 +7,10 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -133,7 +135,12 @@ class _CollectionCards extends StatelessWidget { child: Wrap( spacing: 8, runSpacing: 8, - children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()], + children: [ + _PeopleCollectionCard(), + _PlacesCollectionCard(), + _LocalAlbumsCollectionCard(), + _MemoriesCollectionCard(), + ], ), ), ); @@ -327,6 +334,76 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget { } } +class _MemoriesCollectionCard extends ConsumerWidget { + const _MemoriesCollectionCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final memories = ref.watch(driftAllMemoriesProvider); + + return LayoutBuilder( + builder: (context, constraints) { + final isTablet = constraints.maxWidth > 600; + final widthFactor = isTablet ? 0.25 : 0.5; + final size = context.width * widthFactor - 20.0; + + return GestureDetector( + onTap: () => context.pushRoute(const DriftMemoryListRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + gradient: LinearGradient( + colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: memories.widgetWhen( + onLoading: () => const Center(child: CircularProgressIndicator()), + onData: (memories) { + return GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: memories.take(4).map((memory) { + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: Thumbnail.remote( + remoteId: memory.assets[0].id, + thumbhash: memory.assets[0].thumbHash ?? "", + fit: BoxFit.cover, + ), + ); + }).toList(), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'memories'.t(context: context), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + class _QuickAccessButtonList extends ConsumerWidget { const _QuickAccessButtonList(); diff --git a/mobile/lib/presentation/pages/drift_memory_list.page.dart b/mobile/lib/presentation/pages/drift_memory_list.page.dart new file mode 100644 index 0000000000..cc0324a4e1 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_memory_list.page.dart @@ -0,0 +1,104 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +@RoutePage() +class DriftMemoryListPage extends ConsumerStatefulWidget { + const DriftMemoryListPage({super.key}); + + @override + ConsumerState createState() => _DriftMemoryListPageState(); +} + +class _DriftMemoryListPageState extends ConsumerState { + bool _onlyFavorites = false; + + @override + Widget build(BuildContext context) { + final memories = ref.watch(driftAllMemoriesProvider); + + return LayoutBuilder( + builder: (context, constraints) { + return Scaffold( + appBar: AppBar( + title: Text('memories'.tr()), + actions: [ + IconButton( + icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline), + onPressed: () { + setState(() => _onlyFavorites = !_onlyFavorites); + }, + ), + ], + ), + body: SafeArea( + child: memories.when( + data: (memories) { + if (_onlyFavorites) { + memories = memories.where((memory) => memory.isSaved).toList(); + } + + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constraints.maxWidth > 600 ? 4 : 2, + childAspectRatio: 0.5625, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + ), + padding: const EdgeInsets.all(16), + itemCount: memories.length, + itemBuilder: (context, index) => GestureDetector( + onTap: () { + if (memories[index].assets.isNotEmpty) { + DriftMemoryPage.setMemory(ref, memories[index]); + } + context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)); + }, + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: ColorFiltered( + colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken), + child: AbsorbPointer( + child: Thumbnail.remote( + remoteId: memories[index].assets[0].id, + thumbhash: memories[index].assets[0].thumbHash ?? "", + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: Text( + DateFormat.yMMMMd().format(memories[index].memoryAt), + style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15), + ), + ), + if (memories[index].isSaved) + const Positioned( + top: 16, + right: 16, + child: Icon(Icons.favorite, color: Colors.white, size: 24), + ), + ], + ), + ), + ); + }, + error: (error, stack) => const Text("Error loading memories"), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index 62889b10cb..511de6a595 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -14,7 +14,7 @@ class DriftMemoryLane extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); + final memoryLaneProvider = ref.watch(driftMemoryLaneProvider); final memories = memoryLaneProvider.value ?? const []; if (memories.isEmpty) { return const SizedBox.shrink(); diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index 91495bb5ee..3613dd25ea 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -13,7 +13,7 @@ final driftMemoryServiceProvider = Provider( (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), ); -final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) { +final driftMemoryLaneProvider = FutureProvider.autoDispose>((ref) { final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); if (userId == null || !enabled) { return const []; @@ -22,3 +22,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose>( final service = ref.watch(driftMemoryServiceProvider); return service.getMemoryLane(userId); }); + +final driftAllMemoriesProvider = FutureProvider.autoDispose>((ref) { + final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); + if (userId == null || !enabled) { + return const []; + } + + final service = ref.watch(driftMemoryServiceProvider); + return service.getAllMemories(userId); +}); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b39a568e26..e9d3f3c250 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -52,6 +52,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; import 'package:immich_mobile/presentation/pages/drift_map.page.dart'; import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart'; import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_person.page.dart'; @@ -191,6 +192,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4b538d789..8aebf11d4c 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -754,6 +754,22 @@ class DriftMapRouteArgs { int get hashCode => key.hashCode ^ initialLocation.hashCode; } +/// generated route for +/// [DriftMemoryListPage] +class DriftMemoryListRoute extends PageRouteInfo { + const DriftMemoryListRoute({List? children}) + : super(DriftMemoryListRoute.name, initialChildren: children); + + static const String name = 'DriftMemoryListRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftMemoryListPage(); + }, + ); +} + /// generated route for /// [DriftMemoryPage] class DriftMemoryRoute extends PageRouteInfo { From 61f37b233dc8f061a083dbc6411f309212ade464 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 1 Jun 2026 23:38:00 -0700 Subject: [PATCH 05/31] improve memories web ui --- .../[[assetId=id]]/+page.svelte | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index a6ea1374e5..bf027891ff 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -5,7 +5,7 @@ import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils'; import { t } from 'svelte-i18n'; import { mdiHeartOutline, mdiHeart } from '@mdi/js'; - import { Button, Icon } from '@immich/ui'; + import { Button, Icon, LoadingSpinner } from '@immich/ui'; import { locale } from '$lib/stores/preferences.store'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; @@ -66,11 +66,12 @@
{/snippet} {#if memoryManager.memories.length > 0} -
+ + {:else if memoryManager.total > 0} +
+ +
{/if} {/if} From c287f9a49aed3697515de4c86ae4276205c037d4 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 1 Jun 2026 23:43:15 -0700 Subject: [PATCH 06/31] fix(web): avoid unnecessary memory refreshes --- web/src/lib/managers/memory-manager.svelte.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 65f2e778f9..7bc9ba4bb2 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -208,14 +208,18 @@ class MemoryManager { const initialDelay = nextEvent.diff(now).as('milliseconds'); setTimeout(() => { - this.clearCache(); - this.loadNextPage(); + if (this.#page <= 2) { + this.clearCache(); + this.loadNextPage(); + } // Schedule subsequent events hourly setInterval( () => { - this.clearCache(); - this.loadNextPage(); + if (this.#page <= 2) { + this.clearCache(); + this.loadNextPage(); + } }, 60 * 60 * 1000, ); From 93f19b86a1dfafddab9181ab129da515d57bc737 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Tue, 2 Jun 2026 00:25:58 -0700 Subject: [PATCH 07/31] refactor(web): move timline memory manager filter --- web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte | 6 ------ web/src/routes/(user)/photos/[[assetId=id]]/+page.ts | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 96a74158fb..44c1ac4e65 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -33,14 +33,12 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; - import { asLocalTimeISO } from '$lib/utils/date-time'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; - import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; let timelineManager = $state() as TimelineManager; @@ -83,10 +81,6 @@ assetMultiSelectManager.clear(); }; - if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) { - memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) }; - } - const items = $derived( memoryManager.memories.map((memory) => ({ id: memory.id, diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts index b4751c1807..6ce4a2d70d 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts @@ -1,4 +1,7 @@ +import { DateTime } from 'luxon'; +import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { authenticate } from '$lib/utils/auth'; +import { asLocalTimeISO } from '$lib/utils/date-time'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; @@ -6,6 +9,10 @@ export const load = (async ({ url }) => { await authenticate(url); const $t = await getFormatter(); + if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) { + memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) }; + } + return { meta: { title: $t('photos'), From 03153c864e6d972bcf9175fb7165f23cfc631c0d Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 4 Jun 2026 12:42:18 -0700 Subject: [PATCH 08/31] fix(web): memories infinitely loading on 0 results --- web/src/lib/managers/memory-manager.svelte.ts | 5 +++-- .../memory/[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 7bc9ba4bb2..a3cdc0f9cc 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -44,13 +44,13 @@ class MemoryManager { | undefined; #hasNextPage: boolean; #page: number; - #total: number; + #total: number | undefined; constructor() { this.#filters = undefined; this.#hasNextPage = true; this.#page = 1; - this.#total = 0; + this.#total = $state(undefined); eventManager.on({ AuthLogout: () => this.clearCache(), @@ -177,6 +177,7 @@ class MemoryManager { this.#loading = undefined; this.#hasNextPage = true; this.#page = 1; + this.#total = undefined; this.memories = []; } diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index bf027891ff..5c4d5f9415 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -53,7 +53,10 @@ {#if page.url.searchParams.has(QueryParameter.ID)} {:else} - + {#snippet buttons()}
- {:else if memoryManager.total > 0} + {:else if memoryManager.total === undefined}
From 209dcb38c52137de2aaef857f8a650d2514a27df Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 4 Jun 2026 13:38:48 -0700 Subject: [PATCH 09/31] fix(web): memories resetting --- web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte | 6 ++++++ web/src/routes/(user)/photos/[[assetId=id]]/+page.ts | 7 ------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 44c1ac4e65..201deb48ec 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -33,12 +33,14 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; + import { asLocalTimeISO } from '$lib/utils/date-time'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; let timelineManager = $state() as TimelineManager; @@ -90,6 +92,10 @@ src: getAssetMediaUrl({ id: memory.assets[0].id }), })), ); + + if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) { + memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) }; + } diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts index 6ce4a2d70d..b4751c1807 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts @@ -1,7 +1,4 @@ -import { DateTime } from 'luxon'; -import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { authenticate } from '$lib/utils/auth'; -import { asLocalTimeISO } from '$lib/utils/date-time'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; @@ -9,10 +6,6 @@ export const load = (async ({ url }) => { await authenticate(url); const $t = await getFormatter(); - if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) { - memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) }; - } - return { meta: { title: $t('photos'), From dd1f5acd4821484157785129a03ceb25d32372d9 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 4 Jun 2026 13:39:53 -0700 Subject: [PATCH 10/31] feat(web): animated memory previews --- .../[[assetId=id]]/+page.svelte | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5c4d5f9415..ae1dfcb8ce 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -48,6 +48,24 @@ intersectionObserver.observe(lastElement); } }); + + const rotation = () => { + const classes = [ + 'rotate-[-2.5deg]', + '-rotate-2', + 'rotate-[-1.5deg]', + '-rotate-1', + 'rotate-[-0.5deg]', + 'rotate-0', + 'rotate-[0.5deg]', + 'rotate-1', + 'rotate-[1.5deg]', + 'rotate-2', + 'rotate-[2.5deg]', + ]; + + return classes[Math.round(Math.random() * classes.length)]; + }; {#if page.url.searchParams.has(QueryParameter.ID)} @@ -73,8 +91,7 @@ {#each memoryManager.memories as memory, index (memory.id)} (index === memoryManager.memories.length - 1 ? lastElement : null), (e) => { From ffdb62fb39577569840ba7192b2235a7b5491b0b Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Tue, 23 Jun 2026 13:30:29 -0700 Subject: [PATCH 11/31] feat(web): memory page dark styling --- .../memory/[[photos=photos]]/[[assetId=id]]/+page.svelte | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte index ae1dfcb8ce..def3e95420 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -91,7 +91,7 @@ {#each memoryManager.memories as memory, index (memory.id)} (index === memoryManager.memories.length - 1 ? lastElement : null), (e) => { @@ -112,9 +112,7 @@ class="aspect-square object-cover brightness-75" loading="lazy" /> -

+

{$memoryLaneTitle(memory)}

From ca5573b902b0cd7cacf3cb2616168aa3bbb8b08b Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 29 Jun 2026 11:21:31 -0700 Subject: [PATCH 12/31] chore(mobile): fix failing memory lane tests --- .../repositories/memory.repository.dart | 4 ++-- mobile/lib/providers/app_life_cycle.provider.dart | 2 +- mobile/openapi/lib/api/memories_api.dart | 12 ++++++------ .../infrastructure/memory_provider_test.dart | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 351bf113de..8a61f6a4ab 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -27,8 +27,8 @@ class DriftMemoryRepository extends DriftDatabaseRepository { final now = DateTime.now(); final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0); - query.where(_db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc)); - query.where(_db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc)); + query.where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc)); + query.where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc)); } query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index ad0940b776..3082c6a4e4 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -116,7 +116,7 @@ class AppLifeCycleNotifier extends StateNotifier { _safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"), _safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"), ]); - _ref.invalidate(driftMemoryFutureProvider); + _ref.invalidate(driftAllMemoriesProvider); if (syncSuccess) { await Future.wait([ _safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) { diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index 5c0c25ae69..c07ed32198 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -272,7 +272,7 @@ class MemoriesApi { /// Number of memories to return /// /// * [MemoryType] type: - Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future? abortTrigger, }) async { + Future memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future? abortTrigger, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories/statistics'; @@ -344,8 +344,8 @@ class MemoriesApi { /// Number of memories to return /// /// * [MemoryType] type: - Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future? abortTrigger, }) async { - final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,); + Future memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future? abortTrigger, }) async { + final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -450,7 +450,7 @@ class MemoriesApi { /// Number of memories to return /// /// * [MemoryType] type: - Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future? abortTrigger, }) async { + Future searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future? abortTrigger, }) async { // ignore: prefer_const_declarations final apiPath = r'/memories'; @@ -522,8 +522,8 @@ class MemoriesApi { /// Number of memories to return /// /// * [MemoryType] type: - Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future? abortTrigger, }) async { - final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,); + Future searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future? abortTrigger, }) async { + final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/test/providers/infrastructure/memory_provider_test.dart b/mobile/test/providers/infrastructure/memory_provider_test.dart index e588153fc9..2bd5ed4267 100644 --- a/mobile/test/providers/infrastructure/memory_provider_test.dart +++ b/mobile/test/providers/infrastructure/memory_provider_test.dart @@ -44,11 +44,11 @@ void main() { when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty()); }); - group('driftMemoryFutureProvider', () { + group('driftMemoryLaneProvider', () { test('re-queries after local midnight', () { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryFutureProvider, (_, __) {}); + container.listen(driftMemoryLaneProvider, (_, __) {}); async.flushMicrotasks(); verify(() => memoryService.getMemoryLane('user-1')).called(1); @@ -66,7 +66,7 @@ void main() { test('cancels the midnight timer when disposed', () { fakeAsync((async) { final container = makeContainer(); - final subscription = container.listen(driftMemoryFutureProvider, (_, __) {}); + final subscription = container.listen(driftMemoryLaneProvider, (_, __) {}); async.flushMicrotasks(); verify(() => memoryService.getMemoryLane('user-1')).called(1); @@ -83,7 +83,7 @@ void main() { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryFutureProvider, (_, __) {}); + container.listen(driftMemoryLaneProvider, (_, __) {}); async.flushMicrotasks(); async.elapse(const Duration(hours: 25)); From f1247e248798e834f494585a63492de2183d7a74 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 29 Jun 2026 11:43:33 -0700 Subject: [PATCH 13/31] chore(mobile): remove old import --- mobile/lib/presentation/pages/drift_library.page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index c715e9341a..cf4dd0ddfb 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider. import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; From a7755346a7732e7d034bc284a32a14f262f86710 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Mon, 29 Jun 2026 12:18:44 -0700 Subject: [PATCH 14/31] chore(e2e): fix failing memory lane tests --- e2e/src/ui/mock-network/memory-network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/src/ui/mock-network/memory-network.ts b/e2e/src/ui/mock-network/memory-network.ts index 9a3a9e6555..1c7d458f87 100644 --- a/e2e/src/ui/mock-network/memory-network.ts +++ b/e2e/src/ui/mock-network/memory-network.ts @@ -30,7 +30,7 @@ export const setupMemoryMockApiRoutes = async ( return route.fulfill({ status: 200, contentType: 'application/json', - json: activeMemories, + json: { total: activeMemories.length, items: activeMemories, hasNextPage: false }, }); } From d4569b8d2635ce4e8d3dd28bc7212764476af23d Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 1 Jul 2026 19:28:55 -0700 Subject: [PATCH 15/31] chore(server): revert searchMemories changes --- mobile/openapi/README.md | 1 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/memories_api.dart | 9 +- mobile/openapi/lib/api_client.dart | 2 - .../lib/model/memory_search_response_dto.dart | 120 ------------------ open-api/immich-openapi-specs.json | 31 +---- packages/sdk/src/fetch-client.ts | 9 +- server/src/controllers/memory.controller.ts | 3 +- server/src/dtos/memory.dto.ts | 9 -- server/src/repositories/memory.repository.ts | 8 +- server/src/services/memory.service.spec.ts | 20 ++- server/src/services/memory.service.ts | 12 +- .../specs/services/memory.service.spec.ts | 12 +- web/src/lib/managers/memory-manager.svelte.ts | 12 +- 14 files changed, 42 insertions(+), 207 deletions(-) delete mode 100644 mobile/openapi/lib/model/memory_search_response_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 07cf01f87f..5fe04860e5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -482,7 +482,6 @@ Class | Method | HTTP request | Description - [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemorySearchOrder](doc//MemorySearchOrder.md) - - [MemorySearchResponseDto](doc//MemorySearchResponseDto.md) - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 19e334aa0f..413a64040c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -203,7 +203,6 @@ part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; part 'model/memory_response_dto.dart'; part 'model/memory_search_order.dart'; -part 'model/memory_search_response_dto.dart'; part 'model/memory_statistics_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; diff --git a/mobile/openapi/lib/api/memories_api.dart b/mobile/openapi/lib/api/memories_api.dart index c07ed32198..a36a7c83e7 100644 --- a/mobile/openapi/lib/api/memories_api.dart +++ b/mobile/openapi/lib/api/memories_api.dart @@ -522,7 +522,7 @@ class MemoriesApi { /// Number of memories to return /// /// * [MemoryType] type: - Future searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future? abortTrigger, }) async { + Future?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future? abortTrigger, }) async { final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -531,8 +531,11 @@ class MemoriesApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } return null; } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f3a71fe17e..90bff45431 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -451,8 +451,6 @@ class ApiClient { return MemoryResponseDto.fromJson(value); case 'MemorySearchOrder': return MemorySearchOrderTypeTransformer().decode(value); - case 'MemorySearchResponseDto': - return MemorySearchResponseDto.fromJson(value); case 'MemoryStatisticsResponseDto': return MemoryStatisticsResponseDto.fromJson(value); case 'MemoryType': diff --git a/mobile/openapi/lib/model/memory_search_response_dto.dart b/mobile/openapi/lib/model/memory_search_response_dto.dart deleted file mode 100644 index 82e04564cf..0000000000 --- a/mobile/openapi/lib/model/memory_search_response_dto.dart +++ /dev/null @@ -1,120 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class MemorySearchResponseDto { - /// Returns a new [MemorySearchResponseDto] instance. - MemorySearchResponseDto({ - required this.hasNextPage, - this.items = const [], - required this.total, - }); - - /// Whether there are more pages - bool hasNextPage; - - List items; - - /// Total number of matching memories - /// - /// Minimum value: 0 - /// Maximum value: 9007199254740991 - int total; - - @override - bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto && - other.hasNextPage == hasNextPage && - _deepEquality.equals(other.items, items) && - other.total == total; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (hasNextPage.hashCode) + - (items.hashCode) + - (total.hashCode); - - @override - String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]'; - - Map toJson() { - final json = {}; - json[r'hasNextPage'] = this.hasNextPage; - json[r'items'] = this.items; - json[r'total'] = this.total; - return json; - } - - /// Returns a new [MemorySearchResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static MemorySearchResponseDto? fromJson(dynamic value) { - upgradeDto(value, "MemorySearchResponseDto"); - if (value is Map) { - final json = value.cast(); - - return MemorySearchResponseDto( - hasNextPage: mapValueOfType(json, r'hasNextPage')!, - items: MemoryResponseDto.listFromJson(json[r'items']), - total: mapValueOfType(json, r'total')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MemorySearchResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = MemorySearchResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'hasNextPage', - 'items', - 'total', - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c38ba67f59..cb49af2164 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7207,7 +7207,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MemorySearchResponseDto" + "items": { + "$ref": "#/components/schemas/MemoryResponseDto" + }, + "type": "array" } } }, @@ -19974,32 +19977,6 @@ ], "type": "string" }, - "MemorySearchResponseDto": { - "properties": { - "hasNextPage": { - "description": "Whether there are more pages", - "type": "boolean" - }, - "items": { - "items": { - "$ref": "#/components/schemas/MemoryResponseDto" - }, - "type": "array" - }, - "total": { - "description": "Total number of matching memories", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "hasNextPage", - "items", - "total" - ], - "type": "object" - }, "MemoryStatisticsResponseDto": { "properties": { "total": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 813c514241..a9c40ea8b8 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1350,13 +1350,6 @@ export type MemoryResponseDto = { /** Last update date */ updatedAt: string; }; -export type MemorySearchResponseDto = { - /** Whether there are more pages */ - hasNextPage: boolean; - items: MemoryResponseDto[]; - /** Total number of matching memories */ - total: number; -}; export type MemoryCreateDto = { /** Asset IDs to associate with memory */ assetIds?: string[]; @@ -4988,7 +4981,7 @@ export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $t }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: MemorySearchResponseDto; + data: MemoryResponseDto[]; }>(`/memories${QS.query(QS.explode({ "for": $for, isSaved, diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 4a41ce0f0f..66a3012888 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -7,7 +7,6 @@ import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, - MemorySearchResponseDto, MemoryStatisticsResponseDto, MemoryUpdateDto, } from 'src/dtos/memory.dto'; @@ -29,7 +28,7 @@ export class MemoryController { 'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { + searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.search(auth, dto); } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 335fd9d64a..2b82a9ba64 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -76,20 +76,11 @@ const MemoryResponseSchema = z }) .meta({ id: 'MemoryResponseDto' }); -const MemorySearchResponseSchema = z - .object({ - total: z.int().min(0).describe('Total number of matching memories'), - items: z.array(MemoryResponseSchema), - hasNextPage: z.boolean().describe('Whether there are more pages'), - }) - .meta({ id: 'MemorySearchResponseDto' }); - export class MemorySearchDto extends createZodDto(MemorySearchSchema) {} export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {} export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {} export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {} export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {} -export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {} export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => { return { diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index b95cb495ad..2de0d5755e 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -58,8 +58,8 @@ export class MemoryRepository implements IBulkAsset { { params: [DummyValue.UUID, {}] }, { name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] }, ) - async search(ownerId: string, dto: MemorySearchDto) { - const items = await this.searchBuilder(ownerId, dto) + search(ownerId: string, dto: MemorySearchDto) { + return this.searchBuilder(ownerId, dto) .select((eb) => jsonArrayFrom( eb @@ -90,11 +90,9 @@ export class MemoryRepository implements IBulkAsset { ? qb.orderBy(sql`RANDOM()`) : qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection), ) - .$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1)) + .$if(dto.size !== undefined, (qb) => qb.limit(dto.size!)) .$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!)) .execute(); - - return paginationHelper(items, dto.size ?? items.length); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index cda4184e1f..cc35c672f9 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -34,28 +34,24 @@ describe(MemoryService.name, () => { const asset = AssetFactory.create(); const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build(); const memory2 = MemoryFactory.create({ ownerId: userId }); - mocks.memory.search.mockResolvedValue({ - items: [getForMemory(memory1), getForMemory(memory2)], - hasNextPage: false, - }); + mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]); mocks.memory.statistics.mockResolvedValue({ total: 2 }); - await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({ - items: expect.arrayContaining([ + await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( + expect.arrayContaining([ expect.objectContaining({ id: memory1.id, assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]), }), ]), - hasNextPage: false, - total: 2, - }); + ); + mocks.memory.search.mockResolvedValue([]); + await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); }); it('should map empty result', async () => { - mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false }); - mocks.memory.statistics.mockResolvedValue({ total: 0 }); - await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 }); + mocks.memory.search.mockResolvedValue([]); + await expect(sut.search(factory.auth(), {})).resolves.toEqual([]); }); }); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 0fef2c9b5c..ac8f88ad87 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -71,14 +71,10 @@ export class MemoryService extends BaseService { } async search(auth: AuthDto, dto: MemorySearchDto) { - const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto); - const { total } = await this.memoryRepository.statistics(auth.user.id, dto); - - return { - total, - items: items.map((memory: Memory) => mapMemory(memory, auth)), - hasNextPage, - }; + const memories = await this.memoryRepository.search(auth.user.id, dto); + return memories + .filter((memory: Memory) => memory.assets && memory.assets.length > 0) + .map((memory: Memory) => mapMemory(memory, auth)); } statistics(auth: AuthDto, dto: MemorySearchDto) { diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index a134e0735d..b3a3da6010 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -133,8 +133,8 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.items.length).toBe(1); - expect(memories.items[0]).toEqual( + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( expect.objectContaining({ id: expect.any(String), createdAt: expect.any(Date), @@ -173,8 +173,8 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.items.length).toBe(1); - expect(memories.items[0]).toEqual( + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( expect.objectContaining({ id: expect.any(String), createdAt: expect.any(Date), @@ -228,12 +228,12 @@ describe(MemoryService.name, () => { await sut.onMemoriesCreate(); const memories = await memoryRepo.search(user.id, {}); - expect(memories.items.length).toBe(1); + expect(memories.length).toBe(1); await sut.onMemoriesCreate(); const memoriesAfter = await memoryRepo.search(user.id, {}); - expect(memoriesAfter.items.length).toBe(1); + expect(memoriesAfter.length).toBe(1); }); }); diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index a3cdc0f9cc..1ceb577e20 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -6,6 +6,7 @@ import { updateMemory, MemorySearchOrder, MemoryType, + memoriesStatistics, } from '@immich/sdk'; import { DateTime } from 'luxon'; import { authManager } from '$lib/managers/auth-manager.svelte'; @@ -191,10 +192,15 @@ class MemoryManager { private async load(page: number) { if (this.#filters !== undefined) { - const { items, hasNextPage, total } = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page }); + const items = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page }); this.memories.push(...items); - this.#hasNextPage = hasNextPage; - this.#total = total; + + if (this.#total === undefined) { + const { total } = await memoriesStatistics(this.#filters); + this.#total = total; + } + + this.#hasNextPage = this.memories.length < this.#total; } } From 467eee91f3d00db9346368851e072b0a4025abf1 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 1 Jul 2026 19:35:58 -0700 Subject: [PATCH 16/31] chore(server): lint --- server/src/repositories/memory.repository.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 2de0d5755e..b421ba6ec8 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -9,7 +9,6 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { IBulkAsset } from 'src/types'; -import { paginationHelper } from 'src/utils/pagination'; @Injectable() export class MemoryRepository implements IBulkAsset { From 4b9311587e7d967617cfb02b3c5cd263985a4f2e Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 1 Jul 2026 19:36:38 -0700 Subject: [PATCH 17/31] chore(mobile): fix bad merge --- mobile/lib/presentation/pages/dev/main_timeline.page.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index a6a6ba8046..b1398deb0e 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -12,10 +12,6 @@ class MainTimelinePage extends ConsumerStatefulWidget { const MainTimelinePage({super.key}); @override -<<<<<<< feat/memories-view - Widget build(BuildContext context, WidgetRef ref) { - final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false)); -======= ConsumerState createState() => _MainTimelinePageState(); } @@ -43,8 +39,7 @@ class _MainTimelinePageState extends ConsumerState { @override Widget build(BuildContext context) { - final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); ->>>>>>> main + final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false)); return Timeline( topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), topSliverWidgetHeight: hasMemories ? 200 : 0, From d305287ef95a00310015fd154713a5943dccd67d Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 1 Jul 2026 19:44:48 -0700 Subject: [PATCH 18/31] chore(e2e): fix memory ui tests --- e2e/src/ui/mock-network/memory-network.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/src/ui/mock-network/memory-network.ts b/e2e/src/ui/mock-network/memory-network.ts index 1c7d458f87..9a3a9e6555 100644 --- a/e2e/src/ui/mock-network/memory-network.ts +++ b/e2e/src/ui/mock-network/memory-network.ts @@ -30,7 +30,7 @@ export const setupMemoryMockApiRoutes = async ( return route.fulfill({ status: 200, contentType: 'application/json', - json: { total: activeMemories.length, items: activeMemories, hasNextPage: false }, + json: activeMemories, }); } From 7f79ad9a2ac83af3ed2f1ceeb22af4e1b50fdb2d Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 1 Jul 2026 21:12:49 -0700 Subject: [PATCH 19/31] chore(web): rename /memory to /memories --- e2e/src/ui/mock-network/memory-network.ts | 10 ++++++++++ e2e/src/ui/specs/memory/utils.ts | 6 +++--- web/src/lib/route.ts | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 0 .../[[photos=photos]]/[[assetId=id]]/+page.ts | 0 .../[[assetId=id]]/MemoryPhotoViewer.svelte | 0 .../[[assetId=id]]/MemoryVideoViewer.svelte | 0 .../[[assetId=id]]/MemoryViewer.svelte | 0 8 files changed, 14 insertions(+), 4 deletions(-) rename web/src/routes/(user)/{memory => memories}/[[photos=photos]]/[[assetId=id]]/+page.svelte (100%) rename web/src/routes/(user)/{memory => memories}/[[photos=photos]]/[[assetId=id]]/+page.ts (100%) rename web/src/routes/(user)/{memory => memories}/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte (100%) rename web/src/routes/(user)/{memory => memories}/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte (100%) rename web/src/routes/(user)/{memory => memories}/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte (100%) diff --git a/e2e/src/ui/mock-network/memory-network.ts b/e2e/src/ui/mock-network/memory-network.ts index 9a3a9e6555..1cb93995b8 100644 --- a/e2e/src/ui/mock-network/memory-network.ts +++ b/e2e/src/ui/mock-network/memory-network.ts @@ -62,4 +62,14 @@ export const setupMemoryMockApiRoutes = async ( await route.fallback(); }); + + await context.route('**/api/memories/statistics*', async (route) => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: { + total: memories.length, + }, + }); + }); }; diff --git a/e2e/src/ui/specs/memory/utils.ts b/e2e/src/ui/specs/memory/utils.ts index cf99033e7e..d4fe0324cb 100644 --- a/e2e/src/ui/specs/memory/utils.ts +++ b/e2e/src/ui/specs/memory/utils.ts @@ -2,7 +2,7 @@ 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\/([^/]+)/); + const pathMatch = url.pathname.match(/\/memories\/photos\/([^/]+)/); if (pathMatch) { return pathMatch[1]; } @@ -20,12 +20,12 @@ export const memoryViewerUtils = { }, async openMemoryPage(page: Page) { - await page.goto('/memory'); + await page.goto('/memories'); await this.waitForMemoryLoad(page); }, async openMemoryPageWithAsset(page: Page, assetId: string) { - await page.goto(`/memory?id=${assetId}`); + await page.goto(`/memories?id=${assetId}`); await this.waitForMemoryLoad(page); }, }; diff --git a/web/src/lib/route.ts b/web/src/lib/route.ts index 8542617ab1..50b323df2c 100644 --- a/web/src/lib/route.ts +++ b/web/src/lib/route.ts @@ -87,7 +87,7 @@ export const Route = { '/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''), // memories - memories: (params?: { id?: string }) => '/memory' + asQueryString(params), + memories: (params?: { id?: string }) => '/memories' + asQueryString(params), // partners viewPartner: ({ id }: { id: string }) => `/partners/${id}`, diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryPhotoViewer.svelte diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryVideoViewer.svelte diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte similarity index 100% rename from web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte rename to web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte From 3784d0c0d2762c778d4095e56e58a79a13ec4056 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 5 Aug 2026 22:33:14 -0700 Subject: [PATCH 20/31] chore(mobile): linting --- mobile/lib/presentation/pages/drift_library.page.dart | 2 +- mobile/lib/presentation/pages/drift_memory_list.page.dart | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 2d4940b21d..aeabaa5601 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -7,8 +7,8 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; diff --git a/mobile/lib/presentation/pages/drift_memory_list.page.dart b/mobile/lib/presentation/pages/drift_memory_list.page.dart index cc0324a4e1..011c715e32 100644 --- a/mobile/lib/presentation/pages/drift_memory_list.page.dart +++ b/mobile/lib/presentation/pages/drift_memory_list.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -57,7 +59,7 @@ class _DriftMemoryListPageState extends ConsumerState { if (memories[index].assets.isNotEmpty) { DriftMemoryPage.setMemory(ref, memories[index]); } - context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)); + unawaited(context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index))); }, child: Stack( children: [ From 6d6d7c6c775da99740f389e8ed3fe580e5b07205 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 5 Aug 2026 23:01:08 -0700 Subject: [PATCH 21/31] chore(web): linting --- web/src/lib/managers/memory-manager.svelte.ts | 28 ++++++++++--------- .../[[assetId=id]]/+page.svelte | 7 +++-- .../[[assetId=id]]/MemoryViewer.svelte | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 1ceb577e20..cae7ee6d20 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -191,17 +191,18 @@ class MemoryManager { } private async load(page: number) { - if (this.#filters !== undefined) { - const items = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page }); - this.memories.push(...items); - - if (this.#total === undefined) { - const { total } = await memoriesStatistics(this.#filters); - this.#total = total; - } - - this.#hasNextPage = this.memories.length < this.#total; + if (this.#filters === undefined) { + return; } + const items = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page }); + this.memories.push(...items); + + if (this.#total === undefined) { + const { total } = await memoriesStatistics(this.#filters); + this.#total = total; + } + + this.#hasNextPage = this.memories.length < this.#total; } private scheduleHourlyRefresh() { @@ -223,10 +224,11 @@ class MemoryManager { // Schedule subsequent events hourly setInterval( () => { - if (this.#page <= 2) { - this.clearCache(); - this.loadNextPage(); + if (this.#page > 2) { + return; } + this.clearCache(); + this.loadNextPage(); }, 60 * 60 * 1000, ); diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte index def3e95420..da81a40877 100644 --- a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -43,10 +43,11 @@ }); $effect(() => { - if (lastElement) { - intersectionObserver.disconnect(); - intersectionObserver.observe(lastElement); + if (lastElement === undefined) { + return; } + intersectionObserver.disconnect(); + intersectionObserver.observe(lastElement); }); const rotation = () => { diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index 329a23a15e..a8581d00b2 100644 --- a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -283,7 +283,7 @@ afterNavigate(({ from, to }) => { if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) { - previousPage = from!.url.toString(); + previousPage = from!.url.href; } memoryManager From 81f4f2c007d69034c3b9b21f23742aa1ed6828d1 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Sun, 9 Aug 2026 17:36:52 -0700 Subject: [PATCH 22/31] fix(web): prevent duplicate memory page loading --- web/src/lib/managers/memory-manager.svelte.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index cae7ee6d20..1b96801e61 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -46,12 +46,14 @@ class MemoryManager { #hasNextPage: boolean; #page: number; #total: number | undefined; + #queued: boolean; constructor() { this.#filters = undefined; this.#hasNextPage = true; this.#page = 1; this.#total = $state(undefined); + this.#queued = false; eventManager.on({ AuthLogout: () => this.clearCache(), @@ -161,7 +163,7 @@ class MemoryManager { if (this.#loading === undefined) { this.#loading = this.load(this.#page++); } else { - void this.#loading.then(() => (this.#loading = this.load(this.#page++))); + this.#queued = true; } } } @@ -203,6 +205,11 @@ class MemoryManager { } this.#hasNextPage = this.memories.length < this.#total; + + if (this.#queued) { + this.#queued = false; + this.load(this.#page++); + } } private scheduleHourlyRefresh() { From b3996571d8f1c403fdb2b552b9f910852d2e4a5a Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Sun, 9 Aug 2026 19:06:47 -0700 Subject: [PATCH 23/31] fix(web): lint --- web/src/lib/managers/memory-manager.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 1b96801e61..532b04d2e3 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -208,7 +208,7 @@ class MemoryManager { if (this.#queued) { this.#queued = false; - this.load(this.#page++); + await this.load(this.#page++); } } From 630c55c57a3059a85fc11956a7c270459088844d Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Tue, 11 Aug 2026 23:49:55 -0700 Subject: [PATCH 24/31] fix(web): memories page improvements --- web/src/lib/managers/memory-manager.svelte.ts | 20 +++---- web/src/routes/(user)/explore/+page.ts | 2 +- .../[[assetId=id]]/+page.svelte | 60 +++++++++---------- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- .../[[assetId=id]]/MemoryViewer.svelte | 2 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 5 +- 6 files changed, 43 insertions(+), 48 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 532b04d2e3..a62ccdc0b0 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -43,18 +43,12 @@ class MemoryManager { $type?: MemoryType; } | undefined; - #hasNextPage: boolean; - #page: number; - #total: number | undefined; - #queued: boolean; + #hasNextPage: boolean = true; + #page: number = 1; + #total: number | undefined = $state(); + #queued: boolean = false; constructor() { - this.#filters = undefined; - this.#hasNextPage = true; - this.#page = 1; - this.#total = $state(undefined); - this.#queued = false; - eventManager.on({ AuthLogout: () => this.clearCache(), AuthUserLoaded: () => this.initialize(), @@ -78,7 +72,7 @@ class MemoryManager { void this.loadNextPage(); } - ready() { + refresh() { return this.initialize(); } @@ -176,6 +170,10 @@ class MemoryManager { return this.#total; } + get loading() { + return this.#loading; + } + private clearCache() { this.#loading = undefined; this.#hasNextPage = true; diff --git a/web/src/routes/(user)/explore/+page.ts b/web/src/routes/(user)/explore/+page.ts index 7c2a7f4eaf..3e9e20ca3b 100644 --- a/web/src/routes/(user)/explore/+page.ts +++ b/web/src/routes/(user)/explore/+page.ts @@ -11,7 +11,7 @@ export const load = (async ({ url }) => { const [explore, people] = await Promise.all([ getExploreData(), getAllPeople({ withHidden: false }), - memoryManager.ready(), + memoryManager.refresh(), ]); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte index da81a40877..d5cae323b8 100644 --- a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -15,23 +15,23 @@ import { memoryManager } from '$lib/managers/memory-manager.svelte'; import { clearQueryParam, setQueryValue } from '$lib/utils/navigation'; - interface Props { + type Props = { data: PageData; - } + }; - let { data }: Props = $props(); + const { data }: Props = $props(); let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true'); - let lastElement: HTMLElement | undefined = $state(); + let lastElement: HTMLElement | null | undefined = $state(); const toggleFavorites = async () => { onlyFavorites = !onlyFavorites; memoryManager.filters = onlyFavorites ? { isSaved: true } : {}; - await memoryManager.ready(); + await memoryManager.refresh(); if (onlyFavorites) { - void setQueryValue('favorites', 'true'); + await setQueryValue('favorites', 'true'); } else { - void clearQueryParam('favorites', page.url); + await clearQueryParam('favorites', page.url); } }; @@ -43,30 +43,28 @@ }); $effect(() => { - if (lastElement === undefined) { + if (!lastElement) { return; } intersectionObserver.disconnect(); intersectionObserver.observe(lastElement); }); - const rotation = () => { - const classes = [ - 'rotate-[-2.5deg]', - '-rotate-2', - 'rotate-[-1.5deg]', - '-rotate-1', - 'rotate-[-0.5deg]', - 'rotate-0', - 'rotate-[0.5deg]', - 'rotate-1', - 'rotate-[1.5deg]', - 'rotate-2', - 'rotate-[2.5deg]', - ]; + const rotationClasses = [ + 'rotate-[-2.5deg]', + '-rotate-2', + 'rotate-[-1.5deg]', + '-rotate-1', + 'rotate-[-0.5deg]', + 'rotate-0', + 'rotate-[0.5deg]', + 'rotate-1', + 'rotate-[1.5deg]', + 'rotate-2', + 'rotate-[2.5deg]', + ]; - return classes[Math.round(Math.random() * classes.length)]; - }; + const getRotation = () => rotationClasses[Math.floor(Math.random() * rotationClasses.length)]; {#if page.url.searchParams.has(QueryParameter.ID)} @@ -92,7 +90,7 @@ {#each memoryManager.memories as memory, index (memory.id)} (index === memoryManager.memories.length - 1 ? lastElement : null), (e) => { @@ -102,24 +100,24 @@ } } > - {#if memory.isSaved} -
- -
- {/if} {$getAltText(toTimelineAsset(memory.assets[0]))} + {#if memory.isSaved} +
+ +
+ {/if}

{$memoryLaneTitle(memory)}

{/each}
- {:else if memoryManager.total === undefined} + {:else if memoryManager.loading}
diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts index 7e36420e25..e1cef572c7 100644 --- a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -15,7 +15,7 @@ export const load = (async ({ url }) => { !isEqual(memoryManager.filters, filters) ) { memoryManager.filters = filters; - await memoryManager.ready(); + await memoryManager.refresh(); } return { diff --git a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index a8581d00b2..07304963f1 100644 --- a/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memories/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -287,7 +287,7 @@ } memoryManager - .ready() + .refresh() .then(() => { let target; if (to?.params?.assetId) { diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 201deb48ec..7f008fcf18 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -33,7 +33,6 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; - import { asLocalTimeISO } from '$lib/utils/date-time'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; @@ -93,8 +92,8 @@ })), ); - if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) { - memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) }; + if (memoryManager.filters === undefined || memoryManager.filters.$for !== DateTime.now().toISODate()) { + memoryManager.filters = { $for: DateTime.now().toISODate() }; } From 07c551ef4cf0a594b62fc2283c0e54f8d3bb2dd3 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 12 Aug 2026 00:00:03 -0700 Subject: [PATCH 25/31] chore(mobile): lint --- mobile/lib/presentation/pages/drift_memory_list.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/presentation/pages/drift_memory_list.page.dart b/mobile/lib/presentation/pages/drift_memory_list.page.dart index 011c715e32..e2b6e53ab4 100644 --- a/mobile/lib/presentation/pages/drift_memory_list.page.dart +++ b/mobile/lib/presentation/pages/drift_memory_list.page.dart @@ -28,7 +28,7 @@ class _DriftMemoryListPageState extends ConsumerState { builder: (context, constraints) { return Scaffold( appBar: AppBar( - title: Text('memories'.tr()), + title: Text(context.t.memories), actions: [ IconButton( icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline), From 105add38d54477e638eb88b0bb711bffa5ac3d83 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 12 Aug 2026 00:04:23 -0700 Subject: [PATCH 26/31] fix(mobile): fix translations --- mobile/lib/presentation/pages/drift_library.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 225b408d12..dd63ac93f5 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -391,7 +391,7 @@ class _MemoriesCollectionCard extends ConsumerWidget { Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'memories'.t(context: context), + context.t.memories, style: context.textTheme.titleSmall?.copyWith( color: context.colorScheme.onSurface, fontWeight: FontWeight.w500, From ac92b959dfda4bde159357050c6487b534e9dc46 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 12 Aug 2026 00:17:30 -0700 Subject: [PATCH 27/31] fix(mobile): build errors --- mobile/lib/presentation/pages/drift_memory_list.page.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/presentation/pages/drift_memory_list.page.dart b/mobile/lib/presentation/pages/drift_memory_list.page.dart index e2b6e53ab4..59c57cf491 100644 --- a/mobile/lib/presentation/pages/drift_memory_list.page.dart +++ b/mobile/lib/presentation/pages/drift_memory_list.page.dart @@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; From 284a49f3e240a611ae224ee524bf5787a1b86262 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 13 Aug 2026 11:24:37 -0700 Subject: [PATCH 28/31] fix(web): prevent memories from infinitely loading --- web/src/lib/managers/memory-manager.svelte.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index a62ccdc0b0..19fe71dda8 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -31,7 +31,7 @@ export type MemoryAsset = MemoryIndex & { const PAGE_SIZE = 250; class MemoryManager { - #loading: Promise | undefined; + #loading = $state>(); #filters: | { $for?: string; @@ -203,10 +203,12 @@ class MemoryManager { } this.#hasNextPage = this.memories.length < this.#total; + this.#loading = undefined; if (this.#queued) { this.#queued = false; - await this.load(this.#page++); + this.#loading = this.load(this.#page++); + await this.#loading; } } From 765322d19c4b1a7e7e516107f39f4bebefce4079 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 13 Aug 2026 19:14:40 -0700 Subject: [PATCH 29/31] chore(e2e)): fix failng memory viewer test --- e2e/src/ui/generators/memory/model-objects.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/src/ui/generators/memory/model-objects.ts b/e2e/src/ui/generators/memory/model-objects.ts index f81b2f8896..6b4d8c725a 100644 --- a/e2e/src/ui/generators/memory/model-objects.ts +++ b/e2e/src/ui/generators/memory/model-objects.ts @@ -80,5 +80,7 @@ export function generateMemoriesFromTimeline( ); } + memories.sort((a, b) => new Date(b.memoryAt).getMilliseconds() - new Date(a.memoryAt).getMilliseconds()); + return memories; } From ce1f1c6e8e52281cd92d19995ca5f8bc8d41dfad Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Fri, 14 Aug 2026 09:10:43 -0700 Subject: [PATCH 30/31] chore(web): clean up types in memory manager --- web/src/lib/managers/memory-manager.svelte.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index 19fe71dda8..c24442a04d 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -32,17 +32,7 @@ const PAGE_SIZE = 250; class MemoryManager { #loading = $state>(); - #filters: - | { - $for?: string; - isSaved?: boolean; - isTrashed?: boolean; - order?: MemorySearchOrder; - page?: number; - size?: number; - $type?: MemoryType; - } - | undefined; + #filters: Parameters[0] | undefined; #hasNextPage: boolean = true; #page: number = 1; #total: number | undefined = $state(); From 0111e939ded78a836a31d1a4276f928f57564c48 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Fri, 14 Aug 2026 09:41:52 -0700 Subject: [PATCH 31/31] chore(web): lint --- web/src/lib/managers/memory-manager.svelte.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/lib/managers/memory-manager.svelte.ts b/web/src/lib/managers/memory-manager.svelte.ts index c24442a04d..fc38a92c6b 100644 --- a/web/src/lib/managers/memory-manager.svelte.ts +++ b/web/src/lib/managers/memory-manager.svelte.ts @@ -4,8 +4,6 @@ import { removeMemoryAssets, searchMemories, updateMemory, - MemorySearchOrder, - MemoryType, memoriesStatistics, } from '@immich/sdk'; import { DateTime } from 'luxon';