log($userId, UserActivity::TYPE_UPLOAD, UserActivity::ENTITY_ARTWORK, $artworkId, $meta); } public function logComment(int $userId, int $commentId, bool $isReply = false, array $meta = []): ?UserActivity { return $this->log( $userId, $isReply ? UserActivity::TYPE_REPLY : UserActivity::TYPE_COMMENT, UserActivity::ENTITY_ARTWORK_COMMENT, $commentId, $meta, ); } public function logLike(int $userId, int $artworkId, array $meta = []): ?UserActivity { return $this->log($userId, UserActivity::TYPE_LIKE, UserActivity::ENTITY_ARTWORK, $artworkId, $meta); } public function logFavourite(int $userId, int $artworkId, array $meta = []): ?UserActivity { return $this->log($userId, UserActivity::TYPE_FAVOURITE, UserActivity::ENTITY_ARTWORK, $artworkId, $meta); } public function logFollow(int $userId, int $targetUserId, array $meta = []): ?UserActivity { return $this->log($userId, UserActivity::TYPE_FOLLOW, UserActivity::ENTITY_USER, $targetUserId, $meta); } public function logAchievement(int $userId, int $achievementId, array $meta = []): ?UserActivity { return $this->log($userId, UserActivity::TYPE_ACHIEVEMENT, UserActivity::ENTITY_ACHIEVEMENT, $achievementId, $meta); } public function logForumPost(int $userId, int $threadId, array $meta = []): ?UserActivity { return $this->log($userId, UserActivity::TYPE_FORUM_POST, UserActivity::ENTITY_FORUM_THREAD, $threadId, $meta); } public function logForumReply(int $userId, int $postId, array $meta = []): ?UserActivity { return $this->log($userId, UserActivity::TYPE_FORUM_REPLY, UserActivity::ENTITY_FORUM_POST, $postId, $meta); } public function feedForUser(User $user, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE): array { $normalizedFilter = $this->normalizeFilter($filter); $resolvedPage = max(1, $page); $resolvedPerPage = max(1, min(50, $perPage)); $version = $this->cacheVersion((int) $user->id); return Cache::remember( sprintf('user_activity_feed:%d:%d:%s:%d:%d', (int) $user->id, $version, $normalizedFilter, $resolvedPage, $resolvedPerPage), now()->addSeconds(30), function () use ($user, $normalizedFilter, $resolvedPage, $resolvedPerPage): array { return $this->buildFeed($user, $normalizedFilter, $resolvedPage, $resolvedPerPage); } ); } public function normalizeFilter(string $filter): string { return match (strtolower(trim($filter))) { self::FILTER_UPLOADS => self::FILTER_UPLOADS, self::FILTER_COMMENTS => self::FILTER_COMMENTS, self::FILTER_LIKES => self::FILTER_LIKES, self::FILTER_FORUM => self::FILTER_FORUM, self::FILTER_FOLLOWING => self::FILTER_FOLLOWING, default => self::FILTER_ALL, }; } public function invalidateUserFeed(int $userId): void { if ($userId <= 0) { return; } $this->bumpCacheVersion($userId); } private function log(int $userId, string $type, string $entityType, int $entityId, array $meta = []): ?UserActivity { if ($userId <= 0 || $entityId <= 0) { return null; } $activity = UserActivity::query()->create([ 'user_id' => $userId, 'type' => $type, 'entity_type' => $entityType, 'entity_id' => $entityId, 'meta' => $meta ?: null, 'created_at' => now(), ]); $this->bumpCacheVersion($userId); return $activity; } private function buildFeed(User $user, string $filter, int $page, int $perPage): array { $query = UserActivity::query() ->where('user_id', (int) $user->id) ->whereNull('hidden_at') ->whereIn('type', $this->typesForFilter($filter)) ->latest('created_at') ->latest('id'); $total = (clone $query)->count(); /** @var Collection $rows */ $rows = $query ->forPage($page, $perPage) ->get(['id', 'user_id', 'type', 'entity_type', 'entity_id', 'meta', 'created_at']); $actor = $user->loadMissing('profile')->loadCount('artworks'); $related = $this->loadRelated($rows); $data = $rows ->map(fn (UserActivity $activity): ?array => $this->formatActivity($activity, $actor, $related)) ->filter() ->values() ->all(); return [ 'data' => $data, 'meta' => [ 'current_page' => $page, 'last_page' => (int) max(1, ceil($total / $perPage)), 'per_page' => $perPage, 'total' => $total, 'has_more' => ($page * $perPage) < $total, ], 'filter' => $filter, ]; } private function loadRelated(Collection $rows): array { $artworkIds = $rows ->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ARTWORK) ->pluck('entity_id') ->map(fn (mixed $id): int => (int) $id) ->unique() ->values() ->all(); $commentIds = $rows ->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ARTWORK_COMMENT) ->pluck('entity_id') ->map(fn (mixed $id): int => (int) $id) ->unique() ->values() ->all(); $userIds = $rows ->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_USER) ->pluck('entity_id') ->map(fn (mixed $id): int => (int) $id) ->unique() ->values() ->all(); $achievementIds = $rows ->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_ACHIEVEMENT) ->pluck('entity_id') ->map(fn (mixed $id): int => (int) $id) ->unique() ->values() ->all(); $forumThreadIds = $rows ->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_FORUM_THREAD) ->pluck('entity_id') ->map(fn (mixed $id): int => (int) $id) ->unique() ->values() ->all(); $forumPostIds = $rows ->filter(fn (UserActivity $activity): bool => $activity->entity_type === UserActivity::ENTITY_FORUM_POST) ->pluck('entity_id') ->map(fn (mixed $id): int => (int) $id) ->unique() ->values() ->all(); return [ 'artworks' => empty($artworkIds) ? collect() : Artwork::query() ->with(['stats']) ->whereIn('id', $artworkIds) ->public() ->published() ->whereNull('deleted_at') ->get() ->keyBy('id'), 'comments' => empty($commentIds) ? collect() : ArtworkComment::query() ->with(['artwork.stats']) ->whereIn('id', $commentIds) ->where('is_approved', true) ->whereNull('deleted_at') ->whereHas('artwork', fn ($query) => $query->public()->published()->whereNull('deleted_at')) ->get() ->keyBy('id'), 'users' => empty($userIds) ? collect() : User::query() ->with('profile:user_id,avatar_hash') ->withCount('artworks') ->whereIn('id', $userIds) ->where('is_active', true) ->whereNull('deleted_at') ->get() ->keyBy('id'), 'achievements' => empty($achievementIds) ? collect() : Achievement::query() ->whereIn('id', $achievementIds) ->get() ->keyBy('id'), 'forum_threads' => empty($forumThreadIds) ? collect() : ForumThread::query() ->with('category:id,name,slug') ->whereIn('id', $forumThreadIds) ->where('visibility', 'public') ->whereNull('deleted_at') ->get() ->keyBy('id'), 'forum_posts' => empty($forumPostIds) ? collect() : ForumPost::query() ->with(['thread.category:id,name,slug']) ->whereIn('id', $forumPostIds) ->whereNull('deleted_at') ->where('flagged', false) ->whereHas('thread', fn ($query) => $query->where('visibility', 'public')->whereNull('deleted_at')) ->get() ->keyBy('id'), ]; } private function formatActivity(UserActivity $activity, User $actor, array $related): ?array { $base = [ 'id' => (int) $activity->id, 'type' => (string) $activity->type, 'entity_type' => (string) $activity->entity_type, 'created_at' => $activity->created_at?->toIso8601String(), 'time_ago' => $activity->created_at?->diffForHumans(), 'actor' => $this->buildUserPayload($actor), 'meta' => is_array($activity->meta) ? $activity->meta : [], ]; return match ($activity->type) { UserActivity::TYPE_UPLOAD, UserActivity::TYPE_LIKE, UserActivity::TYPE_FAVOURITE => $this->formatArtworkActivity($base, $activity, $related), UserActivity::TYPE_COMMENT, UserActivity::TYPE_REPLY => $this->formatCommentActivity($base, $activity, $related), UserActivity::TYPE_FOLLOW => $this->formatFollowActivity($base, $activity, $related), UserActivity::TYPE_ACHIEVEMENT => $this->formatAchievementActivity($base, $activity, $related), UserActivity::TYPE_FORUM_POST, UserActivity::TYPE_FORUM_REPLY => $this->formatForumActivity($base, $activity, $related), default => null, }; } private function formatArtworkActivity(array $base, UserActivity $activity, array $related): ?array { /** @var Artwork|null $artwork */ $artwork = $related['artworks']->get((int) $activity->entity_id); if (! $artwork) { return null; } return [ ...$base, 'artwork' => $this->buildArtworkPayload($artwork), ]; } private function formatCommentActivity(array $base, UserActivity $activity, array $related): ?array { /** @var ArtworkComment|null $comment */ $comment = $related['comments']->get((int) $activity->entity_id); if (! $comment || ! $comment->artwork) { return null; } return [ ...$base, 'artwork' => $this->buildArtworkPayload($comment->artwork), 'comment' => [ 'id' => (int) $comment->id, 'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null, 'body' => $this->plainTextExcerpt((string) ($comment->raw_content ?? $comment->content ?? '')), 'url' => route('art.show', ['id' => (int) $comment->artwork_id, 'slug' => Str::slug((string) $comment->artwork->slug ?: (string) $comment->artwork->title)]) . '#comment-' . $comment->id, ], ]; } private function formatFollowActivity(array $base, UserActivity $activity, array $related): ?array { /** @var User|null $target */ $target = $related['users']->get((int) $activity->entity_id); if (! $target) { return null; } return [ ...$base, 'target_user' => $this->buildUserPayload($target), ]; } private function formatAchievementActivity(array $base, UserActivity $activity, array $related): ?array { /** @var Achievement|null $achievement */ $achievement = $related['achievements']->get((int) $activity->entity_id); if (! $achievement) { return null; } return [ ...$base, 'achievement' => [ 'id' => (int) $achievement->id, 'name' => (string) $achievement->name, 'slug' => (string) $achievement->slug, 'description' => (string) ($achievement->description ?? ''), 'icon' => (string) ($achievement->icon ?? 'fa-solid fa-trophy'), 'xp_reward' => (int) ($achievement->xp_reward ?? 0), ], ]; } private function formatForumActivity(array $base, UserActivity $activity, array $related): ?array { if ($activity->type === UserActivity::TYPE_FORUM_POST) { /** @var ForumThread|null $thread */ $thread = $related['forum_threads']->get((int) $activity->entity_id); if (! $thread) { return null; } return [ ...$base, 'forum' => [ 'thread' => $this->buildForumThreadPayload($thread), 'post' => null, ], ]; } /** @var ForumPost|null $post */ $post = $related['forum_posts']->get((int) $activity->entity_id); if (! $post || ! $post->thread) { return null; } return [ ...$base, 'forum' => [ 'thread' => $this->buildForumThreadPayload($post->thread), 'post' => [ 'id' => (int) $post->id, 'excerpt' => $this->plainTextExcerpt((string) $post->content), 'url' => $this->forumThreadUrl($post->thread) . '#post-' . $post->id, ], ], ]; } private function buildArtworkPayload(Artwork $artwork): array { $slug = Str::slug((string) ($artwork->slug ?: $artwork->title)); if ($slug === '') { $slug = (string) $artwork->id; } 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' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url, 'stats' => [ 'views' => (int) ($artwork->stats?->views ?? 0), 'likes' => (int) ($artwork->stats?->favorites ?? 0), 'comments' => (int) ($artwork->stats?->comments_count ?? 0), ], ]; } private function buildUserPayload(User $user): array { $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 !== '' ? route('profile.show', ['username' => strtolower($username)]) : null, 'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64), 'badge' => $this->resolveBadge($user), ]; } private function buildForumThreadPayload(ForumThread $thread): array { return [ 'id' => (int) $thread->id, 'title' => $this->plainText((string) $thread->title), 'url' => $this->forumThreadUrl($thread), 'category_name' => $this->plainText((string) ($thread->category?->name ?? 'Forum')), 'category_slug' => (string) ($thread->category?->slug ?? ''), ]; } private function plainTextExcerpt(string $content, int $limit = 220): string { $text = $this->plainText($content); return Str::limit($text, $limit, '...'); } private function plainText(string $value): string { return trim((string) (preg_replace('/\s+/', ' ', strip_tags($this->decodeHtml($value))) ?? '')); } private function decodeHtml(string $value): string { $decoded = $value; for ($pass = 0; $pass < 5; $pass++) { $next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8'); if ($next === $decoded) { break; } $decoded = $next; } return str_replace(['ยด', '´'], ["'", "'"], $decoded); } private function forumThreadUrl(ForumThread $thread): string { $topic = (string) ($thread->slug ?: $thread->id); if (Route::has('forum.topic.show')) { return (string) route('forum.topic.show', ['topic' => $topic]); } if (Route::has('forum.thread.show')) { return (string) route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug ?: $thread->id]); } return '/forum/topic/' . $topic; } private function resolveBadge(User $user): ?array { if ($user->hasRole('admin')) { return ['label' => 'Admin', 'tone' => 'rose']; } if ($user->hasRole('moderator')) { return ['label' => 'Moderator', 'tone' => 'amber']; } if ((int) ($user->artworks_count ?? 0) > 0) { return ['label' => 'Creator', 'tone' => 'sky']; } return null; } private function typesForFilter(string $filter): array { return match ($filter) { self::FILTER_UPLOADS => [UserActivity::TYPE_UPLOAD], self::FILTER_COMMENTS => [UserActivity::TYPE_COMMENT, UserActivity::TYPE_REPLY], self::FILTER_LIKES => [UserActivity::TYPE_LIKE, UserActivity::TYPE_FAVOURITE], self::FILTER_FORUM => [UserActivity::TYPE_FORUM_POST, UserActivity::TYPE_FORUM_REPLY], self::FILTER_FOLLOWING => [UserActivity::TYPE_FOLLOW], default => [ UserActivity::TYPE_UPLOAD, UserActivity::TYPE_COMMENT, UserActivity::TYPE_REPLY, UserActivity::TYPE_LIKE, UserActivity::TYPE_FAVOURITE, UserActivity::TYPE_FOLLOW, UserActivity::TYPE_ACHIEVEMENT, UserActivity::TYPE_FORUM_POST, UserActivity::TYPE_FORUM_REPLY, ], }; } private function cacheVersion(int $userId): int { return (int) Cache::get($this->versionKey($userId), self::FEED_SCHEMA_VERSION); } private function bumpCacheVersion(int $userId): void { $key = $this->versionKey($userId); Cache::add($key, self::FEED_SCHEMA_VERSION, now()->addDays(7)); Cache::increment($key); } private function versionKey(int $userId): string { return 'user_activity_feed_version:v' . self::FEED_SCHEMA_VERSION . ':' . $userId; } }