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:
parent
3cd01fe9c0
commit
0306d90de8
15 changed files with 715 additions and 200 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
: "/";
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue