From 3fb03343a2ed3d8a49a9d6fa45d140972b3a1ec4 Mon Sep 17 00:00:00 2001 From: ashkesarmaa Date: Fri, 23 Oct 2026 18:43:50 +0200 Subject: [PATCH] feat: catch up on the Following log after time away Opening Following after at least two days away now starts with the most starred commits that people you follow made since the newest one you saw, with how many came in from how many people. It stays until you're caught up, then folds into a line you can bring back or hide for that visit. --- KNOWLEDGE_BASE.md | 6 +- app/Http/Controllers/LogController.php | 10 +- app/Http/Requests/ShowLogRequest.php | 12 ++ app/Queries/CatchUpQuery.php | 51 +++++ bootstrap/app.php | 2 +- resources/js/components/catch-up.tsx | 191 ++++++++++++++++++ resources/js/components/commit-feed.tsx | 10 +- resources/js/components/search-dialog.tsx | 2 +- resources/js/pages/log/show.tsx | 40 +++- resources/js/types/log.ts | 9 + .../Feature/Controllers/LogControllerTest.php | 168 +++++++++++++++ 11 files changed, 489 insertions(+), 12 deletions(-) create mode 100644 app/Queries/CatchUpQuery.php create mode 100644 resources/js/components/catch-up.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3cc1b80f8..aba559097 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -151,13 +151,15 @@ Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions] Logs are the app's timelines. **Following** contains your commits and those of developers you follow, newest first; it requires sign-in. **Latest** contains the newest public commits across the app. **Trending** favors recent public commits that attract engagement from distinct other developers across the thread, with replies and quotes weighing more than stars and diminishing returns from additional engagement. Pastéis don't affect Trending. On the web, Trending reshuffles each time you open it: higher-scoring commits are more likely to appear first, but the order varies between visits and the same draw is used while you scroll. The signed API does not redraw on each visit. -Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and offer an "N new commits" control instead of moving what you are reading, and mark where you left off. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. +Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and offer an "N new commits" control instead of moving what you are reading, and mark where you left off. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. + +When you open Following after at least two days away, it starts with a catch-up: how many commits people you follow made since the newest one you saw there, from how many people, and the most starred of them, three at first and up to six with **Show more**, each linking to its commit. It looks back a week at most, skips your own commits and replies, and only appears when more than three commits came in. It stays at the top of Following, even when you open one of its commits and come back, until you choose **I'm caught up**, which folds it into a single line with **Show again** for the rest of that visit, or hide that line too. Your browser remembers when the newest commit you saw was committed, so the server can put the catch-up at the top of the page as it loads; the server never stores that time, and your browser only moves it on while there is nothing to catch up on or once you are caught up. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. Browser Back and Forward restore expanded replies and loaded thread continuations. Offscreen commits are measured before scroll restoration so returning to a log or discussion keeps the content in place. If a background check hits a temporary connection or gateway failure, the page keeps its last received content and tries again on the normal polling schedule. -Sources: [log types](app/Enums/Log.php), [log queries](app/Queries/LogQueries), [log page](resources/js/pages/log/show.tsx), [background polling](resources/js/lib/background-poll.ts). +Sources: [log types](app/Enums/Log.php), [log queries](app/Queries/LogQueries), [catch-up query](app/Queries/CatchUpQuery.php), [log page](resources/js/pages/log/show.tsx), [background polling](resources/js/lib/background-poll.ts). ### Searching developers and commits diff --git a/app/Http/Controllers/LogController.php b/app/Http/Controllers/LogController.php index 35b718e24..3096e6d3d 100644 --- a/app/Http/Controllers/LogController.php +++ b/app/Http/Controllers/LogController.php @@ -9,6 +9,7 @@ use App\Http\Requests\ShowLogRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\CatchUpQuery; use App\Queries\LatestLogCommitsQuery; use App\Queries\LogCommitsQuery; use App\Queries\SuggestedCommitsQuery; @@ -25,9 +26,9 @@ /** * Guests land straight on the Trending log, with the home page's link preview. */ - public function index(ShowLogRequest $request, SuggestedCommitsQuery $suggested, LatestLogCommitsQuery $latest, LogCommitsQuery $commits, UpdateUserLog $action, PageMetadata $metadata): Response|SymfonyResponse + public function index(ShowLogRequest $request, SuggestedCommitsQuery $suggested, LatestLogCommitsQuery $latest, LogCommitsQuery $commits, CatchUpQuery $catchUp, UpdateUserLog $action, PageMetadata $metadata): Response|SymfonyResponse { - $response = $this->show($request, null, Log::Trending, $suggested, $latest, $commits, $action); + $response = $this->show($request, null, Log::Trending, $suggested, $latest, $commits, $catchUp, $action); return $response instanceof Response ? $response->withViewData(['preview' => $metadata->home()]) : $response; } @@ -35,7 +36,7 @@ public function index(ShowLogRequest $request, SuggestedCommitsQuery $suggested, /** * Guests read Trending and Latest, while Following takes them to sign in with GitHub. */ - public function show(ShowLogRequest $request, #[CurrentUser] ?User $user, Log $log, SuggestedCommitsQuery $suggested, LatestLogCommitsQuery $latest, LogCommitsQuery $commits, UpdateUserLog $action): Response|SymfonyResponse + public function show(ShowLogRequest $request, #[CurrentUser] ?User $user, Log $log, SuggestedCommitsQuery $suggested, LatestLogCommitsQuery $latest, LogCommitsQuery $commits, CatchUpQuery $catchUp, UpdateUserLog $action): Response|SymfonyResponse { if (! $user instanceof User) { if ($log === Log::Following) { @@ -57,6 +58,9 @@ public function show(ShowLogRequest $request, #[CurrentUser] ?User $user, Log $l 'suggestions' => fn (): Collection => $user instanceof User && $log === Log::Following && ! $user->following()->exists() ? $suggested->get($user) : new Collection(), + 'catchUp' => fn (): ?array => $user instanceof User && $log === Log::Following + ? $catchUp->get($user, $request->followingSeenAt()) + : null, ]); } } diff --git a/app/Http/Requests/ShowLogRequest.php b/app/Http/Requests/ShowLogRequest.php index 5d74132f6..1e2c1ac05 100644 --- a/app/Http/Requests/ShowLogRequest.php +++ b/app/Http/Requests/ShowLogRequest.php @@ -5,7 +5,9 @@ namespace App\Http\Requests; use App\Models\Commit; +use Carbon\CarbonInterface; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Date; final class ShowLogRequest extends FormRequest { @@ -45,6 +47,16 @@ public function since(): ?Commit : null; } + /** + * When the newest commit the user saw in their Following log was committed, as their browser remembers it. + */ + public function followingSeenAt(): ?CarbonInterface + { + $seenAt = $this->cookie('following_seen_at'); + + return is_string($seenAt) ? rescue(fn (): CarbonInterface => Date::createFromFormat('Y-m-d\TH:i:s.uP', $seenAt), report: false) : null; + } + public function prepareForValidation(): void { $this->merge(['through' => $this->header('X-Log-Through')]); diff --git a/app/Queries/CatchUpQuery.php b/app/Queries/CatchUpQuery.php new file mode 100644 index 000000000..e0a97d6cf --- /dev/null +++ b/app/Queries/CatchUpQuery.php @@ -0,0 +1,51 @@ +, commits_count: int, authors_count: int, since: CarbonInterface}|null + */ + public function get(User $user, ?CarbonInterface $seenAt): ?array + { + if (! $seenAt instanceof CarbonInterface || $seenAt->isAfter(now()->subDays(2))) { + return null; + } + + $since = $seenAt->max(now()->subWeek()); + $query = Log::Following->query()->builder($user); + $query->where($query->qualifyColumn('user_id'), '!=', $user->id) + ->where($query->qualifyColumn('created_at'), '>', $since); + + $count = (clone $query)->count(); + + if ($count <= self::PREVIEW_COUNT) { + return null; + } + + return [ + 'commits' => (clone $query)->reorder()->orderByDesc('stars_count')->latest()->latest('id')->limit(self::LISTED_COUNT)->get(), + 'commits_count' => $count, + 'authors_count' => $query->distinct()->count($query->qualifyColumn('user_id')), + 'since' => $since, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index fce1ae328..434224f08 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -38,7 +38,7 @@ ->withMiddleware(function (Middleware $middleware): void { $middleware->append(PreventFraming::class); - $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'replies_per_page']); + $middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'who_to_follow', 'who_to_follow_round', 'lane_dimming', 'replies_per_page', 'following_seen_at']); $middleware->redirectGuestsTo(fn (): string => route('home')); $middleware->redirectUsersTo(fn (Request $request): string => route('log.show', $request->user()->log ?? Log::Following)); diff --git a/resources/js/components/catch-up.tsx b/resources/js/components/catch-up.tsx new file mode 100644 index 000000000..82c25735a --- /dev/null +++ b/resources/js/components/catch-up.tsx @@ -0,0 +1,191 @@ +import { Link, useRemember } from '@inertiajs/react'; +import { Check, Star, X } from 'lucide-react'; +import { useState } from 'react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import { ActionTooltip } from '@/components/action-tooltip'; +import { RailSegment } from '@/components/graph-rail'; +import { CommitResult } from '@/components/search-dialog'; +import { Button } from '@/components/ui/button'; +import { useNow } from '@/hooks/use-now'; +import { formatCount, formatExactCount } from '@/lib/numbers'; +import { cn, formatDayLabel } from '@/lib/utils'; +import type { CatchUp as CatchUpData } from '@/types'; + +// CatchUpQuery only catches up when more commits came in than these. +const previewCount = 3; + +const stagedRailTransition = + '[view-transition-class:staged-rail] [view-transition-name:staged-rail-catch-up]'; + +/** + * Moves focus to the control that takes the place of the one just pressed, once it is rendered. + */ +const focusOnceShown = (id: string) => + requestAnimationFrame(() => document.getElementById(id)?.focus()); + +/** + * Opens the Following log after some time away with the most starred of what came in meanwhile. Once caught up, it + * folds into a line that can bring it back for the rest of the visit, or be hidden too. + */ +export function CatchUp({ + catchUp, + stagedLane, + isCaughtUp, + onCaughtUp, +}: { + catchUp: CatchUpData; + stagedLane?: string; + isCaughtUp: boolean; + onCaughtUp: (isCaughtUp: boolean) => void; +}) { + const now = useNow(); + const [isExpanded, setIsExpanded] = useState(false); + const [isHidden, setIsHidden] = useRemember(false, 'catch-up-hidden'); + const moreCount = catchUp.commits.length - previewCount; + const since = formatDayLabel(catchUp.since, now); + + if (isCaughtUp && isHidden) { + return null; + } + + if (isCaughtUp) { + return ( +
+ +
+

