eagerLoads()) ->where('user_id', $profileUser->id) ->visibleTo($viewerId); // Pinned posts (always on page 1, regardless of pagination) $pinned = (clone $baseQuery) ->where('is_pinned', true) ->orderBy('pinned_order') ->get(); $paginated = (clone $baseQuery) ->orderByDesc('created_at') ->paginate(self::PER_PAGE, ['*'], 'page', $page); // On page 1, prepend pinned posts (deduplicated) $paginatedCollection = $paginated->getCollection(); if ($page === 1 && $pinned->isNotEmpty()) { $pinnedIds = $pinned->pluck('id'); $rest = $paginatedCollection->reject(fn ($p) => $pinnedIds->contains($p->id)); $combined = $pinned->concat($rest); } else { $combined = $paginatedCollection->reject(fn ($p) => $p->is_pinned && $page === 1); } return [ 'data' => $combined->values()->all(), 'meta' => [ 'total' => $paginated->total(), 'current_page' => $paginated->currentPage(), 'last_page' => $paginated->lastPage(), 'per_page' => $paginated->perPage(), ], ]; } // ───────────────────────────────────────────────────────────────────────── // Following feed (ranked + diversity-limited) // ───────────────────────────────────────────────────────────────────────── public function getFollowingFeed( User $viewer, int $page = 1, string $filter = 'all', ): array { $followingIds = DB::table('user_followers') ->where('follower_id', $viewer->id) ->pluck('user_id') ->toArray(); if (empty($followingIds)) { return ['data' => [], 'meta' => ['total' => 0, 'current_page' => $page, 'last_page' => 1, 'per_page' => self::PER_PAGE]]; } $query = Post::with($this->eagerLoads()) ->whereIn('user_id', $followingIds) ->visibleTo($viewer->id) ->orderByDesc('created_at'); if ($filter === 'shares') $query->where('type', Post::TYPE_ARTWORK_SHARE); elseif ($filter === 'text') $query->where('type', Post::TYPE_TEXT); elseif ($filter === 'uploads') $query->where('type', Post::TYPE_UPLOAD); $paginated = $query->paginate(self::PER_PAGE, ['*'], 'page', $page); $diversified = $this->applyDiversityPass($paginated->getCollection()); return [ 'data' => $diversified->values()->all(), 'meta' => [ 'total' => $paginated->total(), 'current_page' => $paginated->currentPage(), 'last_page' => $paginated->lastPage(), 'per_page' => $paginated->perPage(), ], ]; } // ───────────────────────────────────────────────────────────────────────── // Hashtag feed // ───────────────────────────────────────────────────────────────────────── public function getHashtagFeed( string $tag, ?int $viewerId, int $page = 1, ): array { $tag = mb_strtolower($tag); $paginated = Post::with($this->eagerLoads()) ->whereHas('hashtags', fn ($q) => $q->where('tag', $tag)) ->visibleTo($viewerId) ->orderByDesc('created_at') ->paginate(self::PER_PAGE, ['*'], 'page', $page); return [ 'data' => $paginated->getCollection()->values()->all(), 'meta' => [ 'total' => $paginated->total(), 'current_page' => $paginated->currentPage(), 'last_page' => $paginated->lastPage(), 'per_page' => $paginated->perPage(), ], ]; } // ───────────────────────────────────────────────────────────────────────── // Saved posts feed // ───────────────────────────────────────────────────────────────────────── public function getSavedFeed(User $viewer, int $page = 1): array { $paginated = Post::with($this->eagerLoads()) ->whereHas('saves', fn ($q) => $q->where('user_id', $viewer->id)) ->where('status', Post::STATUS_PUBLISHED) ->orderByDesc('created_at') ->paginate(self::PER_PAGE, ['*'], 'page', $page); return [ 'data' => $paginated->getCollection()->values()->all(), 'meta' => [ 'total' => $paginated->total(), 'current_page' => $paginated->currentPage(), 'last_page' => $paginated->lastPage(), 'per_page' => $paginated->perPage(), ], ]; } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── /** Eager loads for authenticated/profile feeds. */ private function eagerLoads(): array { return [ 'user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions', 'hashtags', ]; } /** Eager loads safe for public (trending) feed calls from PostTrendingService. */ public function publicEagerLoads(): array { return $this->eagerLoads(); } /** * Penalize runs of 5+ posts from the same author by deferring them to the end. */ public function applyDiversityPass(Collection $posts): Collection { $result = collect(); $deferred = collect(); $runCounts = []; foreach ($posts as $post) { $uid = $post->user_id; $runCounts[$uid] = ($runCounts[$uid] ?? 0) + 1; ($runCounts[$uid] <= 5 ? $result : $deferred)->push($post); } return $result->merge($deferred); } // ───────────────────────────────────────────────────────────────────────── // Formatter // ───────────────────────────────────────────────────────────────────────── /** * Serialize a Post into a JSON-safe array for API responses. */ public function formatPost(Post $post, ?int $viewerId): array { $artworkData = null; if ($post->type === Post::TYPE_ARTWORK_SHARE) { $target = $post->targets->firstWhere('target_type', 'artwork'); $artwork = $target?->artwork; if ($artwork) { $artworkData = [ 'id' => $artwork->id, 'title' => $artwork->title, 'slug' => $artwork->slug, 'thumb_url' => $artwork->thumb_url ?? null, 'author' => [ 'id' => $artwork->user->id, 'username' => $artwork->user->username, 'name' => $artwork->user->name, 'avatar' => $artwork->user->profile?->avatar_url ?? null, ], ]; } } $viewerLiked = $viewerSaved = false; if ($viewerId) { $viewerLiked = $post->reactions->where('user_id', $viewerId)->where('reaction', 'like')->isNotEmpty(); // saves are lazy-loaded only when needed; check if relation is loaded if ($post->relationLoaded('saves')) { $viewerSaved = $post->saves->where('user_id', $viewerId)->isNotEmpty(); } else { $viewerSaved = $post->saves()->where('user_id', $viewerId)->exists(); } } return [ 'id' => $post->id, 'type' => $post->type, 'visibility' => $post->visibility, 'status' => $post->status, 'body' => $post->body, 'reactions_count' => $post->reactions_count, 'comments_count' => $post->comments_count, 'saves_count' => $post->saves_count, 'impressions_count'=> $post->impressions_count, 'is_pinned' => (bool) $post->is_pinned, 'pinned_order' => $post->pinned_order, 'publish_at' => $post->publish_at?->toISOString(), 'viewer_liked' => $viewerLiked, 'viewer_saved' => $viewerSaved, 'artwork' => $artworkData, 'author' => [ 'id' => $post->user->id, 'username' => $post->user->username, 'name' => $post->user->name, 'avatar' => $post->user->profile?->avatar_url ?? null, ], 'hashtags' => $post->relationLoaded('hashtags') ? $post->hashtags->pluck('tag')->toArray() : [], 'meta' => $post->meta, 'created_at' => $post->created_at->toISOString(), 'updated_at' => $post->updated_at->toISOString(), ]; } }