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
2 changes: 1 addition & 1 deletion .ai/guidelines/resources.js.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

## Navigation and requests

- Every Inertia `<Link>` to a GET page prefetches on hover (`prefetch`). A GET page never writes; the only exceptions are `log/{log}` (remembers the log), `notifications` (marks read, remembers the filter), `remotes/@{partner}` and `remotes/orgs/{org}` (mark read), and every link to them keeps `prefetch={false}`. The navigation's Log is the exception: it opens the remembered log and a prefetch never changes the remembered log, so it prefetches everywhere except on a log page, where it scrolls to the top instead. `starred` also keeps it, because starring changes the list. A new GET page that must write joins this list.
- Every Inertia `<Link>` to a GET page prefetches on hover (`prefetch`). A GET page never writes; the only exceptions are `notifications` (marks read, remembers the filter), `remotes/@{partner}` and `remotes/orgs/{org}` (mark read), and every link to them keeps `prefetch={false}`. `starred` also keeps it, because starring changes the list. A new GET page that must write joins this list. The navigation's Log prefetches everywhere except on a log page, where it scrolls to the top instead.
- Never prefetch a non-GET link, an external URL, a guest's sign-in link, a mention of a handle nobody here has (guests get a missing page, and a feed would ask for it on every hover), or a URL that is not an Inertia page (images, feeds, downloads, JSON).
- A click target that is not a link (the commit card surface) prefetches on `mousedown`, never on hover: scrolling a feed hovers every card.
- After an action that changes what a prefetched page shows, flush that page (`router.flush(url)`, `router.flushAll()`, or `invalidateCacheTags`), so the next click does not show the old state.
Expand Down
23 changes: 9 additions & 14 deletions app/Http/Controllers/LogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace App\Http\Controllers;

use App\Actions\UpdateUserLog;
use App\Enums\Log;
use App\Http\Requests\ShowLogRequest;
use App\Http\Resources\ShownStreams;
Expand All @@ -25,30 +24,26 @@
/**
* 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, 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);

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): Response|SymfonyResponse
{
if (! $user instanceof User) {
if ($log === Log::Following) {
if (app()->isLocal()) {
Inertia::flash('localSignIn', true);
if (! $user instanceof User && $log === Log::Following) {
if (app()->isLocal()) {
Inertia::flash('localSignIn', true);

return to_route('home');
}

return Inertia::location(route('login'));
return to_route('home');
}
} elseif ($user->log !== $log && ! $request->prefetch()) {
$action->handle($user, $log);

return Inertia::location(route('login'));
}

return Inertia::render('log/show', [
Expand Down
25 changes: 25 additions & 0 deletions app/Http/Controllers/UserLogController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateUserLog;
use App\Http\Requests\UpdateUserLogRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final readonly class UserLogController
{
public function update(UpdateUserLogRequest $request, #[CurrentUser] User $user, UpdateUserLog $action): Response
{
// Another of the user's pages may have remembered it already, and announcing it again would only make every page
// read it again.
if ($user->log !== $request->log()) {
$action->handle($user, $request->log());
}

return response()->noContent();
}
}
5 changes: 4 additions & 1 deletion app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ public function share(Request $request): array
? Inertia::live($value, on: array_values(array_map(fn (UserUpdate $update): string => $update->value, $updates)), channel: new PrivateChannel($user))
: $value;

// Kept across pages, so a page prefetched before the reader moved to another log doesn't bring the old one back.
$log = Inertia::once(fn (): ?Log => $request->user()?->log)->as('auth.log:'.($user->id ?? 'guest'));

// Kept across pages until the first stream it lists runs out at most, so a stream extended meanwhile is read again.
$liveFollowing = $user instanceof User && $hasAside
? Inertia::once(function () use (&$liveFollowing, $user): array {
Expand Down Expand Up @@ -145,7 +148,7 @@ public function share(Request $request): array
'translationLanguage' => $live(fn (): string => resolve(CommitTranslations::class)->language(), UserUpdate::Account),
'mustReadCommitRules' => $live(fn (): bool => $user instanceof User && $user->commit_rules_accepted_at === null, UserUpdate::Account),
'termsToAccept' => $live(fn (): ?string => $user instanceof User && ! $user->hasAcceptedCurrentTerms() ? config()->string('legal.terms_version') : null, UserUpdate::Account),
'log' => $live(fn (): ?Log => $request->user()?->log, UserUpdate::Account),
'log' => $user instanceof User ? $log->live(UserUpdate::Account->value, new PrivateChannel($user)) : $log,
// Each browser filters notifications its own way, and reloading the count goes through its cookie.
'unreadNotificationsCount' => $live(fn (): int => $user instanceof User ? resolve(NotificationGroupsQuery::class)->unreadCount($user, ListNotificationsRequest::rememberedType($request, $user)) : 0, UserUpdate::Notifications),
'unreadMessagesCount' => $live(fn (): int => $user?->remotes_enabled ? resolve(ConversationsQuery::class)->unreadCount($user) : 0, UserUpdate::Messages, UserUpdate::Inbox),
Expand Down
28 changes: 28 additions & 0 deletions app/Http/Requests/UpdateUserLogRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\Log;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class UpdateUserLogRequest extends FormRequest
{
/**
* @return array<string, array<int, Enum|string>>
*/
public function rules(): array
{
return [
'log' => ['required', Rule::enum(Log::class)],
];
}