+ caught up · {formatExactCount(catchUp.commits_count)}{' '} + commits + since {since} +

+ + + + +
+
+ ); + } + + return ( +
+ +
+

+ {`$ git log --since="${since}"`} +

+

+ While you were away,{' '} + + {formatExactCount(catchUp.commits_count)} + {' '} + commits came in from{' '} + + {formatExactCount(catchUp.authors_count)} + {' '} + {catchUp.authors_count === 1 ? 'person' : 'people'} you + follow. The most starred: +

+
    + {catchUp.commits + .slice(0, isExpanded ? undefined : previewCount) + .map((commit) => ( +
  1. + + + + + {formatCount(commit.stars_count)} + + {commit.stars_count === 1 + ? ' star' + : ' stars'} + + + +
  2. + ))} +
+
+ {moreCount > 0 && ( + + )} + +
+
+
+ ); +} diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index 4c87c07e4..663086268 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -28,6 +28,7 @@ export function CommitFeed({ isRanked = false, isFolded = true, interlude, + lead, isBelowComposer = false, }: { commits: CursorPaginated; @@ -41,6 +42,8 @@ export function CommitFeed({ isRanked?: boolean; isFolded?: boolean; interlude?: (lane: string, isDimmed: boolean) => ReactNode; + /** Shown above the first commit, on the rail that hangs from the composer when there is one. */ + lead?: (stagedLane?: string) => ReactNode; isBelowComposer?: boolean; }) { const now = useNow(); @@ -193,6 +196,8 @@ export function CommitFeed({ const lastCommit = listed.at(-1) ?? pinnedCommit; const interludeIndex = Math.min(interludeAfter, listed.length) - 1; + const stagedLaneOnTop = + isBelowComposer && !pinnedCommit ? 'var(--rail)' : undefined; // Feed commits are unrelated to each other, so each one stands on its own with its author's avatar, even when the // same author committed several in a row. @@ -245,6 +250,7 @@ export function CommitFeed({ /> )} + {lead?.(stagedLaneOnTop)} diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index 80c8db2e5..35d2389af 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -700,7 +700,7 @@ function DeveloperResult({ ); } -function CommitResult({ commit }: { commit: Commit }) { +export function CommitResult({ commit }: { commit: Commit }) { const now = useNow(); return ( diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 393a4d866..963293788 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -1,7 +1,15 @@ -import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; +import { + Head, + Link, + router, + usePage, + usePoll, + useRemember, +} from '@inertiajs/react'; import { ArrowUp } from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import LogController from '@/actions/App/Http/Controllers/LogController'; +import { CatchUp } from '@/components/catch-up'; import { CommitComposer } from '@/components/commit-composer'; import { useCommitDialogClosedCount } from '@/components/commit-composer-dialog'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; @@ -11,13 +19,19 @@ import { PageTabs } from '@/components/page-tabs'; import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; import { WhoToFollowInLog } from '@/components/who-to-follow'; +import { setCookie } from '@/hooks/use-appearance'; import { useComposerVisibility } from '@/hooks/use-composer-visibility'; import { useLaneSwipe } from '@/hooks/use-lane-swipe'; import { useLastVisit } from '@/hooks/use-last-visit'; import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; import { emojify } from '@/lib/emoji'; -import type { Commit, CursorPaginated, Log } from '@/types'; +import type { + CatchUp as CatchUpData, + Commit, + CursorPaginated, + Log, +} from '@/types'; export default function Show({ log, @@ -25,12 +39,14 @@ export default function Show({ isRanked, newCommitsCount = 0, suggestions, + catchUp, }: { log: Log; commits: CursorPaginated; isRanked: boolean; newCommitsCount?: number; suggestions: Commit[]; + catchUp: CatchUpData | null; }) { const { props: { auth }, @@ -114,6 +130,16 @@ export default function Show({ }, [commits]); const firstSeenId = useLastVisit(viewerId, log, commits.data); + const [isCaughtUp, setIsCaughtUp] = useRemember(false, 'caught-up'); + const isCatchingUp = catchUp !== null && !isCaughtUp; + const newestAt = commits.data[0]?.created_at; + + // The server catches the user up from the newest commit they saw here, which only moves on once they are caught up. + useEffect(() => { + if (log === 'following' && !isCatchingUp && newestAt !== undefined) { + setCookie('following_seen_at', newestAt); + } + }, [log, isCatchingUp, newestAt]); const { stop: stopPolling } = usePoll( 30_000, @@ -215,6 +241,16 @@ export default function Show({ isBelowComposer={user !== null} end="That's the root commit. There's nothing before it." interlude={() => } + lead={(stagedLane) => + catchUp !== null && ( + + ) + } empty={ log === 'following' ? ( diff --git a/resources/js/types/log.ts b/resources/js/types/log.ts index a3bc4f002..1a6e19f8f 100644 --- a/resources/js/types/log.ts +++ b/resources/js/types/log.ts @@ -1 +1,10 @@ +import type { Commit } from './commit'; + export type Log = 'following' | 'latest' | 'trending'; + +export type CatchUp = { + commits: Commit[]; + commits_count: number; + authors_count: number; + since: string; +}; diff --git a/tests/Feature/Controllers/LogControllerTest.php b/tests/Feature/Controllers/LogControllerTest.php index 6b37481af..7bd3db094 100644 --- a/tests/Feature/Controllers/LogControllerTest.php +++ b/tests/Feature/Controllers/LogControllerTest.php @@ -746,3 +746,171 @@ ->getJson(route('log.show', Log::Following)) ->assertUnprocessable()->assertJsonValidationErrors('through'); }); + +it('catches up on the most starred commits from followed people after some time away', function (): void { + $this->freezeTime(); + $user = User::factory()->create(); + [$first, $second, $third] = User::factory()->count(3)->create(); + $user->following()->attach([$first->id, $second->id, $third->id]); + $seenAt = now()->subDays(3); + + $starred = Commit::factory()->for($first)->create(['created_at' => now()->subDays(2)]); + $mostStarred = Commit::factory()->for($second)->create(['created_at' => now()->subDays(2)]); + $newest = Commit::factory()->for($first)->create(['created_at' => now()->subDay()]); + $unstarred = Commit::factory()->for($third)->create(['created_at' => now()->subDays(2)]); + $starred->stars()->attach(User::factory()->create()); + $mostStarred->stars()->attach(User::factory()->count(2)->create()); + $newest->stars()->attach(User::factory()->create()); + + Commit::factory()->for($first)->create(['created_at' => now()->subDays(4)]); + Commit::factory()->for($user)->create(['created_at' => now()->subDay()]); + Commit::factory()->for($second)->create(['parent_id' => $mostStarred->id, 'root_id' => $mostStarred->id, 'created_at' => now()->subDay()]); + Commit::factory()->create(['created_at' => now()->subDay()]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', $seenAt->toJSON()) + ->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('catchUp.commits_count', 4) + ->where('catchUp.authors_count', 3) + ->where('catchUp.since', $seenAt->toJSON()) + ->where('catchUp.commits.0.id', $mostStarred->id) + ->where('catchUp.commits.0.stars_count', 2) + ->where('catchUp.commits.1.id', $newest->id) + ->where('catchUp.commits.2.id', $starred->id) + ->where('catchUp.commits.3.id', $unstarred->id) + ->where('catchUp.commits.0.user.id', $second->id) + ->has('catchUp.commits', 4)); +}); + +it('lists a few more of the most starred to catch up on, not every commit', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + Commit::factory()->for($followed)->count(8)->create(['created_at' => now()->subDay()]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', now()->subDays(3)->toJSON()) + ->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('catchUp.commits_count', 8) + ->has('catchUp.commits', 6)); +}); + +it('does not catch up without some time away', function (?string $seenAt): void { + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + Commit::factory()->for($followed)->count(4)->create(['created_at' => now()->subHour()]); + + $request = $seenAt === null ? $this->actingAs($user) : $this->actingAs($user)->withUnencryptedCookie('following_seen_at', $seenAt); + + $request->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('catchUp', null)); +})->with([ + 'never seen' => null, + 'seen a day ago' => fn (): string => now()->subDay()->toJSON(), + 'relative' => '-3 days', + 'unreadable' => 'not-a-date', +]); + +it('catches up after exactly two days away', function (): void { + $this->freezeTime(); + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + Commit::factory()->for($followed)->count(4)->create(['created_at' => now()->subDay()]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', now()->subDays(2)->toJSON()) + ->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('catchUp.commits_count', 4)); +}); + +it('leaves the catch up out of the poll for new commits', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + $commits = Commit::factory()->for($followed)->count(4)->create(['created_at' => now()->subDay()]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', now()->subDays(3)->toJSON()) + ->get(route('log.show', [Log::Following, 'since' => $commits->first()->id]), [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) resolve(HandleInertiaRequests::class)->version(request()), + 'X-Inertia-Partial-Component' => 'log/show', + 'X-Inertia-Partial-Data' => 'newCommitsCount', + ])->assertOk() + ->assertJsonMissingPath('props.catchUp'); +}); + +it('does not catch up when the log shows no more than the few it would list', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + Commit::factory()->for($followed)->count(3)->create(['created_at' => now()->subDay()]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', now()->subDays(3)->toJSON()) + ->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('catchUp', null)); +}); + +it('catches up on a week at most', function (): void { + $this->freezeTime(); + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + Commit::factory()->for($followed)->count(4)->create(['created_at' => now()->subDays(2)]); + Commit::factory()->for($followed)->count(2)->create(['created_at' => now()->subDays(9)]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', now()->subDays(10)->toJSON()) + ->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('catchUp.commits_count', 4) + ->where('catchUp.since', now()->subWeek()->toJSON())); +}); + +it('leaves muted, blocked and pending commits out of the catch up', function (): void { + $user = User::factory()->create(); + [$followed, $muted, $blocked, $blocker] = User::factory()->count(4)->create(); + $user->following()->attach([$followed->id, $muted->id, $blocked->id, $blocker->id]); + $user->muting()->attach($muted); + $user->blocking()->attach($blocked); + $blocker->blocking()->attach($user); + + Commit::factory()->for($followed)->count(4)->create(['created_at' => now()->subDay()]); + Commit::factory()->for($followed)->pending()->create(['created_at' => now()->subDay()]); + Commit::factory()->for($muted)->create(['created_at' => now()->subDay()]); + Commit::factory()->for($blocked)->create(['created_at' => now()->subDay()]); + Commit::factory()->for($blocker)->create(['created_at' => now()->subDay()]); + + $this->actingAs($user) + ->withUnencryptedCookie('following_seen_at', now()->subDays(3)->toJSON()) + ->get(route('log.show', Log::Following)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('catchUp.commits_count', 4) + ->where('catchUp.authors_count', 1)); +}); + +it('only catches up on the following log', function (bool $signedIn): void { + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + Commit::factory()->for($followed)->count(4)->create(['created_at' => now()->subDay()]); + + $request = $signedIn ? $this->actingAs($user) : $this; + + $request->withUnencryptedCookie('following_seen_at', now()->subDays(3)->toJSON()) + ->get(route('log.show', Log::Latest)) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('catchUp', null)); +})->with(['guest' => false, 'signed in' => true]);