diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index b998e10dc2..8a294116ec 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -34,6 +34,20 @@ class DriftActivitiesPage extends HookConsumerWidget { scrollToBottom(); } + useEffect(() { + void onScroll() { + // In a reversed ListView, scrolling toward older items means reaching maxScrollExtent + if (listViewScrollController.position.pixels >= + listViewScrollController.position.maxScrollExtent - 200) { + if (activityNotifier.hasMore && !activityNotifier.isLoadingMore) { + activityNotifier.loadMore(); + } + } + } + listViewScrollController.addListener(onScroll); + return () => listViewScrollController.removeListener(onScroll); + }, [listViewScrollController]); + return ProviderScope( overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( @@ -67,7 +81,14 @@ class DriftActivitiesPage extends HookConsumerWidget { controller: listViewScrollController, padding: const EdgeInsets.only(top: 8, bottom: 80), reverse: true, - children: activityWidgets, + children: [ + ...activityWidgets, + if (activityNotifier.isLoadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator.adaptive()), + ), + ], ), Align( alignment: Alignment.bottomCenter, diff --git a/mobile/lib/providers/activity.provider.dart b/mobile/lib/providers/activity.provider.dart index b2cdbcf18c..7e99d8ae3d 100644 --- a/mobile/lib/providers/activity.provider.dart +++ b/mobile/lib/providers/activity.provider.dart @@ -3,6 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; +const _pageSize = 50; + // ignore: unintended_html_in_doc_comment /// Maintains the current list of all activities for @@ -13,11 +15,46 @@ class AlbumActivity extends AutoDisposeFamilyAsyncNotifier, (Stri late String albumId; late String? assetId; + bool _hasMore = true; + bool _isLoadingMore = false; + + bool get hasMore => _hasMore; + bool get isLoadingMore => _isLoadingMore; + @override Future> build((String albumId, String? assetId) args) async { albumId = args.$1; assetId = args.$2; - return ref.watch(activityServiceProvider).getAllActivities(albumId, assetId: assetId); + _hasMore = true; + _isLoadingMore = false; + final activities = await ref.watch(activityServiceProvider).getAllActivities( + albumId, + assetId: assetId, + take: _pageSize, + ); + _hasMore = activities.length >= _pageSize; + return activities; + } + + Future loadMore() async { + final activities = state.valueOrNull; + if (!_hasMore || _isLoadingMore || activities == null || activities.isEmpty) { + return; + } + + _isLoadingMore = true; + try { + final older = await ref.watch(activityServiceProvider).getAllActivities( + albumId, + assetId: assetId, + take: _pageSize, + before: activities.first.createdAt, + ); + _hasMore = older.length >= _pageSize; + state = AsyncData([...older, ...activities]); + } finally { + _isLoadingMore = false; + } } Future removeActivity(String id) async { diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart index e8f9abc8c8..bcfa688429 100644 --- a/mobile/lib/repositories/activity_api.repository.dart +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -14,8 +14,8 @@ class ActivityApiRepository extends ApiRepository { ActivityApiRepository(this._api); - Future> getAll(String albumId, {String? assetId}) async { - final response = await checkNull(_api.getActivities(albumId, assetId: assetId)); + Future> getAll(String albumId, {String? assetId, int? take, DateTime? before}) async { + final response = await checkNull(_api.getActivities(albumId, assetId: assetId, take: take, before: before)); return response.map(_toActivity).toList(); } diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 0d4709d0d5..67fc39fcc9 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -20,9 +20,9 @@ class ActivityService with ErrorLoggerMixin { ActivityService(this._activityApiRepository, this._timelineFactory, this._assetService); - Future> getAllActivities(String albumId, {String? assetId}) async { + Future> getAllActivities(String albumId, {String? assetId, int? take, DateTime? before}) async { return logError( - () => _activityApiRepository.getAll(albumId, assetId: assetId), + () => _activityApiRepository.getAll(albumId, assetId: assetId, take: take, before: before), defaultValue: [], errorMessage: "Failed to get all activities for album $albumId", ); diff --git a/mobile/openapi/lib/api/activities_api.dart b/mobile/openapi/lib/api/activities_api.dart index e0a393948c..d1c43addda 100644 --- a/mobile/openapi/lib/api/activities_api.dart +++ b/mobile/openapi/lib/api/activities_api.dart @@ -136,12 +136,20 @@ class ActivitiesApi { /// Asset ID (if activity is for an asset) /// /// * [ReactionLevel] level: + /// Filter by activity level /// /// * [ReactionType] type: + /// Filter by activity type /// /// * [String] userId: /// Filter by user ID - Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async { + /// + /// * [DateTime] before: + /// Return activities created before this date (for pagination) + /// + /// * [int] take: + /// Maximum number of activities to return + Future getActivitiesWithHttpInfo(String albumId, { String? assetId, DateTime? before, ReactionLevel? level, ReactionType? type, int? take, String? userId, }) async { // ignore: prefer_const_declarations final apiPath = r'/activities'; @@ -156,12 +164,18 @@ class ActivitiesApi { if (assetId != null) { queryParams.addAll(_queryParams('', 'assetId', assetId)); } + if (before != null) { + queryParams.addAll(_queryParams('', 'before', before.toUtc().toIso8601String())); + } if (level != null) { queryParams.addAll(_queryParams('', 'level', level)); } if (type != null) { queryParams.addAll(_queryParams('', 'type', type)); } + if (take != null) { + queryParams.addAll(_queryParams('', 'take', take)); + } if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } @@ -193,13 +207,21 @@ class ActivitiesApi { /// Asset ID (if activity is for an asset) /// /// * [ReactionLevel] level: + /// Filter by activity level /// /// * [ReactionType] type: + /// Filter by activity type /// /// * [String] userId: /// Filter by user ID - Future?> getActivities(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async { - final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, level: level, type: type, userId: userId, ); + /// + /// * [DateTime] before: + /// Return activities created before this date (for pagination) + /// + /// * [int] take: + /// Maximum number of activities to return + Future?> getActivities(String albumId, { String? assetId, DateTime? before, ReactionLevel? level, ReactionType? type, int? take, String? userId, }) async { + final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, before: before, level: level, type: type, take: take, userId: userId, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index badf9ce25d..3e539a73de 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -28,6 +28,16 @@ "type": "string" } }, + { + "name": "before", + "required": false, + "in": "query", + "description": "Return activities created before this date (for pagination)", + "schema": { + "format": "date-time", + "type": "string" + } + }, { "name": "level", "required": false, @@ -36,6 +46,16 @@ "$ref": "#/components/schemas/ReactionLevel" } }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of activities to return", + "schema": { + "minimum": 1, + "type": "integer" + } + }, { "name": "type", "required": false, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index e82074d02c..e8a9ad1752 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -3195,11 +3195,13 @@ export type SyncUserV1 = { /** * List all activities */ -export function getActivities({ albumId, assetId, level, $type, userId }: { +export function getActivities({ albumId, assetId, before, level, $type, take, userId }: { albumId: string; assetId?: string; + before?: Date; level?: ReactionLevel; $type?: ReactionType; + take?: number; userId?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3208,8 +3210,10 @@ export function getActivities({ albumId, assetId, level, $type, userId }: { }>(`/activities${QS.query(QS.explode({ albumId, assetId, + before, level, "type": $type, + take, userId }))}`, { ...opts diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts index facd4ed256..58cd212f1e 100644 --- a/server/src/dtos/activity.dto.ts +++ b/server/src/dtos/activity.dto.ts @@ -45,6 +45,8 @@ const ActivitySearchSchema = ActivitySchema.extend({ type: ReactionTypeSchema.optional(), level: ReactionLevelSchema.optional(), userId: z.uuidv4().optional().describe('Filter by user ID'), + take: z.coerce.number().int().min(1).optional().describe('Maximum number of activities to return'), + before: isoDatetimeToDate.optional().describe('Return activities created before this date (for pagination)'), }); const ActivityCreateSchema = ActivitySchema.extend({ diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 69fa02f59d..840369b579 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -14,6 +14,9 @@ export interface ActivitySearch { assetId?: string | null; userId?: string; isLiked?: boolean; + take?: number; + before?: Date; + at?: Date; } @Injectable() @@ -22,7 +25,7 @@ export class ActivityRepository { @GenerateSql({ params: [{ albumId: DummyValue.UUID }] }) search(options: ActivitySearch) { - const { userId, assetId, albumId, isLiked } = options; + const { userId, assetId, albumId, isLiked, take, before, at } = options; return this.db .selectFrom('activity') @@ -42,7 +45,10 @@ export class ActivityRepository { .$if(!!albumId, (qb) => qb.where('activity.albumId', '=', albumId!)) .$if(isLiked !== undefined, (qb) => qb.where('activity.isLiked', '=', isLiked!)) .where('asset.deletedAt', 'is', null) - .orderBy('activity.createdAt', 'asc') + .$if(!!before, (qb) => qb.where('activity.createdAt', '<', before!)) + .$if(!!at, (qb) => qb.where('activity.createdAt', '=', at!)) + .orderBy('activity.createdAt', take !== undefined ? 'desc' : 'asc') + .$if(take !== undefined, (qb) => qb.limit(take!)) .execute(); } diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index b1c25f8286..b00d93d64a 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -19,14 +19,37 @@ import { BaseService } from 'src/services/base.service'; export class ActivityService extends BaseService { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [dto.albumId] }); - const activities = await this.activityRepository.search({ + + const { take, before } = dto; + const searchOptions = { userId: dto.userId, albumId: dto.albumId, assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId, isLiked: dto.type && dto.type === ReactionType.LIKE, - }); + }; - return activities.map((activity) => mapActivity(activity)); + const activities = await this.activityRepository.search({ ...searchOptions, take, before }); + const results = activities.map((activity) => mapActivity(activity)); + + if (take !== undefined) { + // Paginated: query returned DESC-ordered rows; reverse to return ASC + results.reverse(); + + // Complete the boundary timestamp group: the oldest item's createdAt may be shared + // by other activities not returned due to the take limit (e.g. bulk operations). + // Fetch all activities at exactly that timestamp and prepend any not already loaded. + if (results.length > 0) { + const boundaryTime = results[0].createdAt; + const loadedIds = new Set(results.filter((a) => +new Date(a.createdAt) === +new Date(boundaryTime)).map((a) => a.id)); + const extras = await this.activityRepository.search({ ...searchOptions, at: boundaryTime }); + const newExtras = extras.map(mapActivity).filter((a) => !loadedIds.has(a.id)); + return [...newExtras, ...results]; + } + + return results; + } + + return results; } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { diff --git a/web/src/lib/components/asset-viewer/ActivityViewer.svelte b/web/src/lib/components/asset-viewer/ActivityViewer.svelte index 803495c8bb..f58715b7e1 100644 --- a/web/src/lib/components/asset-viewer/ActivityViewer.svelte +++ b/web/src/lib/components/asset-viewer/ActivityViewer.svelte @@ -17,6 +17,7 @@ import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js'; import * as luxon from 'luxon'; import { t } from 'svelte-i18n'; + import { tick } from 'svelte'; import { fromAction } from 'svelte/attachments'; import UserAvatar from '../shared-components/UserAvatar.svelte'; @@ -71,11 +72,11 @@ try { await activityManager.deleteActivity(reaction, index); - const deleteMessages: Record = { + const deleteMessages: Partial> = { [ReactionType.Comment]: $t('comment_deleted'), [ReactionType.Like]: $t('like_deleted'), }; - toastManager.primary(deleteMessages[reaction.type]); + toastManager.primary(deleteMessages[reaction.type] ?? $t('removed')); } catch (error) { handleError(error, $t('errors.unable_to_remove_reaction')); } @@ -98,12 +99,43 @@ isSendingMessage = false; }; + let scrollContainer: HTMLElement | undefined = $state(); + let hasScrolledToBottom = false; + let isAdjustingScroll = false; + $effect(() => { if (assetId && previousAssetId != assetId) { previousAssetId = assetId; + hasScrolledToBottom = false; } }); + $effect(() => { + // Scroll to bottom only on the initial load of activities + if (scrollContainer && activityManager.activities.length > 0 && !hasScrolledToBottom) { + hasScrolledToBottom = true; + scrollContainer.scrollTop = scrollContainer.scrollHeight; + } + }); + + const loadMoreAndPreserveScroll = async () => { + if (!scrollContainer) return; + const prevScrollHeight = scrollContainer.scrollHeight; + const prevScrollTop = scrollContainer.scrollTop; + await activityManager.loadMore(); + await tick(); + isAdjustingScroll = true; + scrollContainer.scrollTop = prevScrollTop + (scrollContainer.scrollHeight - prevScrollHeight); + isAdjustingScroll = false; + }; + + const onScrollContainer = () => { + if (isAdjustingScroll || !scrollContainer || activityManager.isLoadingMore || !activityManager.hasMore) return; + if (scrollContainer.scrollTop < 200) { + void loadMoreAndPreserveScroll(); + } + }; + const onsubmit = async (event: Event) => { event.preventDefault(); await handleSendComment(); @@ -130,7 +162,14 @@
+ {#if activityManager.isLoadingMore} +
+ +
+ {/if} {#each activityManager.activities as reaction, index (reaction.id)} {#if reaction.type === ReactionType.Comment}
diff --git a/web/src/lib/managers/activity-manager.svelte.ts b/web/src/lib/managers/activity-manager.svelte.ts index 44dc0cfb41..1842dd9894 100644 --- a/web/src/lib/managers/activity-manager.svelte.ts +++ b/web/src/lib/managers/activity-manager.svelte.ts @@ -20,19 +20,24 @@ type ActivityCache = { commentCount: number; likeCount: number; isLiked: ActivityResponseDto | null; + hasMore: boolean; }; class ActivityManager { + static readonly PAGE_SIZE = 50; + #albumId = $state(); #assetId = $state(); #activities = $state([]); #commentCount = $state(0); #likeCount = $state(0); #isLiked = $state(null); + #hasMore = $state(true); #cache = new Map(); isLoading = $state(false); + isLoadingMore = $state(false); get assetId() { return this.#assetId; @@ -54,6 +59,10 @@ class ActivityManager { return this.#isLiked; } + get hasMore() { + return this.#hasMore; + } + #getCacheKey(albumId: string, assetId?: string) { return `${albumId}:${assetId ?? ''}`; } @@ -111,9 +120,7 @@ class ActivityManager { this.#likeCount--; } - this.#activities = index - ? this.#activities.splice(index, 1) - : this.#activities.filter(({ id }) => id !== activity.id); + this.#activities = this.#activities.filter(({ id }) => id !== activity.id); await deleteActivity({ id: activity.id }); this.#invalidateCache(this.#albumId, this.#assetId); @@ -148,11 +155,13 @@ class ActivityManager { this.#commentCount = cached.commentCount; this.#likeCount = cached.likeCount; this.#isLiked = cached.isLiked ?? null; + this.#hasMore = cached.hasMore; this.isLoading = false; return; } - this.#activities = await getActivities({ albumId, assetId }); + this.#activities = await getActivities({ albumId, assetId, take: ActivityManager.PAGE_SIZE }); + this.#hasMore = this.#activities.length >= ActivityManager.PAGE_SIZE; const [liked] = await getActivities({ albumId, @@ -172,17 +181,39 @@ class ActivityManager { commentCount: this.#commentCount, likeCount: this.#likeCount, isLiked: this.#isLiked, + hasMore: this.#hasMore, }); this.isLoading = false; } + async loadMore() { + if (!this.#albumId || !this.#hasMore || this.isLoadingMore || this.#activities.length === 0) { + return; + } + + this.isLoadingMore = true; + try { + const older = await getActivities({ + albumId: this.#albumId, + assetId: this.#assetId, + take: ActivityManager.PAGE_SIZE, + before: this.#activities[0].createdAt, + }); + this.#activities = [...older, ...this.#activities]; + this.#hasMore = older.length >= ActivityManager.PAGE_SIZE; + } finally { + this.isLoadingMore = false; + } + } + reset() { this.#albumId = undefined; this.#assetId = undefined; this.#activities = []; this.#commentCount = 0; this.#likeCount = 0; + this.#hasMore = true; } }