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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions app/Http/Controllers/LogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,17 +26,17 @@
/**
* 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;
}

/**
* 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) {
Expand All @@ -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,
]);
}
}
12 changes: 12 additions & 0 deletions app/Http/Requests/ShowLogRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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')]);
Expand Down
51 changes: 51 additions & 0 deletions app/Queries/CatchUpQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Enums\Log;
use App\Models\Commit;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Collection;

final readonly class CatchUpQuery
{
public const int PREVIEW_COUNT = 3;

public const int LISTED_COUNT = 6;

/**
* What came into the user's Following log from others after the newest commit they saw there, once they have been
* away long enough to miss some: the most starred, a few shown at first and a few more on request, and how many
* commits came from how many people in all. It looks back a week at most, so a long absence still ranks recent
* commits, and there is nothing to catch up on when the log itself shows no more than the few.
*
* @return array{commits: Collection<int, Commit>, 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,
];
}
}
2 changes: 1 addition & 1 deletion bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
191 changes: 191 additions & 0 deletions resources/js/components/catch-up.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Caught up" className="flex">
<RailSegment
lane={stagedLane}
isDashed
className={cn(stagedLane && stagedRailTransition)}
/>
<div className="flex min-w-0 flex-1 items-center gap-1 border-b py-1.5 pr-4">
<p
className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground"
title={`caught up · ${formatExactCount(catchUp.commits_count)} commits since ${since}`}
>
caught up · {formatExactCount(catchUp.commits_count)}{' '}
commits
<span className="max-sm:hidden"> since {since}</span>
</p>
<Button
id="catch-up-show-again"
variant="ghost"
size="sm"
onClick={() => {
onCaughtUp(false);
focusOnceShown('catch-up-done');
}}
className="text-primary hover:text-primary"
data-test="catch-up-show-again"
>
Show again
</Button>
<ActionTooltip label="Hide">
<Button
variant="ghost"
size="icon"
onClick={() => setIsHidden(true)}
aria-label="Hide caught up"
className="-mr-2 size-8 text-muted-foreground"
data-test="catch-up-hide"
>
<X className="size-4" />
</Button>
</ActionTooltip>
</div>
</section>
);
}

return (
<section aria-labelledby="catch-up" className="flex">
<RailSegment
lane={stagedLane}
isDashed
className={cn(stagedLane && stagedRailTransition)}
/>
<div className="min-w-0 flex-1 border-b pt-4 pr-4 pb-3">
<h2
id="catch-up"
className="truncate font-mono text-[13px] text-muted-foreground"
>
{`$ git log --since="${since}"`}
</h2>
<p className="mt-1 text-[15px] text-pretty">
While you were away,{' '}
<span className="font-mono tabular-nums">
{formatExactCount(catchUp.commits_count)}
</span>{' '}
commits came in from{' '}
<span className="font-mono tabular-nums">
{formatExactCount(catchUp.authors_count)}
</span>{' '}
{catchUp.authors_count === 1 ? 'person' : 'people'} you
follow. The most starred:
</p>
<ol
id="catch-up-commits"
className="mt-3 divide-y overflow-hidden rounded-lg border"
>
{catchUp.commits
.slice(0, isExpanded ? undefined : previewCount)
.map((commit) => (
<li key={commit.id}>
<Link
href={CommitController.show({
user: commit.user,
commit,
})}
className="flex items-start gap-3 p-3 transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
data-test="catch-up-commit"
>
<CommitResult commit={commit} />
<span
className="flex shrink-0 items-center gap-1 font-mono text-[13px] font-medium text-muted-foreground tabular-nums"
title={`${formatExactCount(commit.stars_count)} ${commit.stars_count === 1 ? 'star' : 'stars'}`}
>
<Star aria-hidden className="size-4" />
{formatCount(commit.stars_count)}
<span className="sr-only">
{commit.stars_count === 1
? ' star'
: ' stars'}
</span>
</span>
</Link>
</li>
))}
</ol>
<div className="mt-3 flex items-center gap-3">
{moreCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-controls="catch-up-commits"
className="-ml-3 text-primary hover:text-primary"
data-test="catch-up-more"
>
{isExpanded
? 'Show fewer'
: `Show ${moreCount} more`}
</Button>
)}
<Button
id="catch-up-done"
variant="outline"
size="sm"
onClick={() => {
onCaughtUp(true);
focusOnceShown('catch-up-show-again');
}}
className="ml-auto"
data-test="catch-up-done"
>
<Check />
I'm caught up
</Button>
</div>
</div>
</section>
);
}
10 changes: 7 additions & 3 deletions resources/js/components/commit-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function CommitFeed({
isRanked = false,
isFolded = true,
interlude,
lead,
isBelowComposer = false,
}: {
commits: CursorPaginated<Commit>;
Expand All @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -245,6 +250,7 @@ export function CommitFeed({
/>
</section>
)}
{lead?.(stagedLaneOnTop)}
<InfiniteScroll
data="commits"
onlyNext
Expand All @@ -264,9 +270,7 @@ export function CommitFeed({
localDayKey(previous.created_at) !==
localDayKey(commit.created_at));
const stagedLane =
isBelowComposer && index === 0 && !pinnedCommit
? 'var(--rail)'
: undefined;
index === 0 ? stagedLaneOnTop : undefined;

return (
<Fragment key={commit.id}>
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/search-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ function DeveloperResult({
);
}

function CommitResult({ commit }: { commit: Commit }) {
export function CommitResult({ commit }: { commit: Commit }) {
const now = useNow();

return (
Expand Down
Loading