From dae5a49c4b63189f9d5e30f629eab5d357601b78 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 20 Sep 2026 14:50:58 +0200 Subject: [PATCH] Limit anonymous community activity queries to the latest page. Skip unused activity sources and historical over-fetching for guests while keeping authenticated feeds filterable. --- app/Services/CommunityActivityService.php | 73 +++++++++++++++++++---- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/app/Services/CommunityActivityService.php b/app/Services/CommunityActivityService.php index c5823d54..6faa0c55 100644 --- a/app/Services/CommunityActivityService.php +++ b/app/Services/CommunityActivityService.php @@ -33,8 +33,10 @@ final class CommunityActivityService public function getFeed(?User $viewer, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?int $actorUserId = null): array { $normalizedFilter = $this->normalizeFilter($filter); - $resolvedPage = max(1, $page); - $resolvedPerPage = max(1, min(50, $perPage)); + $resolvedPage = $viewer ? max(1, $page) : 1; + $resolvedPerPage = $viewer + ? max(1, min(50, $perPage)) + : 20; $cacheKey = sprintf( 'community_activity:%s:%d:%d:%d:%d', @@ -68,20 +70,55 @@ final class CommunityActivityService private function buildFeed(?User $viewer, string $filter, int $page, int $perPage, ?int $actorUserId): array { - $sourceLimit = max(80, $page * $perPage * 6); + // Guests only receive the latest page. Since every source is ordered + // newest-first, one page per source is sufficient to produce the + // globally newest page after merging, while avoiding historical + // over-fetching for anonymous traffic. + $sourceLimit = $viewer === null + ? $perPage + : max(80, $page * $perPage * 6); $followingIds = $filter === self::FILTER_FOLLOWING && $viewer ? $viewer->following()->pluck('users.id')->map(fn ($id) => (int) $id)->all() : []; + $actorIds = match ($filter) { + self::FILTER_FOLLOWING => $followingIds, + self::FILTER_MY => $viewer ? [(int) $viewer->id] : [], + default => null, + }; - $commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false); - $replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true); - $reactionModels = $this->fetchReactionModels($sourceLimit); - $recordedActivities = $this->fetchRecordedActivities($sourceLimit); + if ($actorUserId !== null) { + $actorIds = $actorIds === null + ? [(int) $actorUserId] + : array_values(array_intersect($actorIds, [(int) $actorUserId])); + } + + // A comments/replies feed can only contain its matching comment type. + // Avoid loading and hydrating the other activity sources only to discard + // them in the merge filter below. Actor-scoped filters also constrain + // each source query while preserving the final in-memory safety filter. + $commentModels = in_array($filter, [self::FILTER_ALL, self::FILTER_COMMENTS, self::FILTER_FOLLOWING, self::FILTER_MY], true) + ? $this->fetchCommentModels($sourceLimit, repliesOnly: false, actorIds: $actorIds) + : collect(); + $replyModels = in_array($filter, [self::FILTER_ALL, self::FILTER_REPLIES, self::FILTER_FOLLOWING, self::FILTER_MY], true) + ? $this->fetchCommentModels($sourceLimit, repliesOnly: true, actorIds: $actorIds) + : collect(); + $reactionModels = in_array($filter, [self::FILTER_ALL, self::FILTER_FOLLOWING, self::FILTER_MY], true) + ? $this->fetchReactionModels($sourceLimit, actorIds: $actorIds) + : collect(); + $recordedActivities = in_array($filter, [self::FILTER_ALL, self::FILTER_FOLLOWING, self::FILTER_MY], true) + ? $this->fetchRecordedActivities($sourceLimit, actorIds: $actorIds) + : collect(); $commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment')); $replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply')); $reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction)); - $mentionActivities = $this->fetchMentionActivities($sourceLimit); + $mentionActivities = in_array($filter, [self::FILTER_ALL, self::FILTER_FOLLOWING, self::FILTER_MY], true) + ? $this->fetchMentionActivities( + $sourceLimit, + actorIds: $actorIds, + mentionedUserId: $filter === self::FILTER_MY ? $viewer?->id : null, + ) + : collect(); $merged = $recordedActivities ->concat($commentActivities) @@ -140,7 +177,7 @@ final class CommunityActivityService ]; } - private function fetchRecordedActivities(int $limit): Collection + private function fetchRecordedActivities(int $limit, ?array $actorIds = null): Collection { $events = ActivityEvent::query() ->select(['id', 'actor_id', 'type', 'target_type', 'target_id', 'meta', 'created_at']) @@ -153,6 +190,7 @@ final class CommunityActivityService }, ]) ->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at')) + ->when($actorIds !== null, fn ($query) => $query->whereIn('actor_id', $actorIds)) ->latest('created_at') ->limit($limit) ->get(); @@ -223,7 +261,7 @@ final class CommunityActivityService ->values(); } - private function fetchCommentModels(int $limit, bool $repliesOnly): Collection + private function fetchCommentModels(int $limit, bool $repliesOnly, ?array $actorIds = null): Collection { return ArtworkComment::query() ->select([ @@ -250,6 +288,7 @@ final class CommunityActivityService ]) ->where('is_approved', true) ->whereNull('deleted_at') + ->when($actorIds !== null, fn ($query) => $query->whereIn('user_id', $actorIds)) ->when($repliesOnly, fn ($query) => $query->whereNotNull('parent_id'), fn ($query) => $query->whereNull('parent_id')) ->whereHas('user', function ($query) { $query->where('is_active', true)->whereNull('deleted_at'); @@ -262,7 +301,7 @@ final class CommunityActivityService ->get(); } - private function fetchReactionModels(int $limit): Collection + private function fetchReactionModels(int $limit, ?array $actorIds = null): Collection { return CommentReaction::query() ->select(['id', 'comment_id', 'user_id', 'reaction', 'created_at']) @@ -292,6 +331,7 @@ final class CommunityActivityService ->whereHas('user', function ($query) { $query->where('is_active', true)->whereNull('deleted_at'); }) + ->when($actorIds !== null, fn ($query) => $query->whereIn('user_id', $actorIds)) ->whereHas('comment', function ($query) { $query ->where('is_approved', true) @@ -395,7 +435,7 @@ final class CommunityActivityService ]; } - private function fetchMentionActivities(int $limit): Collection + private function fetchMentionActivities(int $limit, ?array $actorIds = null, ?int $mentionedUserId = null): Collection { if (! Schema::hasTable('user_mentions')) { return collect(); @@ -403,6 +443,15 @@ final class CommunityActivityService return UserMention::query() ->select(['id', 'user_id', 'mentioned_user_id', 'artwork_id', 'comment_id', 'created_at']) + ->when($actorIds !== null, function ($query) use ($actorIds, $mentionedUserId): void { + $query->where(function ($scope) use ($actorIds, $mentionedUserId): void { + $scope->whereIn('user_id', $actorIds); + + if ($mentionedUserId !== null) { + $scope->orWhere('mentioned_user_id', $mentionedUserId); + } + }); + }) ->with([ 'actor' => function ($query) { $query