feat: add community activity feed and mentions

This commit is contained in:
2026-03-17 18:26:57 +01:00
parent 2728644477
commit 2119741ba7
15 changed files with 1280 additions and 112 deletions

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CommunityActivityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class CommunityActivityController extends Controller
{
public function __construct(private readonly CommunityActivityService $activityService)
{
}
public function index(Request $request): JsonResponse
{
$filter = $this->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');
}
}

View File

@@ -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');
}
}