From 2378cc7af71571e02963ec7e4d21799bc3471e1d Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 7 Nov 2026 01:06:04 +0100 Subject: [PATCH 1/4] feat: try topics on the experimental links pages The links page lists links as topics, each with its 3 most starred commits of the last 6 hours nested under it. A link's own page gets a header like an organization's, above a feed like the log. Everything stays in the links pages: the commits nested under a link have their own component rather than changing the shared commit or reply ones. The icons come from favicon.show for now. --- app/Http/Controllers/LinkController.php | 4 +- app/Queries/LinkCommitsQuery.php | 132 ++++- app/Queries/TopLinksQuery.php | 33 +- .../js/components/link-topic-commits.tsx | 320 ++++++++++ resources/js/pages/links/index.tsx | 553 ++++++++++++++---- .../Controllers/LinkControllerTest.php | 62 +- 6 files changed, 974 insertions(+), 130 deletions(-) create mode 100644 resources/js/components/link-topic-commits.tsx diff --git a/app/Http/Controllers/LinkController.php b/app/Http/Controllers/LinkController.php index e5095d8669..9da6d7842e 100644 --- a/app/Http/Controllers/LinkController.php +++ b/app/Http/Controllers/LinkController.php @@ -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 { @@ -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), ]); } diff --git a/app/Queries/LinkCommitsQuery.php b/app/Queries/LinkCommitsQuery.php index 23f92ddd98..e6b02d3611 100644 --- a/app/Queries/LinkCommitsQuery.php +++ b/app/Queries/LinkCommitsQuery.php @@ -7,27 +7,90 @@ 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 $linkIds + * @return array> + */ + 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 $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 stars the approved commits of each link got over the last 6 hours, keyed by link. + * + * @param list $linkIds + * @return array + */ + 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 $linkIds + * @return array + */ + public function totalStarCounts(array $linkIds): array + { + return $this->starCounts((new Commit)->stars()->newPivotStatement(), $linkIds); } /** @@ -37,6 +100,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 $linkIds + * @return array + */ + 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 $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)); } } diff --git a/app/Queries/TopLinksQuery.php b/app/Queries/TopLinksQuery.php index 8fd8feb109..c1150b5206 100644 --- a/app/Queries/TopLinksQuery.php +++ b/app/Queries/TopLinksQuery.php @@ -5,14 +5,16 @@ 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. * - * @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} */ final readonly class TopLinksQuery { @@ -20,10 +22,15 @@ private const int LIMIT = 10; + public function __construct(private LinkCommitsQuery $commits) + { + // + } + /** * @return list */ - public function get(): array + public function get(User $user): array { $counts = Commit::query() ->approved() @@ -40,16 +47,30 @@ 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); + + $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, '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; } } diff --git a/resources/js/components/link-topic-commits.tsx b/resources/js/components/link-topic-commits.tsx new file mode 100644 index 0000000000..7605078558 --- /dev/null +++ b/resources/js/components/link-topic-commits.tsx @@ -0,0 +1,320 @@ +import { Link } from '@inertiajs/react'; +import type { InertiaLinkProps } from '@inertiajs/react'; +import { CornerDownRight } from 'lucide-react'; +import type { CSSProperties } from 'react'; +import { useCallback, useRef } from 'react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import { AuthorAvatar } from '@/components/author-avatar'; +import { FoldedCommitBody } from '@/components/commit-body'; +import { CommitImages } from '@/components/commit-images'; +import { CommitPastelButton } from '@/components/commit-pastel-button'; +import { CommitPoll } from '@/components/commit-poll'; +import { CommitQuoteButton } from '@/components/commit-quote-button'; +import { CommitStarButton } from '@/components/commit-star-button'; +import { CommitStatusBadge } from '@/components/commit-status-badge'; +import { + ShowThreadLink, + ThreadActions, + ThreadReplies, + ThreadSpine, + useThread, +} from '@/components/commit-thread'; +import { useCommitTranslation } from '@/components/commit-translation'; +import { + GraphScope, + useGraphNode, + useGraphNodeId, +} from '@/components/graph-canvas'; +import { RailDot, railDotRing } from '@/components/graph-rail'; +import { QuotedCommit } from '@/components/quoted-commit'; +import { TimestampTooltip } from '@/components/timestamp-tooltip'; +import { UserPreview } from '@/components/user-preview'; +import { useIsCommitDeleted } from '@/hooks/use-deleted-resources'; +import { useNow } from '@/hooks/use-now'; +import { formatCount } from '@/lib/numbers'; +import { + cn, + formatRelativeTime, + isDeleted, + laneOf, + shortHash, +} from '@/lib/utils'; +import { show } from '@/routes/user'; +import type { Commit } from '@/types'; + +/** The avatar's ring reaches this far beyond its picture, like a reply's. */ +const avatarRing = 4; + +/** + * The most starred commits of a link, nested under it like replies: the link stands where a commit's author would, and + * its lane branches off to each of them. They read like commits of the log, with a smaller avatar. The commit card + * would bring its large one, and a reply belongs to the commit above it, so this page draws its own. + */ +export function LinkTopicCommits({ + commits, + lane, + graphParent, + more, +}: { + commits: Commit[]; + lane: string; + /** The node the commits branch off. */ + graphParent: string; + /** The link's other commits, when it has more than the ones listed. */ + more?: { count: number; href: NonNullable }; +}) { + return ( +
+ {commits.map((commit) => ( + // A commit can also be listed as the reply of another one, whose node then has an id of its own. + + + + ))} + {more && ( +
+ +
+ )} +
+ ); +} + +function LinkTopicCommit({ + commit, + graphParent, +}: { + commit: Commit; + graphParent: string; +}) { + const now = useNow(); + const thread = useThread(commit); + const { bodyRef, ...translation } = useCommitTranslation(commit); + const wasDeleted = useIsCommitDeleted(commit.id); + const nodeRef = useGraphNode({ + id: useGraphNodeId()(commit.id), + lane: laneOf(commit), + parent: graphParent, + dashed: true, + ring: avatarRing, + }); + const avatarRef = useRef(null); + // A stable ref, so the node stays registered across renders. + const avatarNodeRef = useCallback( + (element: HTMLAnchorElement | null) => { + avatarRef.current = element; + nodeRef(element); + }, + [nodeRef], + ); + const permalink = CommitController.show({ + profile: commit.author, + commit, + }); + + return ( +
+
+
+ + + + + + +
+ +
+
+ + + {commit.author.name} + + + @{commit.author.handle} + + + + · + + + + + + + + + {shortHash(commit.id)} + +
+ + {commit.parent && ( +

+ + + replying to{' '} + {isDeleted(commit.parent) + ? '[deleted]' + : `@${commit.parent.author.handle}`} + +

+ )} + +
+ +
+ {translation.notice} + + {commit.poll && ( + + )} + {commit.quoted_commit_id !== null && ( + + )} + + + + + + + {translation.button} + + } + /> +
+
+ + +
+ ); +} + +function MoreCommitsLink({ + count, + href, + graphParent, +}: { + count: number; + href: NonNullable; + graphParent: string; +}) { + const dotRef = useGraphNode({ + id: `${graphParent}:more`, + lane: 'var(--muted-foreground)', + parent: graphParent, + dashed: true, + ring: railDotRing, + }); + + return ( + + + + + + + + Show all {formatCount(count)} + + + ); +} diff --git a/resources/js/pages/links/index.tsx b/resources/js/pages/links/index.tsx index a5f5f17c57..cb01a19fef 100644 --- a/resources/js/pages/links/index.tsx +++ b/resources/js/pages/links/index.tsx @@ -1,36 +1,47 @@ import { Head, Link, router } from '@inertiajs/react'; -import { ArrowLeft, Link2 } from 'lucide-react'; -import type { FormEvent } from 'react'; +import { ArrowLeft, ExternalLink, Link2, Star } from 'lucide-react'; +import type { CSSProperties, FormEvent, MouseEvent } from 'react'; import { useState } from 'react'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; import LinkController from '@/actions/App/Http/Controllers/LinkController'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; +import { GraphCanvas } from '@/components/graph-canvas'; +import { GraphRail, RailSegment } from '@/components/graph-rail'; import { HiddenLinkPreviewContext } from '@/components/hidden-link-preview-context'; import { LinkPreview } from '@/components/link-preview'; +import { LinkTopicCommits } from '@/components/link-topic-commits'; +import { Button } from '@/components/ui/button'; import AppLayout from '@/layouts/app-layout'; +import { clickedCommit } from '@/lib/commit-click'; +import { exactCountTitle, formatCount } from '@/lib/numbers'; import { hostname } from '@/lib/urls'; -import { formatRelativeTime } from '@/lib/utils'; +import { cn, formatRelativeTime, isDeleted, laneColor } from '@/lib/utils'; import type { Commit, CursorPaginated, LinkPreview as LinkPreviewData, } from '@/types'; -type TopLink = { +type LinkDetails = Pick; + +type TopLink = LinkDetails & { url: string; - title: string | null; - site_name: string | null; people: number; commits: number; last_mentioned_at: string; + stars: number; + stars_last_6h: number; + top_commits: Commit[]; }; +type LinkStats = { commits: number; people: number }; + type Props = | { url: null; topLinks: TopLink[] } | { url: string; link: LinkPreviewData | null; - stats: { commits: number; people: number }; + stats: LinkStats; commits: CursorPaginated | null; }; @@ -38,12 +49,141 @@ function commitCount(count: number) { return `${count} commit${count === 1 ? '' : 's'}`; } -function peopleCount(count: number) { - return count === 1 ? '1 person' : `${count} people`; +/** + * What stands for a link as a topic. Most links are pages of any site, which go by their title and the site's icon. A + * GitHub project has a better name and picture than its page's: its own, and its owner's avatar. + */ +function linkTopic(url: string, link: LinkDetails) { + const [, owner, project] = + /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)(?:\/([^/?#]+))?/.exec( + url, + ) ?? []; + + return owner + ? { + title: project ? `${owner}/${project}` : owner, + iconUrl: `https://github.com/${owner}.png?size=160`, + } + : { + title: link.title ?? displayedLink(url), + iconUrl: siteFavicon(url), + }; +} + +/** A stand-in until the app reads icons itself, when it fetches a link's page. */ +function siteFavicon(url: string): string { + return `https://favicon.show/${hostname(url)}`; +} + +/** + * Links have no colour of their own yet, so each one gets a lane of the palette, the same one every time. + */ +function linkLane(url: string): string { + const hash = Array.from(url).reduce( + (sum, character) => (sum * 31 + character.charCodeAt(0)) % 1_000_003, + 7, + ); + + return laneColor(hash % 6); +} + +/** The whole link, without the protocol and the www. most links start with. */ +function displayedLink(url: string): string { + return url.replace(/^https?:\/\/(?:www\.)?/u, ''); +} + +function linkPage(url: string) { + return LinkController.index({ query: { url } }); } export default function Index(props: Props) { - const [draft, setDraft] = useState(props.url ?? ''); + return props.url === null ? ( + + ) : ( + + ); +} + +function TopicIcon({ + iconUrl, + className, +}: { + iconUrl: string | null; + className?: string; +}) { + const [isBroken, setIsBroken] = useState(false); + const hasIcon = iconUrl !== null && !isBroken; + + return ( + + {hasIcon ? ( + setIsBroken(true)} + className="size-full object-contain" + /> + ) : ( + + )} + + ); +} + +/** The image a page gives for sharing it, as large as sites draw it, leading to the page. */ +function SocialCard({ url, imageUrl }: { url: string; imageUrl: string }) { + const [isBroken, setIsBroken] = useState(false); + + return isBroken ? null : ( + + setIsBroken(true)} + className="aspect-[1.91/1] w-full bg-muted object-cover" + /> + + ); +} + +function LinkCount({ count, label }: { count: number; label: string }) { + return ( +
  • + + {formatCount(count)} + + {label} +
  • + ); +} + +function TopLinks({ links }: { links: TopLink[] }) { + const [draft, setDraft] = useState(''); + const active = links.filter((link) => link.stars_last_6h > 0); + const quiet = links.filter((link) => link.stars_last_6h === 0); const submit = (event: FormEvent) => { event.preventDefault(); @@ -65,7 +205,7 @@ export default function Index(props: Props) { setDraft(event.target.value)} placeholder="Paste a link to see every commit about it" @@ -76,28 +216,6 @@ export default function Index(props: Props) { - {props.url === null ? ( - - ) : ( - - )} - - ); -} - -function TopLinks({ links }: { links: TopLink[] }) { - return ( - <> -

    - Most shared links in the last 30 days, by people who used them - as their link card. -

    - {links.length === 0 ? (
    @@ -105,44 +223,206 @@ function TopLinks({ links }: { links: TopLink[] }) {
    ) : ( -
      - {links.map((link, index) => ( -
    1. - - - {index + 1} - - - - {link.site_name ?? hostname(link.url)} - - - {link.title ?? link.url} - - - {peopleCount(link.people)} ·{' '} - {commitCount(link.commits)} · last{' '} - {formatRelativeTime( - link.last_mentioned_at, - )} - - - -
    2. + + {active.map((link) => ( + ))} -
    + {quiet.length > 0 && ( +
    +

    + quiet + + no stars in 6h + +

    +
      + {quiet.map((link) => ( + + ))} +
    +
    + )} + )} ); } -function LinkCommits({ +/** + * A link in the list of topics, drawn like a commit with replies: the topic stands where the author would, its line in + * the topic's colour, and its most starred commits nest below, each in its own author's colour. + */ +function LinkTopic({ link }: { link: TopLink }) { + const topic = linkTopic(link.url, link); + const lane = linkLane(link.url); + const href = linkPage(link.url); + const nodeId = `topic:${link.url}`; + const endId = `${nodeId}:end`; + const hasMore = link.commits > link.top_commits.length; + const shownLink = displayedLink(link.url); + + const openClickedCommit = (event: MouseEvent) => { + const click = clickedCommit( + event, + window.getSelection()?.toString() ?? '', + window.location.pathname, + ); + + if (click?.newTab) { + window.open(click.url, '_blank', 'noopener'); + } else if (click) { + router.visit(click.url); + } + }; + + return ( +
    + 0} + graph={{ id: nodeId, endId }} + node={ + + + + + + + + } + /> + +
    + + {topic.title} + + {topic.title !== shownLink && ( + + {shownLink} + + )} +

    + + + {formatCount(link.stars)} + + {commitCount(link.commits)} +

    +
    + + +
    + +
    +
    + ); +} + +/** + * A link no commit got stars on lately folds to a line, with what was last said about it. + */ +function QuietLinkTopic({ link }: { link: TopLink }) { + const topic = linkTopic(link.url, link); + const last = link.top_commits[0]; + + return ( +
  • + + + + + + + + + + {topic.title} + + + + {commitCount(link.commits)} ·{' '} + + last {formatRelativeTime(link.last_mentioned_at)} + + + {topic.title !== displayedLink(link.url) && ( + + {displayedLink(link.url)} + + )} + {last && !isDeleted(last) && ( + + + {last.author.name} + {' '} + {last.body} + + )} + + +
  • + ); +} + +/** + * A link's own page, like an organization's: who it is at the top, then its commits, each standing on its own in its + * author's lane, like in a log. + */ +function LinkPage({ url, link, stats, @@ -150,24 +430,84 @@ function LinkCommits({ }: { url: string; link: LinkPreviewData | null; - stats: { commits: number; people: number }; + stats: LinkStats; commits: CursorPaginated | null; }) { + const topic = link ? linkTopic(url, link) : null; + const title = topic?.title ?? displayedLink(url); + const lane = linkLane(url); const firstCommit = commits?.data[0]; return ( <> -
    + + +
    - - Most shared links + +

    {title}

    +

    + link +

    +
    + +
    +
    + + + + + +
    + +

    + {title} +

    +

    + + + {displayedLink(url)} + +

    + + {link?.description && ( +

    + {link.description} +

    + )} - {link && firstCommit ? ( + {link?.embed && firstCommit ? ( - ) : null} + ) : ( + link?.image_url && ( + + ) + )} - - {url} - +
      + + +
    +
    - {stats.commits > 0 && ( -

    - {commitCount(stats.commits)} by{' '} - {peopleCount(stats.people)}, oldest first -

    +
    + {commits === null ? ( +
    + + No commit uses this exact link as its card yet. + +
    + ) : ( + + + No commit you can see uses this link as its + card. + + } + /> + )}
    - - {commits === null ? ( -
    - - No commit uses this exact link as its card yet. - -
    - ) : ( - - - No commit you can see uses this link as its - card. - - } - /> - - )} ); } diff --git a/tests/Feature/Controllers/LinkControllerTest.php b/tests/Feature/Controllers/LinkControllerTest.php index 7539d01aeb..157e2f82c4 100644 --- a/tests/Feature/Controllers/LinkControllerTest.php +++ b/tests/Feature/Controllers/LinkControllerTest.php @@ -41,7 +41,7 @@ ->has('topLinks', 10)); }); -it('lists the commits whose card is the link, oldest first', function (): void { +it('lists the commits whose card is the link, newest first', function (): void { $link = LinkPreview::factory()->fetched()->create(['url' => 'https://wiki.php.net/rfc/pipe-operator']); $first = Commit::factory()->create(['link_preview_id' => $link->id, 'created_at' => now()->subDay()]); $reply = Commit::factory()->create(['link_preview_id' => $link->id, 'parent_id' => $first->id]); @@ -57,8 +57,61 @@ ->where('link.url', 'https://wiki.php.net/rfc/pipe-operator') ->where('stats', ['commits' => 2, 'people' => 2]) ->has('commits.data', 2) - ->where('commits.data.0.id', $first->id) - ->where('commits.data.1.id', $reply->id)); + ->where('commits.data.0.id', $reply->id) + ->where('commits.data.1.id', $first->id)); +}); + +it('nests the 3 most starred commits of the last 6 hours under each link, then its newest', function (): void { + $link = LinkPreview::factory()->fetched()->create(); + $trending = Commit::factory()->create(['link_preview_id' => $link->id, 'created_at' => now()->subDay()]); + $oldFavourite = Commit::factory()->create(['link_preview_id' => $link->id, 'created_at' => now()->subHours(3)]); + $newest = Commit::factory()->create(['link_preview_id' => $link->id, 'created_at' => now()->subHour()]); + $newer = Commit::factory()->create(['link_preview_id' => $link->id, 'created_at' => now()->subHours(2)]); + $trending->stars()->attach(User::factory()->count(2)->create(), ['created_at' => now()->subHour()]); + $oldFavourite->stars()->attach(User::factory()->count(5)->create(), ['created_at' => now()->subHours(7)]); + + $this->actingAs(User::factory()->create())->get(route('link.index'))->assertOk()->assertInertia(fn ($page) => $page + ->where('topLinks.0.stars_last_6h', 2) + ->where('topLinks.0.commits', 4) + ->has('topLinks.0.top_commits', 3) + ->where('topLinks.0.top_commits.0.id', $trending->id) + ->where('topLinks.0.top_commits.1.id', $newest->id) + ->where('topLinks.0.top_commits.2.id', $newer->id)); +}); + +it('counts every star of the approved commits of each link, however old', function (): void { + $link = LinkPreview::factory()->fetched()->create(); + Commit::factory()->create(['link_preview_id' => $link->id])->stars()->attach(User::factory()->count(2)->create(), ['created_at' => now()->subWeek()]); + Commit::factory()->create(['link_preview_id' => $link->id])->stars()->attach(User::factory()->create(), ['created_at' => now()]); + Commit::factory()->pending()->create(['link_preview_id' => $link->id])->stars()->attach(User::factory()->create(), ['created_at' => now()]); + + $this->actingAs(User::factory()->create())->get(route('link.index'))->assertOk()->assertInertia(fn ($page) => $page + ->where('topLinks.0.stars', 3) + ->where('topLinks.0.stars_last_6h', 1)); +}); + +it('lists the links whose commits got stars in the last 6 hours first', function (): void { + $shared = LinkPreview::factory()->fetched()->create(); + Commit::factory()->count(3)->create(['link_preview_id' => $shared->id]); + $starred = LinkPreview::factory()->fetched()->create(); + Commit::factory()->create(['link_preview_id' => $starred->id])->stars()->attach(User::factory()->create(), ['created_at' => now()]); + + $this->actingAs(User::factory()->create())->get(route('link.index'))->assertOk()->assertInertia(fn ($page) => $page + ->where('topLinks.0.url', $starred->url) + ->where('topLinks.1.url', $shared->url)); +}); + +it('hides the commits the viewer may not see from the list of links', function (): void { + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $viewer->blocking()->attach($blocked); + $link = LinkPreview::factory()->fetched()->create(); + $visible = Commit::factory()->create(['link_preview_id' => $link->id]); + Commit::factory()->for($blocked)->create(['link_preview_id' => $link->id]); + + $this->actingAs($viewer)->get(route('link.index'))->assertOk()->assertInertia(fn ($page) => $page + ->has('topLinks.0.top_commits', 1) + ->where('topLinks.0.top_commits.0.id', $visible->id)); }); it('hides the commits the viewer may not see from the link page', function (): void { @@ -73,8 +126,7 @@ $this->actingAs($viewer)->get(route('link.index', ['url' => $link->url]))->assertOk()->assertInertia(fn ($page) => $page ->has('commits.data', 2) - ->where('commits.data.0.id', $visible->id) - ->where('commits.data.1.id', $own->id)); + ->where('commits.data', fn ($commits) => collect($commits)->pluck('id')->sort()->values()->all() === collect([$visible->id, $own->id])->sort()->values()->all())); }); it('finds the link of a bare domain like its card does', function (): void { From 067c946693942f959ecb5f16c834258cb14fb8d3 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 7 Nov 2026 01:10:56 +0100 Subject: [PATCH 2/4] fix: add the return type rector asks for in the link tests --- tests/Feature/Controllers/LinkControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Controllers/LinkControllerTest.php b/tests/Feature/Controllers/LinkControllerTest.php index 157e2f82c4..e4e2d147b0 100644 --- a/tests/Feature/Controllers/LinkControllerTest.php +++ b/tests/Feature/Controllers/LinkControllerTest.php @@ -126,7 +126,7 @@ $this->actingAs($viewer)->get(route('link.index', ['url' => $link->url]))->assertOk()->assertInertia(fn ($page) => $page ->has('commits.data', 2) - ->where('commits.data', fn ($commits) => collect($commits)->pluck('id')->sort()->values()->all() === collect([$visible->id, $own->id])->sort()->values()->all())); + ->where('commits.data', fn ($commits): bool => collect($commits)->pluck('id')->sort()->values()->all() === collect([$visible->id, $own->id])->sort()->values()->all())); }); it('finds the link of a bare domain like its card does', function (): void { From ebf8b05fa4e315453f270652e0bbfac41b877df1 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 7 Nov 2026 01:23:32 +0100 Subject: [PATCH 3/4] fix: count every commit of a link the viewer may see The count came from the 30 days the ranking looks at, so "Show all" could be missing or too high next to commits ranked over all time. --- app/Queries/LinkCommitsQuery.php | 28 +++++++++++++++++++ app/Queries/TopLinksQuery.php | 6 ++-- .../Controllers/LinkControllerTest.php | 14 ++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/Queries/LinkCommitsQuery.php b/app/Queries/LinkCommitsQuery.php index e6b02d3611..a493adca40 100644 --- a/app/Queries/LinkCommitsQuery.php +++ b/app/Queries/LinkCommitsQuery.php @@ -71,6 +71,34 @@ public function top(array $linkIds, User $user): array return $top; } + /** + * How many commits of each link the viewer may see, however old, keyed by link. + * + * @param list $linkIds + * @return array + */ + public function visibleCounts(array $linkIds, User $user): array + { + /** @var list $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. * diff --git a/app/Queries/TopLinksQuery.php b/app/Queries/TopLinksQuery.php index c1150b5206..d14cf822b9 100644 --- a/app/Queries/TopLinksQuery.php +++ b/app/Queries/TopLinksQuery.php @@ -11,7 +11,8 @@ /** * 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. + * 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{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} @@ -54,6 +55,7 @@ public function get(User $user): array $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, @@ -61,7 +63,7 @@ public function get(User $user): array '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, diff --git a/tests/Feature/Controllers/LinkControllerTest.php b/tests/Feature/Controllers/LinkControllerTest.php index e4e2d147b0..c12234665e 100644 --- a/tests/Feature/Controllers/LinkControllerTest.php +++ b/tests/Feature/Controllers/LinkControllerTest.php @@ -101,6 +101,20 @@ ->where('topLinks.1.url', $shared->url)); }); +it('counts the commits of a link the viewer may see, however old', function (): void { + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $viewer->blocking()->attach($blocked); + $link = LinkPreview::factory()->fetched()->create(); + Commit::factory()->count(3)->create(['link_preview_id' => $link->id]); + Commit::factory()->create(['link_preview_id' => $link->id, 'created_at' => now()->subDays(31)]); + Commit::factory()->for($blocked)->create(['link_preview_id' => $link->id]); + + $this->actingAs($viewer)->get(route('link.index'))->assertOk()->assertInertia(fn ($page) => $page + ->where('topLinks.0.commits', 4) + ->has('topLinks.0.top_commits', 3)); +}); + it('hides the commits the viewer may not see from the list of links', function (): void { $viewer = User::factory()->create(); $blocked = User::factory()->create(); From d0ff0e86e438e3f6e8922eed55935a404e7ff214 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 7 Nov 2026 01:34:45 +0100 Subject: [PATCH 4/4] fix: keep invalid links away from favicon.show --- resources/js/pages/links/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/links/index.tsx b/resources/js/pages/links/index.tsx index cb01a19fef..f402038520 100644 --- a/resources/js/pages/links/index.tsx +++ b/resources/js/pages/links/index.tsx @@ -71,8 +71,8 @@ function linkTopic(url: string, link: LinkDetails) { } /** A stand-in until the app reads icons itself, when it fetches a link's page. */ -function siteFavicon(url: string): string { - return `https://favicon.show/${hostname(url)}`; +function siteFavicon(url: string): string | null { + return URL.canParse(url) ? `https://favicon.show/${hostname(url)}` : null; } /**