public function log(): Log
{
return Log::from($this->string('log')->value());
}
}
117 changes: 97 additions & 20 deletions resources/js/components/page-tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
import { Link } from '@inertiajs/react';
import { Link, router } from '@inertiajs/react';
import type { InertiaLinkProps } from '@inertiajs/react';
import { useRef, useState } from 'react';
import { catchUpAfterRestore, pageReadAt } from '@/lib/realtime';
import { cn } from '@/lib/utils';

type Tab = {
title: string;
/**
* Shown instead of the title on phones, where a long title would wrap.
*/
shortTitle?: string;
href: NonNullable<InertiaLinkProps['href']>;
isActive: boolean;
onClick?: InertiaLinkProps['onClick'];
prefetch?: boolean;
component?: InertiaLinkProps['component'];
pageProps?: InertiaLinkProps['pageProps'];
onLoadError?: () => false | void;
};

export function PageTabs({
label,
tabs,
Expand All @@ -17,41 +34,98 @@ export function PageTabs({
prefetch?: boolean;
only?: InertiaLinkProps['only'];
reset?: string[];
tabs: {
title: string;
/**
* Shown instead of the title on phones, where a long title would wrap.
*/
shortTitle?: string;
href: NonNullable<InertiaLinkProps['href']>;
isActive: boolean;
onClick?: InertiaLinkProps['onClick'];
}[];
tabs: Tab[];
}) {
const [pending, setPending] = useState<string | null>(null);
// An instant visit fills its page in place, without the navigation that tells when what it shows was read: from a
// prefetch it catches up like any prefetched page, and from its own request it was read once that started.
const instantRead = useRef<'prefetched' | number | null>(null);
const active = pending ?? tabs.find((tab) => tab.isActive)?.title;
const finish = (title: string) => {
setPending((current) => (current === title ? null : current));
};
const headers = reset?.length
? { 'X-Inertia-Reset': reset.join(',') }
: undefined;
const indicatorName = `tab-indicator-${label.toLowerCase().replaceAll(/[^a-z0-9]+/gu, '-')}`;

return (
<nav aria-label={label} className="flex border-b">
<nav
aria-label={label}
aria-busy={pending !== null}
className="flex border-b"
>
{tabs.map((tab) => (
<Link
key={tab.title}
href={tab.href}
onClick={tab.onClick}
prefetch={prefetch}
component={tab.component}
pageProps={tab.pageProps}
onHttpException={tab.onLoadError}
onNetworkError={tab.onLoadError}
prefetch={
tab.isActive || tab.prefetch === false
? false
: prefetch
}
viewTransition
preserveScroll={preserveScroll}
preserveState={preserveState}
only={only}
// Link exposes custom headers for both visits and prefetches, but no reset option.
headers={
reset?.length
? { 'X-Inertia-Reset': reset.join(',') }
: undefined
}
headers={headers}
onBefore={() => {
setPending(tab.title);
const prefetching = router.getPrefetching(tab.href, {
only,
headers,
preserveScroll,
});
instantRead.current =
tab.component !== undefined &&
(prefetching ??
router.getCached(tab.href, {
only,
headers,
preserveScroll,
})) !== null
? 'prefetched'
: null;
// Failed in-flight prefetches do not fire the consuming visit's onFinish.
void prefetching?.response.catch(() => {
finish(tab.title);
tab.onLoadError?.();
});
}}
onStart={() => {
setPending(tab.title);

if (
tab.component !== undefined &&
instantRead.current === null
) {
instantRead.current = Date.now();
}
}}
onFinish={() => finish(tab.title)}
onSuccess={() => {
const read = instantRead.current;
instantRead.current = null;

if (read === 'prefetched') {
catchUpAfterRestore();
} else if (read !== null) {
pageReadAt(read);
}
}}
aria-current={tab.isActive ? 'page' : undefined}
className={cn(
'relative flex flex-1 justify-center text-[15px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
{ 'text-foreground': tab.isActive },
{
'text-foreground': active === tab.title,
'bg-accent': pending === tab.title,
},
)}
>
<span className="py-3.5">
Expand All @@ -68,7 +142,7 @@ export function PageTabs({
tab.title
)}
</span>
{tab.isActive && (
{active === tab.title && (
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-[3px] bg-primary [view-transition-class:tab-indicator]"
Expand All @@ -77,6 +151,9 @@ export function PageTabs({
)}
</Link>
))}
<span role="status" className="sr-only">
{pending ? `Loading ${pending}…` : ''}
</span>
</nav>
);
}
Loading