Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
263 lines
11 KiB
PHP
263 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Posts;
|
|
|
|
use App\Models\Post;
|
|
use App\Models\PostSave;
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class PostFeedService
|
|
{
|
|
private const PER_PAGE = 20;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Profile feed — pinned posts first, then chronological
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public function getProfileFeed(
|
|
User $profileUser,
|
|
?int $viewerId,
|
|
int $page = 1,
|
|
): array {
|
|
$baseQuery = Post::with($this->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(),
|
|
];
|
|
}
|
|
}
|