Lazy load activity log and start from latest

pull/28628/head
Victor Chang 2026-03-08 22:02:10 +00:00
parent 5386b62dc4
commit f96444ae83
12 changed files with 226 additions and 21 deletions

View File

@ -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,

View File

@ -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 <share-album-id, asset>
@ -13,11 +15,46 @@ class AlbumActivity extends AutoDisposeFamilyAsyncNotifier<List<Activity>, (Stri
late String albumId;
late String? assetId;
bool _hasMore = true;
bool _isLoadingMore = false;
bool get hasMore => _hasMore;
bool get isLoadingMore => _isLoadingMore;
@override
Future<List<Activity>> 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<void> 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<void> removeActivity(String id) async {

View File

@ -14,8 +14,8 @@ class ActivityApiRepository extends ApiRepository {
ActivityApiRepository(this._api);
Future<List<Activity>> getAll(String albumId, {String? assetId}) async {
final response = await checkNull(_api.getActivities(albumId, assetId: assetId));
Future<List<Activity>> 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();
}

View File

@ -20,9 +20,9 @@ class ActivityService with ErrorLoggerMixin {
ActivityService(this._activityApiRepository, this._timelineFactory, this._assetService);
Future<List<Activity>> getAllActivities(String albumId, {String? assetId}) async {
Future<List<Activity>> 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",
);

View File

@ -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<Response> 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<Response> 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<List<ActivityResponseDto>?> 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<List<ActivityResponseDto>?> 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));
}

View File

@ -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,

View File

@ -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

View File

@ -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({

View File

@ -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();
}

View File

@ -19,14 +19,37 @@ import { BaseService } from 'src/services/base.service';
export class ActivityService extends BaseService {
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
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<ActivityStatisticsResponseDto> {

View File

@ -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<ReactionType, string> = {
const deleteMessages: Partial<Record<ReactionType, string>> = {
[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 @@
<div
class="relative w-full immich-scrollbar overflow-y-auto px-2"
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
bind:this={scrollContainer}
onscroll={onScrollContainer}
>
{#if activityManager.isLoadingMore}
<div class="flex justify-center py-3">
<LoadingSpinner />
</div>
{/if}
{#each activityManager.activities as reaction, index (reaction.id)}
{#if reaction.type === ReactionType.Comment}
<div class="mt-3 flex justify-start gap-4 rounded-lg bg-gray-200 py-3 ps-3 dark:bg-gray-800">

View File

@ -20,19 +20,24 @@ type ActivityCache = {
commentCount: number;
likeCount: number;
isLiked: ActivityResponseDto | null;
hasMore: boolean;
};
class ActivityManager {
static readonly PAGE_SIZE = 50;
#albumId = $state<string | undefined>();
#assetId = $state<string | undefined>();
#activities = $state<ActivityResponseDto[]>([]);
#commentCount = $state(0);
#likeCount = $state(0);
#isLiked = $state<ActivityResponseDto | null>(null);
#hasMore = $state(true);
#cache = new Map<CacheKey, ActivityCache>();
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;
}
}