Compare commits

..

27 Commits

Author SHA1 Message Date
Ben Beckford 7f79ad9a2a chore(web): rename /memory to /memories 2026-07-01 21:12:49 -07:00
Ben Beckford d305287ef9 chore(e2e): fix memory ui tests 2026-07-01 19:44:48 -07:00
Ben Beckford 4b9311587e chore(mobile): fix bad merge 2026-07-01 19:36:38 -07:00
Ben Beckford 467eee91f3 chore(server): lint 2026-07-01 19:35:58 -07:00
Ben Beckford cbac2b2bb2 Merge branch 'main' into feat/memories-view 2026-07-01 19:31:01 -07:00
Ben Beckford d4569b8d26 chore(server): revert searchMemories changes 2026-07-01 19:28:55 -07:00
Ben Beckford 237734bb26 feat(web): recently added link in sidebar (#29039)
* feat(web): recently added link in sidebar

* chore(mobile): update openapi patches
2026-07-01 23:12:50 +00:00
Weblate (bot) 4b54fef82e chore(web): update translations (#29410)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ar/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/be/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sq/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translation: Immich/immich

Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Charles IdB <charles.issert2braux@gmail.com>
Co-authored-by: Dmitry Banny <dj.icecore@gmail.com>
Co-authored-by: Enric Pagès i Gassull <enricpages@hotmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: Harsh Kevadia <kevadiyaharsh@gmail.com>
Co-authored-by: Hosted Weblate user 156232 <53017937+parol100@users.noreply.github.com>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jeppe Nellemann <jepnel@proton.me>
Co-authored-by: Mahmoud Dwidar <modydodo2055@gmail.com>
Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Co-authored-by: Pavel Miniutka <pavel.miniutka@gmail.com>
Co-authored-by: Yago Raña Gayoso <yago.rana.gayoso@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: ikeno-web <ikeno@nextcore-consulting.com>
Co-authored-by: rubes <mail@armd.one>
2026-07-01 11:05:25 +00:00
Daniel Dietzler 0050332391 fix: e2e version test (#29412) 2026-07-01 12:41:45 +02:00
Ben Beckford a7755346a7 chore(e2e): fix failing memory lane tests 2026-06-29 12:18:44 -07:00
Ben Beckford f1247e2487 chore(mobile): remove old import 2026-06-29 11:43:33 -07:00
Ben Beckford ca5573b902 chore(mobile): fix failing memory lane tests 2026-06-29 11:21:31 -07:00
Ben Beckford 789df3b198 Merge branch 'main' into feat/memories-view 2026-06-29 09:10:51 -07:00
Ben Beckford ffdb62fb39 feat(web): memory page dark styling 2026-06-23 13:30:29 -07:00
Ben Beckford 427bcb1e35 Merge branch 'main' into feat/memories-view 2026-06-23 13:04:33 -07:00
Ben Beckford dd1f5acd48 feat(web): animated memory previews 2026-06-04 13:39:53 -07:00
Ben Beckford 209dcb38c5 fix(web): memories resetting 2026-06-04 13:38:48 -07:00
Ben Beckford 03153c864e fix(web): memories infinitely loading on 0 results 2026-06-04 12:42:18 -07:00
Ben Beckford 545db90d13 Merge branch 'main' into feat/memories-view 2026-06-04 12:04:44 -07:00
Timon 1b451f3d07 Merge branch 'main' into feat/memories-view 2026-06-03 09:46:49 +02:00
Ben Beckford 93f19b86a1 refactor(web): move timline memory manager filter 2026-06-02 00:25:58 -07:00
Ben Beckford c287f9a49a fix(web): avoid unnecessary memory refreshes 2026-06-01 23:43:15 -07:00
Ben Beckford 61f37b233d improve memories web ui 2026-06-01 23:38:00 -07:00
Ben Beckford eee20881dd feat(mobile): memories view 2026-06-01 23:05:49 -07:00
Ben Beckford bb8bfcdf1e improve memories ui 2026-05-31 18:24:18 -07:00
Ben Beckford 3f1b8e1d9b paginate searchMemories 2026-05-28 23:54:14 -07:00
Ben Beckford 6e78d6e131 wip web memories view 2026-05-28 14:11:15 -07:00
52 changed files with 974 additions and 83 deletions
+8 -6
View File
@@ -91,12 +91,14 @@ describe('/server', () => {
it('should respond with the server version', async () => {
const { status, body } = await request(app).get('/server/version');
expect(status).toBe(200);
expect(body).toEqual({
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
prerelease: expect.anything(),
});
expect(body).toEqual(
expect.objectContaining({
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
}),
);
expect(Object.keys(body)).toEqual(expect.arrayContaining(['major', 'minor', 'patch', 'prerelease']));
});
});
+3
View File
@@ -82,6 +82,9 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
cast: {
gCastEnabled: false,
},
recentlyAdded: {
sidebarWeb: false,
},
},
});
});
+10
View File
@@ -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,
},
});
});
};
+1
View File
@@ -110,6 +110,7 @@ export async function enableTagsPreference(context: BrowserContext) {
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
cast: { gCastEnabled: false },
recentlyAdded: { sidebarWeb: false },
},
});
});
+3 -3
View File
@@ -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);
},
};
+1
View File
@@ -1718,6 +1718,7 @@
"recent_searches": "Recent searches",
"recently_added": "Recently added",
"recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
"recently_added_description": "Browse your assets sorted by when they were uploaded to Immich",
"recently_added_page_title": "Recently Added",
"recently_added_title": "Recently added",
"recently_taken": "Recently taken",
+29 -1
View File
@@ -6,7 +6,7 @@
"action": "Aksion",
"action_common_update": "Përditëso",
"action_description": "Një grup veprimesh për t'u kryer në asetet e filtruara",
"actions": "Aksione",
"actions": "Veprime",
"active": "Aktiv",
"active_count": "Aktive: {count}",
"activity": "Aktivitet",
@@ -536,6 +536,11 @@
"api_keys": "Çelësat API",
"app_architecture_variant": "Varianta (Arkitektura)",
"app_bar_signout_dialog_content": "A je i sigurt që dëshiron të dalësh?",
"back": "Mbrapa",
"close": "Mbyll",
"copy_image": "Kopjo imazhin",
"dark": "E errët",
"disabled": "I çaktivizuar",
"download_original": "Shkarko origjinalin",
"download_paused": "Shkarkimi u pezullua",
"download_settings": "Shkarko",
@@ -544,6 +549,29 @@
"downloading_asset_filename": "Duke shkarkuar asetin {filename}",
"downloading_from_icloud": "Duke shkarkuar nga iCloud",
"downloading_media": "Duke shkarkuar median",
"enable": "Aktivizo",
"error": "Gabim",
"expired": "Skaduar",
"image": "Imazhi",
"info": "Info",
"model": "Modeli",
"name": "Emri",
"none": "Asnjë",
"offline": "Jashtë linje",
"ok": "Në rregull",
"online": "Online",
"path": "Shtegu",
"refresh": "Rifresko",
"rename": "Riemërto",
"search": "Kërko",
"settings": "Cilësimet",
"size": "Madhësia",
"status": "Statusi",
"type": "Lloji",
"unknown": "E panjohur",
"upload_finished": "Ngarkimi përfundoi",
"username": "Emri i përdoruesit",
"version": "Versioni",
"you_dont_have_any_shared_links": "Nuk keni asnjë link të shpërndarë",
"your_wifi_name": "Emri i Wi-Fi tuaj",
"zoom_image": "Zmadho imazhin"
@@ -13,6 +13,10 @@ class DriftMemoryService {
return _repository.getAll(ownerId);
}
Future<List<DriftMemory>> getAllMemories(String ownerId) {
return _repository.getAll(ownerId, onlyToday: false);
}
Future<DriftMemory?> get(String memoryId) {
return _repository.get(memoryId);
}
@@ -9,10 +9,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftMemoryRepository(this._db) : super(_db);
Future<List<DriftMemory>> getAll(String ownerId) async {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
Future<List<DriftMemory>> 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.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc))
..where(_db.memoryEntity.hideAt.isNull() | _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.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)]);
final rows = await query.get();
if (rows.isEmpty) {
+1 -1
View File
@@ -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) {
@@ -39,7 +39,7 @@ class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
Widget build(BuildContext context) {
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,
@@ -7,9 +7,11 @@ 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/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/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -134,7 +136,12 @@ class _CollectionCards extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
children: [
_PeopleCollectionCard(),
_PlacesCollectionCard(),
_LocalAlbumsCollectionCard(),
_MemoriesCollectionCard(),
],
),
),
);
@@ -328,6 +335,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,
),
),
),
],
),
);
},
);
}
}
@visibleForTesting
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
@@ -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<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
}
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
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()),
),
),
);
},
);
}
}
@@ -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();
@@ -116,7 +116,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_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((_) {
@@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
);
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
@@ -29,3 +29,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>(
final service = ref.watch(driftMemoryServiceProvider);
return service.getMemoryLane(userId);
});
final driftAllMemoriesProvider = FutureProvider.autoDispose<List<DriftMemory>>((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);
});
+2
View File
@@ -53,6 +53,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';
@@ -193,6 +194,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: '/'),
+16
View File
@@ -754,6 +754,22 @@ class DriftMapRouteArgs {
int get hashCode => key.hashCode ^ initialLocation.hashCode;
}
/// generated route for
/// [DriftMemoryListPage]
class DriftMemoryListRoute extends PageRouteInfo<void> {
const DriftMemoryListRoute({List<PageRouteInfo>? 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<DriftMemoryRouteArgs> {
+1
View File
@@ -26,6 +26,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'sharedLinks': SharedLinksResponse(enabled: true, sidebarWeb: false).toJson(),
'cast': CastResponse(gCastEnabled: false).toJson(),
'albums': {'defaultAssetOrder': 'desc'},
'recentlyAdded': RecentlyAddedResponse(sidebarWeb: false).toJson(),
},
'ServerConfigDto': {
'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json',
+2
View File
@@ -545,6 +545,8 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [RecentlyAddedResponse](doc//RecentlyAddedResponse.md)
- [RecentlyAddedUpdate](doc//RecentlyAddedUpdate.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
+2
View File
@@ -266,6 +266,8 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/recently_added_response.dart';
part 'model/recently_added_update.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
+24 -6
View File
@@ -265,11 +265,14 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/memories/statistics';
@@ -292,6 +295,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));
}
@@ -331,12 +337,15 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,);
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? 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));
}
@@ -434,11 +443,14 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/memories';
@@ -461,6 +473,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));
}
@@ -500,12 +515,15 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,);
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? 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));
}
+4
View File
@@ -577,6 +577,10 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'RecentlyAddedResponse':
return RecentlyAddedResponse.fromJson(value);
case 'RecentlyAddedUpdate':
return RecentlyAddedUpdate.fromJson(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
+100
View File
@@ -0,0 +1,100 @@
//
// 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 RecentlyAddedResponse {
/// Returns a new [RecentlyAddedResponse] instance.
RecentlyAddedResponse({
required this.sidebarWeb,
});
/// Whether the recently added page appears in the web sidebar
bool sidebarWeb;
@override
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedResponse &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(sidebarWeb.hashCode);
@override
String toString() => 'RecentlyAddedResponse[sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'sidebarWeb'] = this.sidebarWeb;
return json;
}
/// Returns a new [RecentlyAddedResponse] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RecentlyAddedResponse? fromJson(dynamic value) {
upgradeDto(value, "RecentlyAddedResponse");
if (value is Map) {
final json = value.cast<String, dynamic>();
return RecentlyAddedResponse(
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
);
}
return null;
}
static List<RecentlyAddedResponse> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RecentlyAddedResponse>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RecentlyAddedResponse.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RecentlyAddedResponse> mapFromJson(dynamic json) {
final map = <String, RecentlyAddedResponse>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RecentlyAddedResponse.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RecentlyAddedResponse-objects as value to a dart map
static Map<String, List<RecentlyAddedResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RecentlyAddedResponse>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RecentlyAddedResponse.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'sidebarWeb',
};
}
+108
View File
@@ -0,0 +1,108 @@
//
// 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 RecentlyAddedUpdate {
/// Returns a new [RecentlyAddedUpdate] instance.
RecentlyAddedUpdate({
this.sidebarWeb = const Optional.absent(),
});
/// Whether the recently added page appears in the web sidebar
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<bool?> sidebarWeb;
@override
bool operator ==(Object other) => identical(this, other) || other is RecentlyAddedUpdate &&
other.sidebarWeb == sidebarWeb;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
@override
String toString() => 'RecentlyAddedUpdate[sidebarWeb=$sidebarWeb]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.sidebarWeb.isPresent) {
final value = this.sidebarWeb.value;
json[r'sidebarWeb'] = value;
}
return json;
}
/// Returns a new [RecentlyAddedUpdate] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static RecentlyAddedUpdate? fromJson(dynamic value) {
upgradeDto(value, "RecentlyAddedUpdate");
if (value is Map) {
final json = value.cast<String, dynamic>();
return RecentlyAddedUpdate(
sidebarWeb: json.containsKey(r'sidebarWeb') ? Optional.present(mapValueOfType<bool>(json, r'sidebarWeb')) : const Optional.absent(),
);
}
return null;
}
static List<RecentlyAddedUpdate> listFromJson(dynamic json, {bool growable = false,}) {
final result = <RecentlyAddedUpdate>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = RecentlyAddedUpdate.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, RecentlyAddedUpdate> mapFromJson(dynamic json) {
final map = <String, RecentlyAddedUpdate>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = RecentlyAddedUpdate.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of RecentlyAddedUpdate-objects as value to a dart map
static Map<String, List<RecentlyAddedUpdate>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<RecentlyAddedUpdate>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = RecentlyAddedUpdate.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
+9 -1
View File
@@ -22,6 +22,7 @@ class UserPreferencesResponseDto {
required this.people,
required this.purchase,
required this.ratings,
required this.recentlyAdded,
required this.sharedLinks,
required this.tags,
});
@@ -44,6 +45,8 @@ class UserPreferencesResponseDto {
RatingsResponse ratings;
RecentlyAddedResponse recentlyAdded;
SharedLinksResponse sharedLinks;
TagsResponse tags;
@@ -59,6 +62,7 @@ class UserPreferencesResponseDto {
other.people == people &&
other.purchase == purchase &&
other.ratings == ratings &&
other.recentlyAdded == recentlyAdded &&
other.sharedLinks == sharedLinks &&
other.tags == tags;
@@ -74,11 +78,12 @@ class UserPreferencesResponseDto {
(people.hashCode) +
(purchase.hashCode) +
(ratings.hashCode) +
(recentlyAdded.hashCode) +
(sharedLinks.hashCode) +
(tags.hashCode);
@override
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesResponseDto[albums=$albums, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -91,6 +96,7 @@ class UserPreferencesResponseDto {
json[r'people'] = this.people;
json[r'purchase'] = this.purchase;
json[r'ratings'] = this.ratings;
json[r'recentlyAdded'] = this.recentlyAdded;
json[r'sharedLinks'] = this.sharedLinks;
json[r'tags'] = this.tags;
return json;
@@ -114,6 +120,7 @@ class UserPreferencesResponseDto {
people: PeopleResponse.fromJson(json[r'people'])!,
purchase: PurchaseResponse.fromJson(json[r'purchase'])!,
ratings: RatingsResponse.fromJson(json[r'ratings'])!,
recentlyAdded: RecentlyAddedResponse.fromJson(json[r'recentlyAdded'])!,
sharedLinks: SharedLinksResponse.fromJson(json[r'sharedLinks'])!,
tags: TagsResponse.fromJson(json[r'tags'])!,
);
@@ -172,6 +179,7 @@ class UserPreferencesResponseDto {
'people',
'purchase',
'ratings',
'recentlyAdded',
'sharedLinks',
'tags',
};
+17 -1
View File
@@ -23,6 +23,7 @@ class UserPreferencesUpdateDto {
this.people = const Optional.absent(),
this.purchase = const Optional.absent(),
this.ratings = const Optional.absent(),
this.recentlyAdded = const Optional.absent(),
this.sharedLinks = const Optional.absent(),
this.tags = const Optional.absent(),
});
@@ -107,6 +108,14 @@ class UserPreferencesUpdateDto {
///
Optional<RatingsUpdate?> ratings;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Optional<RecentlyAddedUpdate?> recentlyAdded;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@@ -135,6 +144,7 @@ class UserPreferencesUpdateDto {
other.people == people &&
other.purchase == purchase &&
other.ratings == ratings &&
other.recentlyAdded == recentlyAdded &&
other.sharedLinks == sharedLinks &&
other.tags == tags;
@@ -151,11 +161,12 @@ class UserPreferencesUpdateDto {
(people == null ? 0 : people!.hashCode) +
(purchase == null ? 0 : purchase!.hashCode) +
(ratings == null ? 0 : ratings!.hashCode) +
(recentlyAdded == null ? 0 : recentlyAdded!.hashCode) +
(sharedLinks == null ? 0 : sharedLinks!.hashCode) +
(tags == null ? 0 : tags!.hashCode);
@override
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesUpdateDto[albums=$albums, avatar=$avatar, cast=$cast, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, recentlyAdded=$recentlyAdded, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -199,6 +210,10 @@ class UserPreferencesUpdateDto {
final value = this.ratings.value;
json[r'ratings'] = value;
}
if (this.recentlyAdded.isPresent) {
final value = this.recentlyAdded.value;
json[r'recentlyAdded'] = value;
}
if (this.sharedLinks.isPresent) {
final value = this.sharedLinks.value;
json[r'sharedLinks'] = value;
@@ -229,6 +244,7 @@ class UserPreferencesUpdateDto {
people: json.containsKey(r'people') ? Optional.present(PeopleUpdate.fromJson(json[r'people'])) : const Optional.absent(),
purchase: json.containsKey(r'purchase') ? Optional.present(PurchaseUpdate.fromJson(json[r'purchase'])) : const Optional.absent(),
ratings: json.containsKey(r'ratings') ? Optional.present(RatingsUpdate.fromJson(json[r'ratings'])) : const Optional.absent(),
recentlyAdded: json.containsKey(r'recentlyAdded') ? Optional.present(RecentlyAddedUpdate.fromJson(json[r'recentlyAdded'])) : const Optional.absent(),
sharedLinks: json.containsKey(r'sharedLinks') ? Optional.present(SharedLinksUpdate.fromJson(json[r'sharedLinks'])) : const Optional.absent(),
tags: json.containsKey(r'tags') ? Optional.present(TagsUpdate.fromJson(json[r'tags'])) : const Optional.absent(),
);
@@ -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));
+50
View File
@@ -7171,6 +7171,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,
@@ -7340,6 +7351,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,
@@ -21991,6 +22013,27 @@
],
"type": "string"
},
"RecentlyAddedResponse": {
"properties": {
"sidebarWeb": {
"description": "Whether the recently added page appears in the web sidebar",
"type": "boolean"
}
},
"required": [
"sidebarWeb"
],
"type": "object"
},
"RecentlyAddedUpdate": {
"properties": {
"sidebarWeb": {
"description": "Whether the recently added page appears in the web sidebar",
"type": "boolean"
}
},
"type": "object"
},
"ReleaseChannel": {
"description": "Release channel",
"enum": [
@@ -27525,6 +27568,9 @@
"ratings": {
"$ref": "#/components/schemas/RatingsResponse"
},
"recentlyAdded": {
"$ref": "#/components/schemas/RecentlyAddedResponse"
},
"sharedLinks": {
"$ref": "#/components/schemas/SharedLinksResponse"
},
@@ -27542,6 +27588,7 @@
"people",
"purchase",
"ratings",
"recentlyAdded",
"sharedLinks",
"tags"
],
@@ -27579,6 +27626,9 @@
"ratings": {
"$ref": "#/components/schemas/RatingsUpdate"
},
"recentlyAdded": {
"$ref": "#/components/schemas/RecentlyAddedUpdate"
},
"sharedLinks": {
"$ref": "#/components/schemas/SharedLinksUpdate"
},
+16 -2
View File
@@ -342,6 +342,10 @@ export type RatingsResponse = {
/** Whether ratings are enabled */
enabled: boolean;
};
export type RecentlyAddedResponse = {
/** Whether the recently added page appears in the web sidebar */
sidebarWeb: boolean;
};
export type SharedLinksResponse = {
/** Whether shared links are enabled */
enabled: boolean;
@@ -364,6 +368,7 @@ export type UserPreferencesResponseDto = {
people: PeopleResponse;
purchase: PurchaseResponse;
ratings: RatingsResponse;
recentlyAdded: RecentlyAddedResponse;
sharedLinks: SharedLinksResponse;
tags: TagsResponse;
};
@@ -421,6 +426,10 @@ export type RatingsUpdate = {
/** Whether ratings are enabled */
enabled?: boolean;
};
export type RecentlyAddedUpdate = {
/** Whether the recently added page appears in the web sidebar */
sidebarWeb?: boolean;
};
export type SharedLinksUpdate = {
/** Whether shared links are enabled */
enabled?: boolean;
@@ -444,6 +453,7 @@ export type UserPreferencesUpdateDto = {
people?: PeopleUpdate;
purchase?: PurchaseUpdate;
ratings?: RatingsUpdate;
recentlyAdded?: RecentlyAddedUpdate;
sharedLinks?: SharedLinksUpdate;
tags?: TagsUpdate;
};
@@ -4970,11 +4980,12 @@ 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) {
@@ -4986,6 +4997,7 @@ export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }:
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
@@ -5010,11 +5022,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) {
@@ -5026,6 +5039,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
+1
View File
@@ -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' });
+15
View File
@@ -98,6 +98,13 @@ const CastUpdateSchema = z
.optional()
.meta({ id: 'CastUpdate' });
const RecentlyAddedUpdateSchema = z
.object({
sidebarWeb: z.boolean().optional().describe('Whether the recently added page appears in the web sidebar'),
})
.optional()
.meta({ id: 'RecentlyAddedUpdate' });
const UserPreferencesUpdateSchema = z
.object({
albums: AlbumsUpdateSchema,
@@ -112,6 +119,7 @@ const UserPreferencesUpdateSchema = z
ratings: RatingsUpdateSchema,
sharedLinks: SharedLinksUpdateSchema,
tags: TagsUpdateSchema,
recentlyAdded: RecentlyAddedUpdateSchema,
})
.meta({ id: 'UserPreferencesUpdateDto' });
@@ -191,6 +199,12 @@ const CastResponseSchema = z
})
.meta({ id: 'CastResponse' });
const RecentlyAddedResponseSchema = z
.object({
sidebarWeb: z.boolean().describe('Whether the recently added page appears in the web sidebar'),
})
.meta({ id: 'RecentlyAddedResponse' });
const UserPreferencesResponseSchema = z
.object({
albums: AlbumsResponseSchema,
@@ -204,6 +218,7 @@ const UserPreferencesResponseSchema = z
download: DownloadResponseSchema,
purchase: PurchaseResponseSchema,
cast: CastResponseSchema,
recentlyAdded: RecentlyAddedResponseSchema,
})
.meta({ id: 'UserPreferencesResponseDto' });
@@ -90,6 +90,7 @@ export class MemoryRepository implements IBulkAsset {
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
)
.$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();
}
@@ -35,6 +35,7 @@ describe(MemoryService.name, () => {
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.statistics.mockResolvedValue({ total: 2 });
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([
@@ -44,6 +45,8 @@ describe(MemoryService.name, () => {
}),
]),
);
mocks.memory.search.mockResolvedValue([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
});
it('should map empty result', async () => {
+3
View File
@@ -619,6 +619,9 @@ export type UserPreferences = {
cast: {
gCastEnabled: boolean;
};
recentlyAdded: {
sidebarWeb: boolean;
};
};
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
+3
View File
@@ -50,6 +50,9 @@ const getDefaultPreferences = (): UserPreferences => {
cast: {
gCastEnabled: false,
},
recentlyAdded: {
sidebarWeb: false,
},
};
};
@@ -31,6 +31,7 @@
mdiToolboxOutline,
mdiTrashCan,
mdiTrashCanOutline,
mdiUploadOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -83,6 +84,14 @@
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if}
{#if authManager.preferences.recentlyAdded.sidebarWeb}
<NavbarItem
title={$t('recently_added')}
href={Route.recentlyAdded()}
icon={{ icon: mdiUploadOutline, flipped: true }}
/>
{/if}
{#if authManager.preferences.folders.enabled && authManager.preferences.folders.sidebarWeb}
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if}
+83 -8
View File
@@ -1,9 +1,17 @@
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
import {
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
MemorySearchOrder,
MemoryType,
memoriesStatistics,
} 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 = {
@@ -20,10 +28,31 @@ export type MemoryAsset = MemoryIndex & {
nextMemory?: MemoryResponseDto;
};
const PAGE_SIZE = 250;
class MemoryManager {
#loading: Promise<void> | undefined;
#filters:
| {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}
| undefined;
#hasNextPage: boolean;
#page: number;
#total: number | undefined;
constructor() {
this.#filters = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.#total = $state(undefined);
eventManager.on({
AuthLogout: () => this.clearCache(),
AuthUserLoaded: () => this.initialize(),
@@ -37,6 +66,16 @@ class MemoryManager {
this.scheduleHourlyRefresh();
}
get filters() {
return this.#filters;
}
set filters(filters) {
this.#filters = filters;
this.clearCache();
void this.loadNextPage();
}
ready() {
return this.initialize();
}
@@ -117,22 +156,52 @@ 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.#total = undefined;
this.memories = [];
}
private initialize() {
if (!this.#loading) {
this.#loading = this.load();
this.#loading = this.load(this.#page++);
}
return this.#loading;
}
private async load() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
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;
}
}
private scheduleHourlyRefresh() {
@@ -146,12 +215,18 @@ class MemoryManager {
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
this.#loading = this.load();
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
// Schedule subsequent events hourly
setInterval(
() => {
this.#loading = this.load();
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
},
60 * 60 * 1000,
);
+1 -1
View File
@@ -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}`,
+8 -3
View File
@@ -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 { convertBCP47, langs } from '$lib/utils/i18n';
@@ -366,9 +367,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): 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');
+29 -6
View File
@@ -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 @@
</div>
{/if}
{#if memories.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('memories')}</p>
<a
href={Route.memories()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<ImageCarousel items={memories} />
</div>
{/if}
{#if recents.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
+12 -4
View File
@@ -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'),
},
@@ -0,0 +1,127 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import type { PageData } from './$types';
import { Route } from '$lib/route';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import { t } from 'svelte-i18n';
import { mdiHeartOutline, mdiHeart } from '@mdi/js';
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';
import { page } from '$app/state';
import MemoryViewer from './MemoryViewer.svelte';
import { QueryParameter } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { clearQueryParam, setQueryValue } from '$lib/utils/navigation';
interface Props {
data: PageData;
}
let { data }: Props = $props();
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();
if (onlyFavorites) {
void setQueryValue('favorites', 'true');
} else {
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);
}
});
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)];
};
</script>
{#if page.url.searchParams.has(QueryParameter.ID)}
<MemoryViewer />
{:else}
<UserPageLayout
title={data.meta.title}
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<Button
leadingIcon={mdiHeartOutline}
size="small"
variant={onlyFavorites ? 'filled' : 'ghost'}
color="secondary"
onclick={() => toggleFavorites()}>{$t('only_favorites')}</Button
>
</div>
{/snippet}
{#if memoryManager.memories.length > 0}
<div class="grid w-full grid-cols-3 gap-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{#each memoryManager.memories as memory, index (memory.id)}
<a
href={Route.memories({ id: memory.assets[0].id })}
class={`relative rounded-md bg-gray-50 p-2 pb-0 shadow-md transition-all hover:scale-102 hover:rotate-0 hover:shadow-lg sm:p-5 sm:pb-0 dark:bg-gray-800 ${rotation()}`}
bind:this={
() => (index === memoryManager.memories.length - 1 ? lastElement : null),
(e) => {
if (index === memoryManager.memories.length - 1) {
lastElement = e;
}
}
}
>
{#if memory.isSaved}
<div class="absolute inset-s-2 top-2 z-2">
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-red-400" />
</div>
{/if}
<img
src={getAssetMediaUrl({ id: memory.assets[0].id })}
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
class="aspect-square object-cover brightness-75"
loading="lazy"
/>
<p class="my-2 text-center text-sm font-medium text-ellipsis capitalize hover:cursor-pointer sm:my-5">
{$memoryLaneTitle(memory)}
</p>
</a>
{/each}
</div>
{:else if memoryManager.total === undefined}
<div class="flex items-center justify-center py-16">
<LoadingSpinner size="giant" />
</div>
{/if}
</UserPageLayout>
{/if}
@@ -0,0 +1,27 @@
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';
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,
meta: {
title: $t('memories'),
},
};
}) satisfies PageLoad;
@@ -82,6 +82,7 @@
let progressBarController: Tween<number> | 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)}
/>
<p class="text-lg">
{$memoryLaneTitle(current.memory)}
@@ -1,5 +0,0 @@
<script>
import MemoryViewer from './MemoryViewer.svelte';
</script>
<MemoryViewer />
@@ -1,15 +0,0 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
const user = await authenticate(url);
const $t = await getFormatter();
return {
user,
meta: {
title: $t('memory'),
},
};
}) satisfies PageLoad;
@@ -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<TimelineManager>() 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()) };
}
</script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>
@@ -38,6 +38,9 @@
// Cast
let gCastEnabled = $state(authManager.preferences.cast?.gCastEnabled ?? false);
// Recently added
let recentlyAddedSidebar = $state(authManager.preferences.recentlyAdded?.sidebarWeb ?? false);
const handleSave = async () => {
try {
const response = await updateMyPreferences({
@@ -50,6 +53,7 @@
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
cast: { gCastEnabled },
recentlyAdded: { sidebarWeb: recentlyAddedSidebar },
},
});
@@ -170,6 +174,14 @@
</div>
</SettingAccordion>
<SettingAccordion key="recentlyAdded" title={$t('recently_added')} subtitle={$t('recently_added_description')}>
<div class="mt-4 flex flex-col gap-4 sm:ms-4">
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
<Switch bind:checked={recentlyAddedSidebar} />
</Field>
</div>
</SettingAccordion>
<div class="mt-4 flex justify-end">
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
</div>
@@ -44,4 +44,7 @@ export const preferencesFactory = Sync.makeFactory<UserPreferencesResponseDto>({
enabled: false,
sidebarWeb: false,
},
recentlyAdded: {
sidebarWeb: false,
},
});