feed($user) ->take($limit) ->values() ->all(); } public function feed(User $user) { return $this->mergedFeed($user) ->sortByDesc(fn (array $item): int => $this->timestamp($item['created_at'] ?? null)) ->values(); } /** * @param array $filters */ public function list(User $user, array $filters = []): array { $type = $this->normalizeType((string) ($filters['type'] ?? 'all')); $module = $this->normalizeModule((string) ($filters['module'] ?? 'all')); $q = trim((string) ($filters['q'] ?? '')); $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48); $preferences = $this->preferences->forUser($user); $items = $this->feed($user); if ($type !== 'all') { $items = $items->where('type', $type)->values(); } if ($module !== 'all') { $items = $items->where('module', $module)->values(); } if ($q !== '') { $needle = mb_strtolower($q); $items = $items->filter(function (array $item) use ($needle): bool { return collect([ $item['title'] ?? '', $item['body'] ?? '', $item['actor']['name'] ?? '', $item['module_label'] ?? '', ])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle)); })->values(); } $items = $items->sortByDesc(fn (array $item): int => $this->timestamp($item['created_at'] ?? null))->values(); $total = $items->count(); $lastPage = max(1, (int) ceil($total / $perPage)); $page = min($page, $lastPage); $lastReadAt = $preferences['activity_last_read_at'] ?? null; $lastReadTimestamp = $this->timestamp($lastReadAt); return [ 'items' => $items->forPage($page, $perPage)->map(function (array $item) use ($lastReadTimestamp): array { $item['is_new'] = $this->timestamp($item['created_at'] ?? null) > $lastReadTimestamp; return $item; })->values()->all(), 'meta' => [ 'current_page' => $page, 'last_page' => $lastPage, 'per_page' => $perPage, 'total' => $total, ], 'filters' => [ 'type' => $type, 'module' => $module, 'q' => $q, ], 'type_options' => [ ['value' => 'all', 'label' => 'Everything'], ['value' => 'notification', 'label' => 'Notifications'], ['value' => 'comment', 'label' => 'Comments'], ['value' => 'follower', 'label' => 'Followers'], ], 'module_options' => [ ['value' => 'all', 'label' => 'All content types'], ['value' => 'artworks', 'label' => 'Artworks'], ['value' => 'cards', 'label' => 'Cards'], ['value' => 'collections', 'label' => 'Collections'], ['value' => 'stories', 'label' => 'Stories'], ['value' => 'followers', 'label' => 'Followers'], ['value' => 'system', 'label' => 'System'], ], 'summary' => [ 'unread_notifications' => (int) $user->unreadNotifications()->count(), 'last_read_at' => $lastReadAt, 'new_items' => $items->filter(fn (array $item): bool => $this->timestamp($item['created_at'] ?? null) > $lastReadTimestamp)->count(), ], ]; } public function markAllRead(User $user): array { $this->notifications->markAllRead($user); $updated = $this->preferences->update($user, [ 'activity_last_read_at' => now()->toIso8601String(), ]); return [ 'ok' => true, 'activity_last_read_at' => $updated['activity_last_read_at'], ]; } private function mergedFeed(User $user) { return collect($this->notificationItems($user)) ->concat($this->commentItems($user)) ->concat($this->followerItems($user)); } private function notificationItems(User $user): array { return collect($this->notifications->listForUser($user, 1, 30)['data'] ?? []) ->map(fn (array $item): array => [ 'id' => 'notification:' . $item['id'], 'type' => 'notification', 'module' => 'system', 'module_label' => 'Notification', 'title' => $item['message'], 'body' => $item['message'], 'created_at' => $item['created_at'], 'time_ago' => $item['time_ago'] ?? null, 'url' => $item['url'] ?? route('studio.activity'), 'actor' => $item['actor'] ?? null, 'read' => (bool) ($item['read'] ?? false), ]) ->values() ->all(); } private function commentItems(User $user): array { $artworkComments = DB::table('artwork_comments') ->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id') ->join('users', 'users.id', '=', 'artwork_comments.user_id') ->leftJoin('user_profiles', 'user_profiles.user_id', '=', 'users.id') ->where('artworks.user_id', $user->id) ->whereNull('artwork_comments.deleted_at') ->orderByDesc('artwork_comments.created_at') ->limit(20) ->get([ 'artwork_comments.id', 'artwork_comments.content as body', 'artwork_comments.created_at', 'users.id as actor_id', 'users.name as actor_name', 'users.username as actor_username', 'user_profiles.avatar_hash', 'artworks.title as item_title', 'artworks.slug as item_slug', 'artworks.id as item_id', ]) ->map(fn ($row): array => [ 'id' => 'comment:artworks:' . $row->id, 'type' => 'comment', 'module' => 'artworks', 'module_label' => 'Artwork comment', 'title' => 'New comment on ' . $row->item_title, 'body' => (string) $row->body, 'created_at' => $this->normalizeDate($row->created_at), 'time_ago' => null, 'url' => route('art.show', ['id' => $row->item_id, 'slug' => $row->item_slug]) . '#comment-' . $row->id, 'actor' => [ 'id' => (int) $row->actor_id, 'name' => $row->actor_name ?: $row->actor_username ?: 'Creator', 'username' => $row->actor_username, 'avatar_url' => AvatarUrl::forUser((int) $row->actor_id, $row->avatar_hash, 64), ], ]); $cardComments = NovaCardComment::query() ->with(['user.profile', 'card']) ->whereNull('deleted_at') ->whereHas('card', fn ($query) => $query->where('user_id', $user->id)) ->latest('created_at') ->limit(20) ->get() ->map(fn (NovaCardComment $comment): array => [ 'id' => 'comment:cards:' . $comment->id, 'type' => 'comment', 'module' => 'cards', 'module_label' => 'Card comment', 'title' => 'New comment on ' . ($comment->card?->title ?? 'card'), 'body' => (string) $comment->body, 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.activity'), 'actor' => $comment->user ? [ 'id' => (int) $comment->user->id, 'name' => $comment->user->name ?: $comment->user->username ?: 'Creator', 'username' => $comment->user->username, 'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64), ] : null, ]); $collectionComments = CollectionComment::query() ->with(['user.profile', 'collection']) ->whereNull('deleted_at') ->whereHas('collection', fn ($query) => $query->where('user_id', $user->id)) ->latest('created_at') ->limit(20) ->get() ->map(fn (CollectionComment $comment): array => [ 'id' => 'comment:collections:' . $comment->id, 'type' => 'comment', 'module' => 'collections', 'module_label' => 'Collection comment', 'title' => 'New comment on ' . ($comment->collection?->title ?? 'collection'), 'body' => (string) $comment->body, 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'url' => $comment->collection ? route('settings.collections.show', ['collection' => $comment->collection->id]) : route('studio.activity'), 'actor' => $comment->user ? [ 'id' => (int) $comment->user->id, 'name' => $comment->user->name ?: $comment->user->username ?: 'Creator', 'username' => $comment->user->username, 'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64), ] : null, ]); $storyComments = StoryComment::query() ->with(['user.profile', 'story']) ->whereNull('deleted_at') ->whereHas('story', fn ($query) => $query->where('creator_id', $user->id)) ->latest('created_at') ->limit(20) ->get() ->map(fn (StoryComment $comment): array => [ 'id' => 'comment:stories:' . $comment->id, 'type' => 'comment', 'module' => 'stories', 'module_label' => 'Story comment', 'title' => 'New comment on ' . ($comment->story?->title ?? 'story'), 'body' => (string) ($comment->raw_content ?: $comment->content), 'created_at' => $comment->created_at?->toIso8601String(), 'time_ago' => $comment->created_at?->diffForHumans(), 'url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.activity'), 'actor' => $comment->user ? [ 'id' => (int) $comment->user->id, 'name' => $comment->user->name ?: $comment->user->username ?: 'Creator', 'username' => $comment->user->username, 'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64), ] : null, ]); return $artworkComments ->concat($cardComments) ->concat($collectionComments) ->concat($storyComments) ->values() ->all(); } private function followerItems(User $user): array { return DB::table('user_followers as uf') ->join('users as follower', 'follower.id', '=', 'uf.follower_id') ->leftJoin('user_profiles as profile', 'profile.user_id', '=', 'follower.id') ->where('uf.user_id', $user->id) ->whereNull('follower.deleted_at') ->orderByDesc('uf.created_at') ->limit(20) ->get([ 'uf.created_at', 'follower.id', 'follower.username', 'follower.name', 'profile.avatar_hash', ]) ->map(fn ($row): array => [ 'id' => 'follower:' . $row->id . ':' . strtotime((string) $row->created_at), 'type' => 'follower', 'module' => 'followers', 'module_label' => 'Follower', 'title' => ($row->name ?: $row->username ?: 'Someone') . ' followed you', 'body' => 'New audience activity in Creator Studio.', 'created_at' => $this->normalizeDate($row->created_at), 'time_ago' => null, 'url' => '/@' . strtolower((string) $row->username), 'actor' => [ 'id' => (int) $row->id, 'name' => $row->name ?: $row->username ?: 'Creator', 'username' => $row->username, 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), ], ]) ->values() ->all(); } private function normalizeType(string $type): string { return in_array($type, ['all', 'notification', 'comment', 'follower'], true) ? $type : 'all'; } private function normalizeModule(string $module): string { return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories', 'followers', 'system'], true) ? $module : 'all'; } private function timestamp(mixed $value): int { if (! is_string($value) || $value === '') { return 0; } return strtotime($value) ?: 0; } private function normalizeDate(mixed $value): ?string { if ($value instanceof \DateTimeInterface) { return $value->format(DATE_ATOM); } if (is_string($value) && $value !== '') { return $value; } return null; } }