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
147 lines
4.7 KiB
PHP
147 lines
4.7 KiB
PHP
<?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;
|
|
}
|
|
}
|