diff --git a/app/Http/Controllers/LinkController.php b/app/Http/Controllers/LinkController.php index e5095d866..9da6d7842 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 23f92ddd9..a493adca4 100644 --- a/app/Queries/LinkCommitsQuery.php +++ b/app/Queries/LinkCommitsQuery.php @@ -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 $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 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. + * + * @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 +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 $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 8fd8feb10..d14cf822b 100644 --- a/app/Queries/TopLinksQuery.php +++ b/app/Queries/TopLinksQuery.php @@ -5,14 +5,17 @@ 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} */ final readonly class TopLinksQuery { @@ -20,10 +23,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 +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; } } diff --git a/resources/js/components/link-topic-commits.tsx b/resources/js/components/link-topic-commits.tsx new file mode 100644 index 000000000..760507855 --- /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 a5f5f17c5..f40203852 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 | null { + return URL.canParse(url) ? `https://favicon.show/${hostname(url)}` : null; +} + +/** + * 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 7539d01ae..c12234665 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,75 @@ ->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('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(); + $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 +140,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): 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 {