Lazy load activity log and start from latest
parent
5386b62dc4
commit
f96444ae83
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue