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:
146
app/Services/Posts/PostTrendingService.php
Normal file
146
app/Services/Posts/PostTrendingService.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Computes the trending post feed with Wilson/decay scoring and author diversity.
|
||||
*
|
||||
* Score formula (per spec):
|
||||
* base = (likes * 3) + (comments * 5) + (shares * 6) + (unique_reactors * 4)
|
||||
* score = base * exp(-hours_since_post / 24)
|
||||
*
|
||||
* Diversity rule: max 2 posts per author in the top N results.
|
||||
* Cache TTL: 2 minutes.
|
||||
*/
|
||||
class PostTrendingService
|
||||
{
|
||||
private const CACHE_KEY = 'feed:trending';
|
||||
private const CACHE_TTL = 120; // seconds
|
||||
private const WINDOW_DAYS = 7;
|
||||
private const MAX_PER_AUTHOR = 2;
|
||||
|
||||
private PostFeedService $feedService;
|
||||
|
||||
public function __construct(PostFeedService $feedService)
|
||||
{
|
||||
$this->feedService = $feedService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return trending posts for the given viewer.
|
||||
*
|
||||
* @param int|null $viewerId
|
||||
* @param int $page
|
||||
* @param int $perPage
|
||||
* @return array{data: array, meta: array}
|
||||
*/
|
||||
public function getTrending(?int $viewerId, int $page = 1, int $perPage = 20): array
|
||||
{
|
||||
$rankedIds = $this->getRankedIds();
|
||||
|
||||
// Paginate from the ranked ID list
|
||||
$total = count($rankedIds);
|
||||
$pageIds = array_slice($rankedIds, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
if (empty($pageIds)) {
|
||||
return ['data' => [], 'meta' => ['total' => $total, 'current_page' => $page, 'last_page' => (int) ceil($total / $perPage) ?: 1, 'per_page' => $perPage]];
|
||||
}
|
||||
|
||||
// Load posts preserving ranked order
|
||||
$posts = Post::with($this->feedService->publicEagerLoads())
|
||||
->whereIn('id', $pageIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ordered = array_filter(array_map(fn ($id) => $posts->get($id), $pageIds));
|
||||
$data = array_values(array_map(
|
||||
fn ($post) => $this->feedService->formatPost($post, $viewerId),
|
||||
$ordered,
|
||||
));
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($total / $perPage) ?: 1,
|
||||
'per_page' => $perPage,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or compute the ranked post-ID list from cache.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public function getRankedIds(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
|
||||
return $this->computeRankedIds();
|
||||
});
|
||||
}
|
||||
|
||||
/** Force a cache refresh (called by the CLI command). */
|
||||
public function refresh(): array
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
return $this->getRankedIds();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function computeRankedIds(): array
|
||||
{
|
||||
$cutoff = now()->subDays(self::WINDOW_DAYS);
|
||||
|
||||
$rows = DB::table('posts')
|
||||
->leftJoin(
|
||||
DB::raw('(SELECT post_id, COUNT(*) as unique_reactors FROM post_reactions GROUP BY post_id) pr'),
|
||||
'posts.id', '=', 'pr.post_id',
|
||||
)
|
||||
->where('posts.status', Post::STATUS_PUBLISHED)
|
||||
->where('posts.visibility', Post::VISIBILITY_PUBLIC)
|
||||
->where('posts.created_at', '>=', $cutoff)
|
||||
->whereNull('posts.deleted_at')
|
||||
->select([
|
||||
'posts.id',
|
||||
'posts.user_id',
|
||||
'posts.reactions_count',
|
||||
'posts.comments_count',
|
||||
'posts.created_at',
|
||||
DB::raw('COALESCE(pr.unique_reactors, 0) as unique_reactors'),
|
||||
])
|
||||
->get();
|
||||
|
||||
$now = now()->timestamp;
|
||||
|
||||
$scored = $rows->map(function ($row) use ($now) {
|
||||
$hoursSince = ($now - strtotime($row->created_at)) / 3600;
|
||||
$base = ($row->reactions_count * 3)
|
||||
+ ($row->comments_count * 5)
|
||||
+ ($row->unique_reactors * 4);
|
||||
$score = $base * exp(-$hoursSince / 24);
|
||||
|
||||
return ['id' => $row->id, 'user_id' => $row->user_id, 'score' => $score];
|
||||
})->sortByDesc('score');
|
||||
|
||||
// Apply author diversity: max MAX_PER_AUTHOR posts per author
|
||||
$authorCount = [];
|
||||
$result = [];
|
||||
|
||||
foreach ($scored as $item) {
|
||||
$uid = $item['user_id'];
|
||||
$authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1;
|
||||
if ($authorCount[$uid] <= self::MAX_PER_AUTHOR) {
|
||||
$result[] = $item['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user