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:
+