normalizeFilter($filter); $resolvedPage = max(1, $page); $resolvedPerPage = max(1, min(50, $perPage)); $cacheKey = sprintf( 'community_activity:%s:%d:%d:%d:%d', $normalizedFilter, (int) ($viewer?->id ?? 0), (int) ($actorUserId ?? 0), $resolvedPage, $resolvedPerPage, ); return Cache::remember($cacheKey, now()->addSeconds(30), function () use ($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId): array { return $this->buildFeed($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId); }); } public function requiresAuthentication(string $filter): bool { return in_array($this->normalizeFilter($filter), [self::FILTER_FOLLOWING, self::FILTER_MY], true); } public function normalizeFilter(string $filter): string { return match (strtolower(trim($filter))) { self::FILTER_COMMENTS => self::FILTER_COMMENTS, self::FILTER_REPLIES => self::FILTER_REPLIES, self::FILTER_FOLLOWING => self::FILTER_FOLLOWING, self::FILTER_MY => self::FILTER_MY, default => self::FILTER_ALL, }; } private function buildFeed(?User $viewer, string $filter, int $page, int $perPage, ?int $actorUserId): array { $sourceLimit = max(80, $page * $perPage * 6); $followingIds = $filter === self::FILTER_FOLLOWING && $viewer ? $viewer->following()->pluck('users.id')->map(fn ($id) => (int) $id)->all() : []; $commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false); $replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true); $reactionModels = $this->fetchReactionModels($sourceLimit); $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); $merged = $commentActivities ->concat($replyActivities) ->concat($reactionActivities) ->concat($mentionActivities) ->filter(function (array $activity) use ($filter, $viewer, $followingIds, $actorUserId): bool { $actorId = (int) ($activity['user']['id'] ?? 0); $mentionedUserId = (int) ($activity['mentioned_user']['id'] ?? 0); if ($actorUserId !== null && $actorId !== (int) $actorUserId) { return false; } return match ($filter) { self::FILTER_COMMENTS => $activity['type'] === 'comment', self::FILTER_REPLIES => $activity['type'] === 'reply', self::FILTER_FOLLOWING => in_array($actorId, $followingIds, true), self::FILTER_MY => $viewer !== null && ($actorId === (int) $viewer->id || ($activity['type'] === 'mention' && $mentionedUserId === (int) $viewer->id)), default => true, }; }) ->sortByDesc(fn (array $activity) => $activity['sort_timestamp'] ?? $activity['created_at']) ->values(); $total = $merged->count(); $offset = ($page - 1) * $perPage; $pageItems = $merged->slice($offset, $perPage)->values(); $reactionTotals = $this->loadCommentReactionTotals( $pageItems->pluck('comment.id')->filter()->map(fn ($id) => (int) $id)->unique()->all(), $viewer?->id, ); $data = $pageItems->map(function (array $activity) use ($reactionTotals): array { $commentId = (int) ($activity['comment']['id'] ?? 0); if ($commentId > 0) { $activity['comment']['reactions'] = $reactionTotals[$commentId] ?? $this->defaultReactionTotals(); } unset($activity['sort_timestamp']); return $activity; })->all(); return [ 'data' => $data, 'meta' => [ 'current_page' => $page, 'last_page' => (int) max(1, ceil($total / $perPage)), 'per_page' => $perPage, 'total' => $total, 'has_more' => $offset + $perPage < $total, ], 'filter' => $filter, ]; } private function fetchCommentModels(int $limit, bool $repliesOnly): Collection { return ArtworkComment::query() ->select([ 'id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved', ]) ->with([ 'user' => function ($query) { $query ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') ->with('profile:user_id,avatar_hash') ->withCount('artworks'); }, 'artwork' => function ($query) { $query->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved'); }, ]) ->where('is_approved', true) ->whereNull('deleted_at') ->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'); }) ->whereHas('artwork', function ($query) { $query->public()->published()->whereNull('deleted_at'); }) ->latest('created_at') ->limit($limit) ->get(); } private function fetchReactionModels(int $limit): Collection { return CommentReaction::query() ->select(['id', 'comment_id', 'user_id', 'reaction', 'created_at']) ->with([ 'user' => function ($query) { $query ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') ->with('profile:user_id,avatar_hash') ->withCount('artworks'); }, 'comment' => function ($query) { $query ->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved') ->with([ 'user' => function ($userQuery) { $userQuery ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') ->with('profile:user_id,avatar_hash') ->withCount('artworks'); }, 'artwork' => function ($artworkQuery) { $artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved'); }, ]); }, ]) ->whereHas('user', function ($query) { $query->where('is_active', true)->whereNull('deleted_at'); }) ->whereHas('comment', function ($query) { $query ->where('is_approved', true) ->whereNull('deleted_at') ->whereHas('user', function ($userQuery) { $userQuery->where('is_active', true)->whereNull('deleted_at'); }) ->whereHas('artwork', function ($artworkQuery) { $artworkQuery->public()->published()->whereNull('deleted_at'); }); }) ->latest('created_at') ->limit($limit) ->get(); } private function mapCommentActivity(ArtworkComment $comment, string $type): array { $artwork = $comment->artwork; $iso = $comment->created_at?->toIso8601String(); return [ 'id' => $type . ':' . $comment->id, 'type' => $type, 'user' => $this->buildUserPayload($comment->user), 'comment' => $this->buildCommentPayload($comment), 'artwork' => $this->buildArtworkPayload($artwork), 'created_at' => $iso, 'time_ago' => $comment->created_at?->diffForHumans(), 'sort_timestamp' => $iso, ]; } private function mapReactionActivity(CommentReaction $reaction): array { $comment = $reaction->comment; $artwork = $comment?->artwork; $reactionType = ReactionType::tryFrom((string) $reaction->reaction); $iso = $reaction->created_at?->toIso8601String(); return [ 'id' => 'reaction:' . $reaction->id, 'type' => 'reaction', 'user' => $this->buildUserPayload($reaction->user), 'comment' => $comment ? $this->buildCommentPayload($comment) : null, 'artwork' => $this->buildArtworkPayload($artwork), 'reaction' => [ 'slug' => $reactionType?->value ?? (string) $reaction->reaction, 'emoji' => $reactionType?->emoji() ?? '👍', 'label' => $reactionType?->label() ?? Str::headline((string) $reaction->reaction), ], 'created_at' => $iso, 'time_ago' => $reaction->created_at?->diffForHumans(), 'sort_timestamp' => $iso, ]; } private function fetchMentionActivities(int $limit): Collection { if (! Schema::hasTable('user_mentions')) { return collect(); } return UserMention::query() ->select(['id', 'user_id', 'mentioned_user_id', 'artwork_id', 'comment_id', 'created_at']) ->with([ 'actor' => function ($query) { $query ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') ->with('profile:user_id,avatar_hash') ->withCount('artworks'); }, 'mentionedUser' => function ($query) { $query ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') ->with('profile:user_id,avatar_hash') ->withCount('artworks'); }, 'comment' => function ($query) { $query ->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved') ->with([ 'user' => function ($userQuery) { $userQuery ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') ->with('profile:user_id,avatar_hash') ->withCount('artworks'); }, 'artwork' => function ($artworkQuery) { $artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved'); }, ]); }, ]) ->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at')) ->whereHas('mentionedUser', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at')) ->whereHas('comment', function ($query) { $query ->where('is_approved', true) ->whereNull('deleted_at') ->whereHas('artwork', fn ($artworkQuery) => $artworkQuery->public()->published()->whereNull('deleted_at')); }) ->latest('created_at') ->limit($limit) ->get() ->map(function (UserMention $mention): array { $iso = $mention->created_at?->toIso8601String(); return [ 'id' => 'mention:' . $mention->id, 'type' => 'mention', 'user' => $this->buildUserPayload($mention->actor), 'mentioned_user' => $this->buildUserPayload($mention->mentionedUser), 'comment' => $mention->comment ? $this->buildCommentPayload($mention->comment) : null, 'artwork' => $this->buildArtworkPayload($mention->comment?->artwork), 'created_at' => $iso, 'time_ago' => $mention->created_at?->diffForHumans(), 'sort_timestamp' => $iso, ]; }) ->values(); } private function buildUserPayload(?User $user): ?array { if (! $user) { return null; } $username = (string) ($user->username ?? ''); return [ 'id' => (int) $user->id, 'name' => html_entity_decode((string) ($user->name ?? $username ?: 'User'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'username' => $username, 'profile_url' => $username !== '' ? '/@' . $username : null, 'avatar_url' => $user->profile?->avatar_url ?: AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64), 'badge' => $this->resolveBadge($user), ]; } private function resolveBadge(User $user): ?array { if ($user->isAdmin()) { return ['label' => 'Admin', 'tone' => 'rose']; } if ($user->isModerator()) { return ['label' => 'Moderator', 'tone' => 'amber']; } if ((int) ($user->artworks_count ?? 0) > 0) { return ['label' => 'Creator', 'tone' => 'sky']; } return null; } private function buildArtworkPayload(?Artwork $artwork): ?array { if (! $artwork) { return null; } $slug = Str::slug((string) ($artwork->slug ?: $artwork->title)); if ($slug === '') { $slug = (string) $artwork->id; } $thumb = ThumbnailPresenter::present($artwork, 'md'); return [ 'id' => (int) $artwork->id, 'title' => html_entity_decode((string) ($artwork->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]), 'thumb' => $thumb['url'] ?? null, ]; } private function buildCommentPayload(ArtworkComment $comment): array { $artwork = $this->buildArtworkPayload($comment->artwork); $commentUrl = $artwork ? $artwork['url'] . '#comment-' . $comment->id : null; return [ 'id' => (int) $comment->id, 'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null, 'body' => $this->excerptComment($comment), 'body_html' => $comment->getDisplayHtml(), 'url' => $commentUrl, 'author' => $this->buildUserPayload($comment->user), ]; } private function excerptComment(ArtworkComment $comment): string { $raw = $comment->raw_content ?? $comment->content ?? ''; $plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode((string) $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?? ''); return Str::limit($plain, 180, '…'); } private function loadCommentReactionTotals(array $commentIds, ?int $viewerId): array { if ($commentIds === []) { return []; } $rows = DB::table('comment_reactions') ->whereIn('comment_id', $commentIds) ->selectRaw('comment_id, reaction, COUNT(*) as total') ->groupBy('comment_id', 'reaction') ->get(); $viewerReactions = []; if ($viewerId) { $viewerReactions = DB::table('comment_reactions') ->whereIn('comment_id', $commentIds) ->where('user_id', $viewerId) ->get(['comment_id', 'reaction']) ->groupBy('comment_id') ->map(fn (Collection $items) => $items->pluck('reaction')->all()) ->all(); } $totalsByComment = []; foreach ($commentIds as $commentId) { $totalsByComment[(int) $commentId] = $this->defaultReactionTotals(); } foreach ($rows as $row) { $commentId = (int) $row->comment_id; $slug = (string) $row->reaction; if (! isset($totalsByComment[$commentId][$slug])) { continue; } $totalsByComment[$commentId][$slug]['count'] = (int) $row->total; } foreach ($viewerReactions as $commentId => $slugs) { foreach ($slugs as $slug) { if (isset($totalsByComment[(int) $commentId][(string) $slug])) { $totalsByComment[(int) $commentId][(string) $slug]['mine'] = true; } } } return $totalsByComment; } private function defaultReactionTotals(): array { $totals = []; foreach (ReactionType::cases() as $type) { $totals[$type->value] = [ 'emoji' => $type->emoji(), 'label' => $type->label(), 'count' => 0, 'mine' => false, ]; } return $totals; } }