Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/Http/Controllers/LinkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
use Inertia\Response;

/**
* A prototype gathering every commit about a link on one page, like the comments under it.
* A prototype of topics, a link being one: a page to explore them, and a page for each one with every commit about it.
*/
final readonly class LinkController
{
Expand All @@ -27,7 +27,7 @@ public function index(ListLinksRequest $request, #[CurrentUser] User $user, Link
if ($url === null) {
return Inertia::render('links/index', [
'url' => null,
'topLinks' => $topLinks->get(),
'topLinks' => $topLinks->get($user),
]);
}

Expand Down
160 changes: 149 additions & 11 deletions app/Queries/LinkCommitsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,118 @@
use App\Models\Commit;
use App\Models\LinkPreview;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder;

/**
* The commits whose link card is the given link, oldest first, so they read like the comments under it.
* The commits whose link card is the given link, the link being their topic.
*/
final readonly class LinkCommitsQuery
{
private const int RECENT_HOURS = 6;

private const int TOP_COMMITS = 3;

/**
* Every commit of the topic, newest first, each standing on its own like in a log.
*/
public function builder(LinkPreview $link, User $user): CommitBuilder
{
return Commit::query()
return $this->withCardData(Commit::query(), $user)
->where('link_preview_id', $link->id)
->visibleTo($user)
->withVisibleReplyCount($user)
->withReplyPreviews($user)
->withCardDataFor($user)
->with([
'parent' => function (BelongsTo $parent) use ($user): void {
$parent->getQuery()->withTrashed()->visibleTo($user)->with('author');
},
])
->orderBy('commits.created_at')->orderBy('commits.id');
->latest('commits.created_at')
->orderByDesc('commits.id');
}

/**
* The most starred commits of each topic over the last 6 hours, then its newest ones, keyed by link. They are
* ranked in a joined subquery, among the commits the viewer may see.
*
* @param list<string> $linkIds
* @return array<string, list<Commit>>
*/
public function top(array $linkIds, User $user): array
{
$recentStars = $this->recentStars()
->select('commit_id')
->selectRaw('count(*) as stars')
->whereIn('commit_id', Commit::query()->whereIn('link_preview_id', $linkIds)->select('id'))
->groupBy('commit_id');

$ranked = Commit::query()
->leftJoinSub($recentStars, 'recent_stars', 'recent_stars.commit_id', '=', 'commits.id')
->whereIn('link_preview_id', $linkIds)
->visibleTo($user)
->select('commits.id as ranked_id')
->selectRaw('row_number() over (partition by commits.link_preview_id order by coalesce(recent_stars.stars, 0) desc, commits.created_at desc, commits.id desc) as topic_rank');

/** @var Collection<int, Commit> $commits */
$commits = $this->withCardData(Commit::query(), $user)
->joinSub($ranked, 'ranked', 'ranked.ranked_id', '=', 'commits.id')
->where('ranked.topic_rank', '<=', self::TOP_COMMITS)
->orderBy('ranked.topic_rank')
->get();

$top = [];

foreach ($commits as $commit) {
$top[(string) $commit->link_preview_id][] = $commit;
}

return $top;
}

/**
* How many commits of each link the viewer may see, however old, keyed by link.
*
* @param list<string> $linkIds
* @return array<string, int>
*/
public function visibleCounts(array $linkIds, User $user): array
{
/** @var list<object{link_preview_id: string, commits: int}> $rows */
$rows = Commit::query()
->whereIn('link_preview_id', $linkIds)
->visibleTo($user)
->toBase()
->select('link_preview_id')
->selectRaw('count(*) as commits')
->groupBy('link_preview_id')
->get()
->all();

$counts = [];

foreach ($rows as $row) {
$counts[$row->link_preview_id] = $row->commits;
}

return $counts;
}

/**
* How many stars the approved commits of each link got over the last 6 hours, keyed by link.
*
* @param list<string> $linkIds
* @return array<string, int>
*/
public function recentStarCounts(array $linkIds): array
{
return $this->starCounts($this->recentStars(), $linkIds);
}

/**
* How many stars the approved commits of each link have, keyed by link.
*
* @param list<string> $linkIds
* @return array<string, int>
*/
public function totalStarCounts(array $linkIds): array
{
return $this->starCounts((new Commit)->stars()->newPivotStatement(), $linkIds);
}

/**
Expand All @@ -37,6 +128,53 @@ public function stats(LinkPreview $link): array
{
$commits = Commit::query()->where('link_preview_id', $link->id)->approved();

return ['commits' => $commits->count(), 'people' => $commits->distinct()->count('user_id')];
return [
'commits' => $commits->count(),
'people' => $commits->clone()->distinct()->count('user_id'),
];
}

/**
* @param list<string> $linkIds
* @return array<string, int>
*/
private function starCounts(Builder $stars, array $linkIds): array
{
$commits = Commit::query()->whereIn('link_preview_id', $linkIds)->approved()->toBase()->select('id', 'link_preview_id');

/** @var list<object{link_preview_id: string, stars: int}> $rows */
$rows = $stars
->joinSub($commits, 'link_commits', 'link_commits.id', '=', 'stars.commit_id')
->groupBy('link_commits.link_preview_id')
->select('link_commits.link_preview_id')
->selectRaw('count(*) as stars')
->get()
->all();

$counts = [];

foreach ($rows as $row) {
$counts[$row->link_preview_id] = $row->stars;
}

return $counts;
}

private function withCardData(CommitBuilder $commits, User $user): CommitBuilder
{
return $commits
->withVisibleReplyCount($user)
->withCardDataFor($user)
->with([
'parent' => function (BelongsTo $parent) use ($user): void {
$parent->getQuery()->withTrashed()->visibleTo($user)->with('author');
},
]);
}

private function recentStars(): Builder
{
return (new Commit)->stars()->newPivotStatement()
->where('stars.created_at', '>=', now()->subHours(self::RECENT_HOURS));
}
}
37 changes: 30 additions & 7 deletions app/Queries/TopLinksQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,33 @@
namespace App\Queries;

use App\Models\Commit;
use App\Models\User;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;

/**
* The links that the most people used as their link card lately, to try the link page on real ones.
* The links that the most people used as their link card lately, as topics to explore: the ones whose commits got stars
* over the last 6 hours first, each with its most starred commits. Only the ranking looks at the last 30 days: the
* commits shown and counted are all the ones the viewer may see, as on the link's own page.
*
* @phpstan-type TopLinkRow object{url: string, title: string|null, site_name: string|null, people: int, commits: int, last_mentioned_at: string}
* @phpstan-type TopLink array{url: string, title: string|null, site_name: string|null, people: int, commits: int, last_mentioned_at: string}
* @phpstan-type TopLinkRow object{id: string, url: string, title: string|null, description: string|null, site_name: string|null, people: int, commits: int, last_mentioned_at: string}
* @phpstan-type TopLink array{url: string, title: string|null, description: string|null, site_name: string|null, people: int, commits: int, last_mentioned_at: string, stars: int, stars_last_6h: int, top_commits: list<Commit>}
*/
final readonly class TopLinksQuery
{
private const int DAYS = 30;

private const int LIMIT = 10;

public function __construct(private LinkCommitsQuery $commits)
{
//
}

/**
* @return list<TopLink>
*/
public function get(): array
public function get(User $user): array
{
$counts = Commit::query()
->approved()
Expand All @@ -40,16 +48,31 @@ public function get(): array
->join('link_previews', 'link_previews.id', '=', 'counts.link_preview_id')
->orderByDesc('people')->orderByDesc('commits')->latest('last_mentioned_at')
->limit(self::LIMIT)
->get(['link_previews.url', 'link_previews.title', 'link_previews.site_name', 'counts.people', 'counts.commits', 'counts.last_mentioned_at'])
->get(['link_previews.id', 'link_previews.url', 'link_previews.title', 'link_previews.description', 'link_previews.site_name', 'counts.people', 'counts.commits', 'counts.last_mentioned_at'])
->all();

return array_map(fn (object $row): array => [
$linkIds = array_map(fn (object $row): string => $row->id, $rows);
$top = $this->commits->top($linkIds, $user);
$recentStars = $this->commits->recentStarCounts($linkIds);
$stars = $this->commits->totalStarCounts($linkIds);
$visibleCommits = $this->commits->visibleCounts($linkIds, $user);

$links = array_map(fn (object $row): array => [
'url' => $row->url,
'title' => $row->title,
'description' => $row->description,
'site_name' => $row->site_name,
'people' => $row->people,
'commits' => $row->commits,
'commits' => $visibleCommits[$row->id] ?? 0,
'last_mentioned_at' => Date::parse($row->last_mentioned_at)->toIso8601String(),
'stars' => $stars[$row->id] ?? 0,
'stars_last_6h' => $recentStars[$row->id] ?? 0,
'top_commits' => $top[$row->id] ?? [],
], $rows);

// Sorting is stable, so links with as many recent stars stay in order of how many people shared them.
usort($links, fn (array $a, array $b): int => $b['stars_last_6h'] <=> $a['stars_last_6h']);

return $links;
}
}
Loading
Loading