feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
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
This commit is contained in:
262
app/Services/Posts/PostFeedService.php
Normal file
262
app/Services/Posts/PostFeedService.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user