diff --git a/app/Http/Controllers/Api/CommunityActivityController.php b/app/Http/Controllers/Api/CommunityActivityController.php new file mode 100644 index 00000000..02c5d7d9 --- /dev/null +++ b/app/Http/Controllers/Api/CommunityActivityController.php @@ -0,0 +1,45 @@ +resolveFilter($request); + + if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) { + return response()->json(['error' => 'Unauthenticated'], 401); + } + + $feed = $this->activityService->getFeed( + viewer: $request->user(), + filter: $filter, + page: (int) $request->query('page', 1), + perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE), + actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null, + ); + + return response()->json($feed); + } + + private function resolveFilter(Request $request): string + { + if ($request->boolean('following') && ! $request->filled('filter')) { + return 'following'; + } + + return (string) $request->query('filter', 'all'); + } +} diff --git a/app/Http/Controllers/Web/CommunityActivityController.php b/app/Http/Controllers/Web/CommunityActivityController.php index e79d629f..40b7d383 100644 --- a/app/Http/Controllers/Web/CommunityActivityController.php +++ b/app/Http/Controllers/Web/CommunityActivityController.php @@ -5,120 +5,50 @@ declare(strict_types=1); namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; -use App\Models\ActivityEvent; -use App\Models\Artwork; -use App\Models\User; +use App\Services\CommunityActivityService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -/** - * Community activity feed. - * - * GET /community/activity?type=global|following - */ final class CommunityActivityController extends Controller { - private const PER_PAGE = 30; + public function __construct(private readonly CommunityActivityService $activityService) + { + } public function index(Request $request) { - $user = $request->user(); - $type = $request->query('type', 'global'); // global | following - $perPage = self::PER_PAGE; - - $query = ActivityEvent::query() - ->orderByDesc('created_at') - ->with(['actor:id,name,username']); - - if ($type === 'following' && $user) { - // Show only events from followed users - $followingIds = DB::table('user_followers') - ->where('follower_id', $user->id) - ->pluck('user_id') - ->all(); - - if (empty($followingIds)) { - $query->whereRaw('0 = 1'); // empty result set - } else { - $query->whereIn('actor_id', $followingIds); - } + $filter = $this->resolveFilter($request); + if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) { + $filter = 'all'; } - $events = $query->paginate($perPage)->withQueryString(); - $enriched = $this->enrich($events->getCollection()); + $feed = $this->activityService->getFeed( + viewer: $request->user(), + filter: $filter, + page: 1, + perPage: CommunityActivityService::DEFAULT_PER_PAGE, + actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null, + ); - return view('web.community.activity', [ - 'events' => $events, - 'enriched' => $enriched, - 'active_tab' => $type, + return view('web.comments.latest', [ 'page_title' => 'Community Activity', + 'props' => [ + 'initialActivities' => $feed['data'], + 'initialMeta' => $feed['meta'], + 'initialFilter' => $feed['filter'], + 'initialUserId' => $request->filled('user_id') ? (int) $request->query('user_id') : null, + 'isAuthenticated' => (bool) $request->user(), + ], + 'initialFilter' => $feed['filter'], + 'initialUserId' => $request->filled('user_id') ? (int) $request->query('user_id') : null, ]); } - /** - * Attach target object data to each event for display. - */ - private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection + private function resolveFilter(Request $request): string { - // Collect artwork IDs and user IDs to eager-load - $artworkIds = $events - ->where('target_type', ActivityEvent::TARGET_ARTWORK) - ->pluck('target_id') - ->unique() - ->values() - ->all(); + if ($request->boolean('following') && ! $request->filled('filter')) { + return 'following'; + } - $userIds = $events - ->where('target_type', ActivityEvent::TARGET_USER) - ->pluck('target_id') - ->unique() - ->values() - ->all(); - - $artworks = Artwork::whereIn('id', $artworkIds) - ->with('user:id,name,username') - ->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext']) - ->keyBy('id'); - - $users = User::whereIn('id', $userIds) - ->with('profile:user_id,avatar_hash') - ->get(['id', 'name', 'username']) - ->keyBy('id'); - - return $events->map(function (ActivityEvent $event) use ($artworks, $users): array { - $target = null; - - if ($event->target_type === ActivityEvent::TARGET_ARTWORK) { - $artwork = $artworks->get($event->target_id); - $target = $artwork ? [ - 'id' => $artwork->id, - 'title' => $artwork->title, - 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, - 'thumb' => $artwork->thumbUrl('sm'), - ] : null; - } elseif ($event->target_type === ActivityEvent::TARGET_USER) { - $u = $users->get($event->target_id); - $target = $u ? [ - 'id' => $u->id, - 'name' => $u->name, - 'username' => $u->username, - 'url' => '/@' . $u->username, - ] : null; - } - - return [ - 'id' => $event->id, - 'type' => $event->type, - 'target_type' => $event->target_type, - 'actor' => [ - 'id' => $event->actor?->id, - 'name' => $event->actor?->name, - 'username' => $event->actor?->username, - 'url' => '/@' . $event->actor?->username, - ], - 'target' => $target, - 'created_at' => $event->created_at?->toIso8601String(), - ]; - }); + return (string) $request->query('filter', 'all'); } } diff --git a/app/Models/UserMention.php b/app/Models/UserMention.php new file mode 100644 index 00000000..be8b7c8f --- /dev/null +++ b/app/Models/UserMention.php @@ -0,0 +1,51 @@ + 'integer', + 'mentioned_user_id' => 'integer', + 'artwork_id' => 'integer', + 'comment_id' => 'integer', + 'created_at' => 'datetime', + ]; + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function mentionedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'mentioned_user_id'); + } + + public function artwork(): BelongsTo + { + return $this->belongsTo(Artwork::class); + } + + public function comment(): BelongsTo + { + return $this->belongsTo(ArtworkComment::class, 'comment_id'); + } +} diff --git a/app/Observers/ArtworkCommentObserver.php b/app/Observers/ArtworkCommentObserver.php index 06e39a22..793a6e66 100644 --- a/app/Observers/ArtworkCommentObserver.php +++ b/app/Observers/ArtworkCommentObserver.php @@ -6,6 +6,7 @@ namespace App\Observers; use App\Models\ArtworkComment; use App\Services\UserStatsService; +use App\Services\UserMentionSyncService; use Illuminate\Support\Facades\DB; /** @@ -16,6 +17,7 @@ class ArtworkCommentObserver { public function __construct( private readonly UserStatsService $userStats, + private readonly UserMentionSyncService $mentionSync, ) {} public function created(ArtworkComment $comment): void @@ -28,6 +30,14 @@ class ArtworkCommentObserver // The commenter is "active" $this->userStats->ensureRow($comment->user_id); $this->userStats->setLastActiveAt($comment->user_id); + $this->mentionSync->syncForComment($comment); + } + + public function updated(ArtworkComment $comment): void + { + if ($comment->wasChanged(['content', 'raw_content', 'rendered_content', 'parent_id'])) { + $this->mentionSync->syncForComment($comment); + } } /** Soft delete. */ @@ -37,6 +47,8 @@ class ArtworkCommentObserver if ($creatorId) { $this->userStats->decrementCommentsReceived($creatorId); } + + $this->mentionSync->deleteForComment((int) $comment->id); } /** Hard delete after soft delete — already decremented; nothing to do. */ @@ -50,6 +62,13 @@ class ArtworkCommentObserver $this->userStats->decrementCommentsReceived($creatorId); } } + + $this->mentionSync->deleteForComment((int) $comment->id); + } + + public function restored(ArtworkComment $comment): void + { + $this->mentionSync->syncForComment($comment); } private function creatorId(int $artworkId): ?int diff --git a/app/Services/CommunityActivityService.php b/app/Services/CommunityActivityService.php new file mode 100644 index 00000000..e07ff3e2 --- /dev/null +++ b/app/Services/CommunityActivityService.php @@ -0,0 +1,474 @@ +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; + } +} diff --git a/app/Services/UserMentionSyncService.php b/app/Services/UserMentionSyncService.php new file mode 100644 index 00000000..dbda23eb --- /dev/null +++ b/app/Services/UserMentionSyncService.php @@ -0,0 +1,78 @@ +extractMentions((string) ($comment->raw_content ?? $comment->content ?? '')); + $mentionedIds = $usernames === [] + ? [] + : User::query() + ->whereIn(DB::raw('LOWER(username)'), $usernames) + ->where('is_active', true) + ->whereNull('deleted_at') + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + + DB::transaction(function () use ($comment, $mentionedIds): void { + DB::table('user_mentions') + ->where('comment_id', (int) $comment->id) + ->delete(); + + if ($mentionedIds === []) { + return; + } + + $rows = collect($mentionedIds) + ->reject(fn (int $id) => $id === (int) $comment->user_id) + ->unique() + ->map(fn (int $mentionedUserId) => [ + 'user_id' => (int) $comment->user_id, + 'mentioned_user_id' => $mentionedUserId, + 'artwork_id' => (int) $comment->artwork_id, + 'comment_id' => (int) $comment->id, + 'created_at' => $comment->created_at ?? now(), + ]) + ->values() + ->all(); + + if ($rows !== []) { + DB::table('user_mentions')->insert($rows); + } + }); + } + + public function deleteForComment(int $commentId): void + { + if (! Schema::hasTable('user_mentions')) { + return; + } + + DB::table('user_mentions')->where('comment_id', $commentId)->delete(); + } + + private function extractMentions(string $content): array + { + preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches); + + return collect($matches[2] ?? []) + ->map(fn ($username) => strtolower((string) $username)) + ->unique() + ->values() + ->all(); + } +} diff --git a/database/migrations/2026_03_16_180000_create_user_mentions_table.php b/database/migrations/2026_03_16_180000_create_user_mentions_table.php new file mode 100644 index 00000000..78fdf2b7 --- /dev/null +++ b/database/migrations/2026_03_16_180000_create_user_mentions_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('mentioned_user_id'); + $table->unsignedBigInteger('artwork_id')->nullable(); + $table->unsignedBigInteger('comment_id'); + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['comment_id', 'mentioned_user_id'], 'user_mentions_comment_user_unique'); + $table->index(['mentioned_user_id', 'created_at'], 'user_mentions_mentioned_created_idx'); + $table->index(['user_id', 'created_at'], 'user_mentions_actor_created_idx'); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('mentioned_user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('artwork_id')->references('id')->on('artworks')->onDelete('cascade'); + $table->foreign('comment_id')->references('id')->on('artwork_comments')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_mentions'); + } +}; diff --git a/resources/js/Pages/Community/CommunityActivityPage.jsx b/resources/js/Pages/Community/CommunityActivityPage.jsx new file mode 100644 index 00000000..0cef05d1 --- /dev/null +++ b/resources/js/Pages/Community/CommunityActivityPage.jsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import ActivityFeed from '../../components/community/ActivityFeed' + +const FILTER_TABS = [ + { key: 'all', label: 'All Activity' }, + { key: 'comments', label: 'Comments' }, + { key: 'replies', label: 'Replies' }, + { key: 'following', label: 'Following', authRequired: true }, + { key: 'my', label: 'My Activity', authRequired: true }, +] + +function FilterPills({ activeFilter, isAuthenticated, onChange }) { + return ( +
+ {FILTER_TABS.map((tab) => { + const disabled = tab.authRequired && !isAuthenticated + const active = activeFilter === tab.key + + return ( + + ) + })} +
+ ) +} + +function updateUrl(filter, userId) { + const url = new URL(window.location.href) + + if (filter && filter !== 'all') url.searchParams.set('filter', filter) + else url.searchParams.delete('filter') + + if (userId) url.searchParams.set('user_id', String(userId)) + else url.searchParams.delete('user_id') + + window.history.replaceState({}, '', url.toString()) +} + +function updateHeaderSummary(filter, userId) { + const filterLabels = { + all: 'All Activity', + comments: 'Comments', + replies: 'Replies', + following: 'Following', + my: 'My Activity', + } + + const filterNode = document.getElementById('community-activity-filter-summary') + const scopeNode = document.getElementById('community-activity-scope-summary') + + if (filterNode) { + filterNode.innerHTML = ` ${filterLabels[filter] || filterLabels.all}` + } + + if (scopeNode) { + if (userId) { + scopeNode.className = 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65' + scopeNode.innerHTML = ` User #${userId}` + } else { + scopeNode.className = 'hidden' + scopeNode.innerHTML = '' + } + } +} + +function CommunityActivityPage({ + initialActivities = [], + initialMeta = {}, + initialFilter = 'all', + initialUserId = null, + isAuthenticated = false, +}) { + const [activeFilter, setActiveFilter] = useState(initialFilter) + const [activities, setActivities] = useState(initialActivities) + const [meta, setMeta] = useState(initialMeta) + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const sentinelRef = useRef(null) + const requestIdRef = useRef(0) + + const hasMore = Boolean(meta?.has_more) + const nextPage = Number(meta?.current_page || 1) + 1 + + const fetchFeed = useCallback(async ({ filter, page, append }) => { + const requestId = ++requestIdRef.current + setError(null) + if (append) setLoadingMore(true) + else setLoading(true) + + try { + const params = new URLSearchParams({ filter, page: String(page) }) + if (initialUserId) params.set('user_id', String(initialUserId)) + + const response = await fetch(`/api/community/activity?${params.toString()}`, { + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + credentials: 'same-origin', + }) + + if (requestId !== requestIdRef.current) return + + if (response.status === 401) { + setError('Please log in to view this activity filter.') + if (!append) { + setActivities([]) + setMeta({ current_page: 1, last_page: 1, has_more: false, total: 0 }) + } + return + } + + if (!response.ok) { + throw new Error('Failed to load community activity.') + } + + const payload = await response.json() + setActivities((prev) => append ? [...prev, ...(payload.data || [])] : (payload.data || [])) + setMeta(payload.meta || {}) + } catch { + if (requestId === requestIdRef.current) { + setError('Failed to load community activity. Please try again.') + } + } finally { + if (requestId === requestIdRef.current) { + setLoading(false) + setLoadingMore(false) + } + } + }, [initialUserId]) + + const handleFilterChange = useCallback((nextFilter) => { + if (nextFilter === activeFilter) return + setActiveFilter(nextFilter) + updateUrl(nextFilter, initialUserId) + fetchFeed({ filter: nextFilter, page: 1, append: false }) + }, [activeFilter, fetchFeed, initialUserId]) + + useEffect(() => { + updateHeaderSummary(activeFilter, initialUserId) + }, [activeFilter, initialUserId]) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel || loading || loadingMore || !hasMore) return undefined + + const observer = new IntersectionObserver((entries) => { + const [entry] = entries + if (entry?.isIntersecting) { + fetchFeed({ filter: activeFilter, page: nextPage, append: true }) + } + }, { rootMargin: '220px 0px' }) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage]) + + const resultsLabel = useMemo(() => { + const total = Number(meta?.total || activities.length || 0) + if (!total) return 'No recent activity' + return `${total.toLocaleString()} events` + }, [activities.length, meta?.total]) + + return ( +
+
+
+
+

Live community pulse

+

+ Comments, replies, reactions, and mentions from across Skinbase in one scrolling Nova feed. +

+
+
{resultsLabel}
+
+ + +
+ + +
+ ) +} + +const mountEl = document.getElementById('community-activity-root') + +if (mountEl) { + let props = {} + try { + const propsEl = document.getElementById('community-activity-props') + props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {} + } catch { + props = {} + } + + createRoot(mountEl).render() +} + +export default CommunityActivityPage diff --git a/resources/js/components/community/ActivityArtworkPreview.jsx b/resources/js/components/community/ActivityArtworkPreview.jsx new file mode 100644 index 00000000..aa06fb95 --- /dev/null +++ b/resources/js/components/community/ActivityArtworkPreview.jsx @@ -0,0 +1,25 @@ +import React from 'react' + +export default function ActivityArtworkPreview({ artwork }) { + if (!artwork?.url || !artwork?.thumb) return null + + return ( + +
+ {artwork.title +
+
+

{artwork.title || 'Artwork'}

+
+
+ ) +} diff --git a/resources/js/components/community/ActivityAvatar.jsx b/resources/js/components/community/ActivityAvatar.jsx new file mode 100644 index 00000000..c0029f74 --- /dev/null +++ b/resources/js/components/community/ActivityAvatar.jsx @@ -0,0 +1,44 @@ +import React from 'react' + +const FALLBACK_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp' + +const BADGE_TONES = { + rose: 'border-rose-400/25 bg-rose-500/10 text-rose-200', + amber: 'border-amber-400/25 bg-amber-500/10 text-amber-200', + sky: 'border-sky-400/25 bg-sky-500/10 text-sky-200', +} + +export default function ActivityAvatar({ user }) { + if (!user) return null + + const badgeClassName = BADGE_TONES[user.badge?.tone] || BADGE_TONES.sky + + return ( +
+ + {user.name { + event.currentTarget.src = FALLBACK_AVATAR + }} + /> + + +
+ + {user.name || user.username || 'User'} + + {user.username &&

@{user.username}

} + {user.badge && ( + + {user.badge.label} + + )} +
+
+ ) +} diff --git a/resources/js/components/community/ActivityCard.jsx b/resources/js/components/community/ActivityCard.jsx new file mode 100644 index 00000000..86ead019 --- /dev/null +++ b/resources/js/components/community/ActivityCard.jsx @@ -0,0 +1,88 @@ +import React from 'react' +import ActivityAvatar from './ActivityAvatar' +import ActivityArtworkPreview from './ActivityArtworkPreview' +import ActivityReactions from './ActivityReactions' + +function ActivityHeadline({ activity }) { + const artworkLink = activity?.artwork?.url + const artworkTitle = activity?.artwork?.title || 'an artwork' + const mentionedUser = activity?.mentioned_user + const reaction = activity?.reaction + const commentAuthor = activity?.comment?.author + + switch (activity?.type) { + case 'comment': + return ( +

+ commented on + {artworkLink ? {artworkTitle} : {artworkTitle}} +

+ ) + case 'reply': + return ( +

+ replied on + {artworkLink ? {artworkTitle} : {artworkTitle}} +

+ ) + case 'reaction': + return ( +

+ reacted {reaction?.emoji || '👍'} {reaction?.label || 'Like'} + to + {commentAuthor?.profile_url ? {commentAuthor.name || commentAuthor.username || 'a creator'} : a creator} + on + {artworkLink ? {artworkTitle} : {artworkTitle}} +

+ ) + case 'mention': + return ( +

+ mentioned + {mentionedUser?.profile_url ? @{mentionedUser.username || mentionedUser.name} : someone} + on + {artworkLink ? {artworkTitle} : {artworkTitle}} +

+ ) + default: + return

Shared new activity.

+ } +} + +export default function ActivityCard({ activity, isLoggedIn = false }) { + return ( +
+
+
+ +
+ +
+
+ + {activity.time_ago || ''} +
+ + {activity.comment?.body ? ( +
+

{activity.comment.body}

+
+ ) : null} + + {activity.type === 'mention' && activity.mentioned_user ? ( +
+ + Mentioned @{activity.mentioned_user.username || activity.mentioned_user.name} +
+ ) : null} + + +
+ +
+ +
+
+
+ ) +} diff --git a/resources/js/components/community/ActivityFeed.jsx b/resources/js/components/community/ActivityFeed.jsx new file mode 100644 index 00000000..a15549e0 --- /dev/null +++ b/resources/js/components/community/ActivityFeed.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import ActivityCard from './ActivityCard' + +function ActivitySkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} + +function EmptyState({ isFiltered }) { + return ( +
+
+ +
+

No activity yet

+

+ {isFiltered ? 'This filter has no recent activity right now.' : 'When creators and members interact around artworks, their activity will appear here.'} +

+
+ ) +} + +export default function ActivityFeed({ + activities = [], + isLoggedIn = false, + loading = false, + loadingMore = false, + error = null, + sentinelRef, +}) { + if (loading && activities.length === 0) { + return + } + + if (!loading && activities.length === 0) { + return + } + + return ( +
+ {error ? ( +
+ {error} +
+ ) : null} + + {activities.map((activity) => ( + + ))} + + {loadingMore ? : null} + + + ) +} diff --git a/resources/js/components/community/ActivityReactions.jsx b/resources/js/components/community/ActivityReactions.jsx new file mode 100644 index 00000000..3729b421 --- /dev/null +++ b/resources/js/components/community/ActivityReactions.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import ReactionBar from '../comments/ReactionBar' + +export default function ActivityReactions({ activity, isLoggedIn = false }) { + const commentId = activity?.comment?.id || null + const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#' + const artworkUrl = activity?.artwork?.url || null + + return ( +
+ {commentId ? ( + + ) : null} + + + + Reply + + + {artworkUrl ? ( + + + View artwork + + ) : null} +
+ ) +} diff --git a/resources/views/web/comments/latest.blade.php b/resources/views/web/comments/latest.blade.php index 76ebb54b..d3b8b183 100644 --- a/resources/views/web/comments/latest.blade.php +++ b/resources/views/web/comments/latest.blade.php @@ -1,24 +1,64 @@ @extends('layouts.nova') -@section('content') +@php + $headerBreadcrumbs = collect([ + (object) ['name' => $page_title ?? 'Community Activity', 'url' => route('community.activity')], + ]); -{{-- Inline props for the React component --}} - -
- {{-- SSR skeleton replaced on React hydration --}} -
-
-

Community

-

{{ $page_title }}

-

Most recent artwork comments from the community.

+
+
+
+
+
+
+
+
+
+
-
-@vite(['resources/js/Pages/Community/LatestCommentsPage.jsx']) +@vite(['resources/js/Pages/Community/CommunityActivityPage.jsx']) @endsection diff --git a/vite.config.mjs b/vite.config.mjs index f0300b17..4b93aac9 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -20,6 +20,7 @@ export default defineConfig({ 'resources/js/Pages/ArtworkPage.jsx', 'resources/js/Pages/Home/HomePage.jsx', 'resources/js/Pages/Community/LatestCommentsPage.jsx', + 'resources/js/Pages/Community/CommunityActivityPage.jsx', 'resources/js/Pages/Messages/Index.jsx', 'resources/js/profile.jsx', 'resources/js/feed.jsx', @@ -62,4 +63,4 @@ export default defineConfig({ setupFiles: ['resources/js/test/setupTests.js'], include: ['resources/js/**/*.test.{js,jsx}'], }, -}); \ No newline at end of file +});