Merge /follows/requests into /notifications as a tabbed inbox

Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.

Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-26 08:24:59 +02:00
parent 3cd01fe9c0
commit 0306d90de8
15 changed files with 715 additions and 200 deletions

View file

@ -38,7 +38,7 @@ async function loadFollowableTarget(targetUsername: string) {
* Create a follow row from `followerId` to the local user with username
* `targetUsername`. Public targets auto-accept (`accepted_at = now()`),
* private (locked) targets land Pending (`accepted_at = NULL`) and
* appear in the target's /follows/requests list for manual approval.
* appear in the target's Requests tab on /notifications for manual approval.
* Idempotent: re-following keeps the existing row's state.
*/
export async function followUser(followerId: string, targetUsername: string): Promise<FollowState> {
@ -188,7 +188,8 @@ export interface FollowRequest {
}
/**
* Pending incoming follow requests for `userId`. Used by /follows/requests.
* Pending incoming follow requests for `userId`. Drives the Requests tab
* on /notifications.
* Reverse-chronological by request creation time.
*/
export async function listPendingFollowRequests(userId: string): Promise<FollowRequest[]> {
@ -285,7 +286,8 @@ const COLLECTION_PAGE_SIZE = 50;
/**
* Paginated list of accepted followers of `userId`. Newest acceptance first.
* Pending requests are excluded they live in /follows/requests.
* Pending requests are excluded they live in the Requests tab on
* /notifications.
*/
export async function listFollowers(userId: string, page: number = 1): Promise<CollectionEntry[]> {
const db = getDb();

View file

@ -25,14 +25,14 @@ describe("linkFor", () => {
expect(link.web).toBe("/");
});
it("follow_request_received always points at the requests page", () => {
it("follow_request_received always points at the Requests tab on /notifications", () => {
const link = linkFor({
type: "follow_request_received",
subjectId: null,
payloadVersion: 1,
payload: { followerUsername: "bob", followerDisplayName: null },
});
expect(link.web).toBe("/follows/requests");
expect(link.web).toBe("/notifications?tab=requests");
});
it("follow_request_approved uses payload.targetUsername", () => {

View file

@ -27,15 +27,16 @@ export function linkFor(n: NotificationForLink): LinkBundle {
switch (n.type) {
case "follow_received":
case "follow_request_received": {
// The actionable surface for a request lives at /follows/requests
// (where Approve/Reject is); a received auto-accept notification
// links to the new follower's profile. Both fall back to the
// payload's followerUsername if the actor account is gone.
// The actionable surface for a request lives in the Requests tab
// of /notifications (where Approve/Reject is); a received
// auto-accept notification links to the new follower's profile.
// Both fall back to the payload's followerUsername if the actor
// account is gone.
const username =
p && "followerUsername" in p ? p.followerUsername : null;
const path =
n.type === "follow_request_received"
? "/follows/requests"
? "/notifications?tab=requests"
: username
? `/users/${username}`
: "/";

View file

@ -63,36 +63,30 @@ export async function loader({ request }: Route.LoaderArgs) {
}
}
// Pending follow-request count for the navbar badge. Cheap (a single
// `count(*) WHERE accepted_at IS NULL`); only computed for signed-in
// users. Hidden behind a dynamic import so the root layout doesn't
// pull in the follow module on anonymous renders.
let pendingFollowRequests = 0;
// Unread-notification count for the navbar bell badge. Pending follow
// requests are not separately counted here — each pending request
// creates an unread `follow_request_received` notification, so the
// unread count already covers them. Hidden behind a dynamic import so
// the root layout doesn't pull in the notifications module on
// anonymous renders.
let unreadNotifications = 0;
if (user) {
const { countPendingFollowRequests } = await import("./lib/follow.server.ts");
const { countUnread } = await import("./lib/notifications.server.ts");
[pendingFollowRequests, unreadNotifications] = await Promise.all([
countPendingFollowRequests(user.id),
countUnread(user.id),
]);
unreadNotifications = await countUnread(user.id);
}
return {
user: user ? { id: user.id, username: user.username } : null,
locale,
pendingFollowRequests,
unreadNotifications,
};
}
function NavBar({
user,
pendingFollowRequests,
unreadNotifications,
}: {
user: { id: string; username: string } | null;
pendingFollowRequests: number;
unreadNotifications: number;
}) {
const { t } = useTranslation("journal");
@ -162,18 +156,6 @@ function NavBar({
</span>
)}
</Link>
<Link
to="/follows/requests"
className={`relative ${linkClass("/follows/requests")}`}
title={t("social.requests.title")}
>
{t("social.requests.title")}
{pendingFollowRequests > 0 && (
<span className="ml-1 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-red-500 px-1.5 text-xs font-semibold text-white">
{pendingFollowRequests}
</span>
)}
</Link>
<Link
to={`/users/${user.username}`}
className={linkClass(`/users/${user.username}`)}
@ -214,7 +196,6 @@ function NavBar({
export default function App({ loaderData }: Route.ComponentProps) {
const user = loaderData?.user;
const locale = loaderData?.locale ?? "en";
const pendingFollowRequests = loaderData?.pendingFollowRequests ?? 0;
const unreadNotifications = loaderData?.unreadNotifications ?? 0;
useEffect(() => {
if (user) {
@ -231,7 +212,6 @@ export default function App({ loaderData }: Route.ComponentProps) {
<AlphaBanner />
<NavBar
user={user ?? null}
pendingFollowRequests={pendingFollowRequests}
unreadNotifications={unreadNotifications}
/>
<Outlet />

View file

@ -1,99 +1,8 @@
import { data, redirect, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/follows.requests";
import { getSessionUser } from "~/lib/auth.server";
import { listPendingFollowRequests } from "~/lib/follow.server";
import { ClientDate } from "~/components/ClientDate";
import { redirect } from "react-router";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const requests = await listPendingFollowRequests(user.id);
return data({
requests: requests.map((r) => ({
id: r.id,
followerUsername: r.followerUsername,
followerDisplayName: r.followerDisplayName,
followerDomain: r.followerDomain,
createdAt: r.createdAt.toISOString(),
})),
});
}
export function meta(_args: Route.MetaArgs) {
return [{ title: "Follow requests — trails.cool" }];
}
interface RequestRow {
id: string;
followerUsername: string;
followerDisplayName: string | null;
followerDomain: string;
createdAt: string;
}
function RequestItem({ row }: { row: RequestRow }) {
const { t } = useTranslation("journal");
const approve = useFetcher();
const reject = useFetcher();
const inFlight = approve.state !== "idle" || reject.state !== "idle";
return (
<li className="flex items-center justify-between px-4 py-3">
<div>
<a
href={`/users/${row.followerUsername}`}
className="text-sm font-medium text-gray-900 hover:underline"
>
{row.followerDisplayName ?? row.followerUsername}
</a>
<p className="text-xs text-gray-500">
@{row.followerUsername}@{row.followerDomain} ·{" "}
<ClientDate iso={row.createdAt} />
</p>
</div>
<div className="flex gap-2">
<reject.Form method="post" action={`/api/follows/${row.id}/reject`}>
<button
type="submit"
disabled={inFlight}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{t("social.requests.reject")}
</button>
</reject.Form>
<approve.Form method="post" action={`/api/follows/${row.id}/approve`}>
<button
type="submit"
disabled={inFlight}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{t("social.requests.approve")}
</button>
</approve.Form>
</div>
</li>
);
}
export default function FollowRequests({ loaderData }: Route.ComponentProps) {
const { requests } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{t("social.requests.title")}</h1>
{requests.length === 0 ? (
<p className="mt-8 text-center text-gray-500">{t("social.requests.empty")}</p>
) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{requests.map((r) => (
<RequestItem key={r.id} row={r} />
))}
</ul>
)}
</div>
);
// Folded into /notifications as the Requests tab. Kept as a 301 so any
// pre-existing bookmark, email link, or notification deep-link still
// resolves.
export function loader() {
return redirect("/notifications?tab=requests", 301);
}

View file

@ -1,4 +1,5 @@
import { data, redirect, useFetcher } from "react-router";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { inArray, eq, and } from "drizzle-orm";
import type { Route } from "./+types/notifications";
@ -7,14 +8,43 @@ import { getSessionUser } from "~/lib/auth.server";
import { listForUser } from "~/lib/notifications.server";
import { linkFor } from "~/lib/notifications/link-for";
import { readPayload } from "~/lib/notifications/payload";
import {
countPendingFollowRequests,
listPendingFollowRequests,
} from "~/lib/follow.server";
import { ClientDate } from "~/components/ClientDate";
import { activities } from "@trails-cool/db/schema/journal";
type Tab = "activity" | "requests";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) throw redirect("/auth/login");
const url = new URL(request.url);
const tab: Tab = url.searchParams.get("tab") === "requests" ? "requests" : "activity";
// Pending count drives the Requests tab dot regardless of which tab is
// currently active, so we always fetch it. It's a single COUNT(*) query.
const pendingCount = await countPendingFollowRequests(user.id);
if (tab === "requests") {
const requests = await listPendingFollowRequests(user.id);
return data({
tab: "requests" as const,
pendingCount,
requests: requests.map((r) => ({
id: r.id,
followerUsername: r.followerUsername,
followerDisplayName: r.followerDisplayName,
followerDomain: r.followerDomain,
createdAt: r.createdAt.toISOString(),
})),
notifications: [] as NotificationRow[],
nextCursor: null as string | null,
});
}
const before = url.searchParams.get("before") ?? undefined;
const { rows, nextCursor } = await listForUser(user.id, { before });
@ -41,6 +71,9 @@ export async function loader({ request }: Route.LoaderArgs) {
});
return data({
tab: "activity" as const,
pendingCount,
requests: [] as RequestRow[],
notifications: visibleRows.map((r) => {
const link = linkFor({
type: r.type,
@ -66,7 +99,7 @@ export function meta(_args: Route.MetaArgs) {
return [{ title: "Notifications — trails.cool" }];
}
interface Row {
interface NotificationRow {
id: string;
type: string;
readAt: string | null;
@ -75,7 +108,15 @@ interface Row {
payload: Record<string, unknown> | null;
}
function summary(t: (key: string, opts?: Record<string, unknown>) => string, n: Row): string {
interface RequestRow {
id: string;
followerUsername: string;
followerDisplayName: string | null;
followerDomain: string;
createdAt: string;
}
function summary(t: (key: string, opts?: Record<string, unknown>) => string, n: NotificationRow): string {
const p = n.payload as { followerUsername?: string; followerDisplayName?: string | null;
targetUsername?: string; targetDisplayName?: string | null;
activityName?: string; ownerUsername?: string; ownerDisplayName?: string | null } | null;
@ -103,7 +144,7 @@ function summary(t: (key: string, opts?: Record<string, unknown>) => string, n:
}
}
function NotificationItem({ row }: { row: Row }) {
function NotificationItem({ row }: { row: NotificationRow }) {
const { t } = useTranslation("journal");
const fetcher = useFetcher();
const inFlight = fetcher.state !== "idle";
@ -115,9 +156,6 @@ function NotificationItem({ row }: { row: Row }) {
method: "post",
action: `/api/notifications/${row.id}/read`,
});
// The anchor href takes over the navigation; we don't preventDefault
// unless the request fails — and even if it does, the user can mark
// read manually from the page next time.
void e;
};
@ -147,8 +185,82 @@ function NotificationItem({ row }: { row: Row }) {
);
}
function RequestItem({ row }: { row: RequestRow }) {
const { t } = useTranslation("journal");
const approve = useFetcher();
const reject = useFetcher();
const inFlight = approve.state !== "idle" || reject.state !== "idle";
return (
<li className="flex items-center justify-between px-4 py-3">
<div>
<a
href={`/users/${row.followerUsername}`}
className="text-sm font-medium text-gray-900 hover:underline"
>
{row.followerDisplayName ?? row.followerUsername}
</a>
<p className="text-xs text-gray-500">
@{row.followerUsername}@{row.followerDomain} ·{" "}
<ClientDate iso={row.createdAt} />
</p>
</div>
<div className="flex gap-2">
<reject.Form method="post" action={`/api/follows/${row.id}/reject`}>
<button
type="submit"
disabled={inFlight}
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{t("social.requests.reject")}
</button>
</reject.Form>
<approve.Form method="post" action={`/api/follows/${row.id}/approve`}>
<button
type="submit"
disabled={inFlight}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{t("social.requests.approve")}
</button>
</approve.Form>
</div>
</li>
);
}
function TabLink({
to,
active,
label,
badge,
}: {
to: string;
active: boolean;
label: string;
badge: number;
}) {
return (
<Link
to={to}
className={`relative -mb-px inline-flex items-center gap-2 border-b-2 px-1 py-3 text-sm font-medium ${
active
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-600 hover:text-gray-900"
}`}
>
{label}
{badge > 0 && (
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-red-500 px-1.5 text-xs font-semibold text-white">
{badge}
</span>
)}
</Link>
);
}
export default function Notifications({ loaderData }: Route.ComponentProps) {
const { notifications, nextCursor } = loaderData;
const { tab, pendingCount, notifications, nextCursor, requests } = loaderData;
const { t } = useTranslation("journal");
const markAll = useFetcher();
@ -158,7 +270,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">{t("notifications.title")}</h1>
{hasUnread && (
{tab === "activity" && hasUnread && (
<markAll.Form method="post" action="/api/notifications/read-all">
<button
type="submit"
@ -171,26 +283,51 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
)}
</div>
{notifications.length === 0 ? (
<p className="mt-12 text-center text-gray-500">{t("notifications.empty")}</p>
<div className="mt-4 flex gap-6 border-b border-gray-200">
<TabLink
to="/notifications"
active={tab === "activity"}
label={t("notifications.tabs.activity")}
badge={0}
/>
<TabLink
to="/notifications?tab=requests"
active={tab === "requests"}
label={t("notifications.tabs.requests")}
badge={pendingCount}
/>
</div>
{tab === "activity" ? (
notifications.length === 0 ? (
<p className="mt-12 text-center text-gray-500">{t("notifications.empty")}</p>
) : (
<>
<ul className="mt-6 rounded-lg border border-gray-200 bg-white">
{notifications.map((n) => (
<NotificationItem key={n.id} row={n} />
))}
</ul>
{nextCursor && (
<div className="mt-4 text-center">
<a
href={`/notifications?before=${encodeURIComponent(nextCursor)}`}
className="text-sm font-medium text-blue-600 hover:underline"
>
{t("notifications.loadOlder")}
</a>
</div>
)}
</>
)
) : requests.length === 0 ? (
<p className="mt-12 text-center text-gray-500">{t("social.requests.empty")}</p>
) : (
<>
<ul className="mt-6 rounded-lg border border-gray-200 bg-white">
{notifications.map((n) => (
<NotificationItem key={n.id} row={n} />
))}
</ul>
{nextCursor && (
<div className="mt-4 text-center">
<a
href={`/notifications?before=${encodeURIComponent(nextCursor)}`}
className="text-sm font-medium text-blue-600 hover:underline"
>
{t("notifications.loadOlder")}
</a>
</div>
)}
</>
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{requests.map((r) => (
<RequestItem key={r.id} row={r} />
))}
</ul>
)}
</div>
);