feat: add community activity feed and mentions
This commit is contained in:
45
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
45
app/Http/Controllers/Api/CommunityActivityController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
51
app/Models/UserMention.php
Normal file
51
app/Models/UserMention.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserMention extends Model
|
||||
{
|
||||
protected $table = 'user_mentions';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'mentioned_user_id',
|
||||
'artwork_id',
|
||||
'comment_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
474
app/Services/CommunityActivityService.php
Normal file
474
app/Services/CommunityActivityService.php
Normal file
@@ -0,0 +1,474 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ReactionType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\CommentReaction;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class CommunityActivityService
|
||||
{
|
||||
public const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
private const FILTER_ALL = 'all';
|
||||
private const FILTER_COMMENTS = 'comments';
|
||||
private const FILTER_REPLIES = 'replies';
|
||||
private const FILTER_FOLLOWING = 'following';
|
||||
private const FILTER_MY = 'my';
|
||||
|
||||
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));
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
78
app/Services/UserMentionSyncService.php
Normal file
78
app/Services/UserMentionSyncService.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class UserMentionSyncService
|
||||
{
|
||||
public function syncForComment(ArtworkComment $comment): void
|
||||
{
|
||||
if (! Schema::hasTable('user_mentions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$usernames = $this->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user