Files
SkinbaseNova/app/Services/Posts/PostTrendingService.php
Gregor Klevze dc51d65440 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
2026-03-03 09:48:31 +01:00

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;
}
}