Merge pull request #316 from trails-cool/merge-follow-requests-into-notifications

Merge /follows/requests into /notifications as a tabbed inbox
This commit is contained in:
Ullrich Schäfer 2026-04-26 08:28:24 +02:00 committed by GitHub
commit ad11624c83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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>
);

View file

@ -0,0 +1,438 @@
# Information Architecture Review
*Snapshot date: 2026-04-26.* If the navbar, route table, or feed model has
shifted since then, treat this doc as stale and refresh against
`apps/journal/app/routes.ts` + `apps/journal/app/root.tsx`.
A snapshot of where every page lives, who sees it, and how visitors navigate
between them. Intended for review — flag anything that doesn't make sense or
should change.
## Apps
trails.cool ships two front-ends:
- **Journal** (`trails.cool`) — user accounts, social, content. Most of the IA
question lives here.
- **Planner** (`planner.trails.cool`) — anonymous, ephemeral. Five routes
total; not a real IA concern. Listed at the bottom for completeness.
---
## Journal sitemap
### Public surface (logged-out)
```
/ Anonymous home (hero + marketing + public feed)
/users/:username Public profile (full or locked stub)
/users/:username/followers Followers list (404 on private profile)
/users/:username/following Following list (404 on private profile)
/activities/:id Public activity (or 404)
/routes/:id Public route (or 404)
/auth/register Sign-up
/auth/login Sign-in
/auth/verify Magic-link click-through landing
/auth/accept-terms Re-accept gate (used after a Terms version bump)
/legal/imprint Imprint (German required)
/legal/privacy Privacy policy
/legal/terms Terms of service
/privacy 301 → /legal/privacy
```
### Authenticated surface (logged-in)
```
/ Personal dashboard ("your activities" stream)
/feed Social feed (people you follow, accepted)
/notifications Notifications log (4 event types, cursor paginated)
/follows/requests Incoming Pending follow requests inbox
/users/:username Any profile (own, followed, or locked stub)
/users/:username/followers Gated by locked-account rule
/users/:username/following Gated by locked-account rule
/routes Your routes
/routes/new Create route
/routes/:id View
/routes/:id/edit Edit (handoff to Planner via JWT callback)
/activities Your activities
/activities/new Create activity
/activities/:id View
/settings Profile + email + passkeys + sync + danger zone
/sync/import/:provider Wahoo import flow
/auth/logout Logout
```
### Navigation surfaces
**Top navbar (logged-in)** — in document order:
```
[trails.cool] Feed Routes Activities ... 🔔 Follow requests <username> Settings Logout
└─ unread badge └─ pending badge
```
**Top navbar (logged-out):**
```
[trails.cool] ... Login [Register]
```
**Footer (everywhere):** Imprint · Privacy · Terms · Source (GitHub) · "Alpha".
**Profile self-link in nav** points to `/users/<self>`. There is no separate
"My profile" route; you reach your own profile via your own username.
---
## Logged-in vs logged-out home
`/` does double duty:
| Visitor | What `/` shows |
|---|---|
| Anonymous | Hero · Sign-up CTAs · "Try the Planner" · marketing cards (flagship only) · **public instance feed** |
| Signed-in | "Welcome, X" · Feed button · "New activity" CTA · **personal stream of your own activities** |
The two surfaces share no layout — they're effectively two pages behind one
URL. `home.tsx` branches on `user`.
---
## Two feed surfaces, three views
**Decision (2026-04-26):** merge Social and Public into a single `/feed`
page with a Followed / Public toggle. `/` stays "your content," `/feed`
becomes "everyone else's content."
| Surface | View | Audience |
|---|---|---|
| `/` (logged-in) | **Personal** — your own activities | You |
| `/feed` | **Followed** (default) — accepted-followed users' public activities | Signed-in |
| `/feed?view=public` | **Public** — instance-wide public activities | Signed-in |
| `/` (logged-out) | Hero + marketing + public feed (visitor entry point) | Anonymous |
Signed-in users can now see the public instance feed without logging out.
The logged-out `/` keeps its public feed as the anonymous visitor's first
impression.
Implementation notes (for whoever picks this up):
- `listSocialFeed` and `listRecentPublicActivities` already exist in
`apps/journal/app/lib/activities.server.ts` — the toggle just picks one.
- URL shape: `?view=followed` (default, can omit) | `?view=public`. Keeps the
toggle bookmarkable and avoids two routes.
- Empty-state in the Followed view today links to `/` ("see the public feed
there") — that link should become the in-page Public toggle.
- Anonymous request to `/feed` keeps redirecting to `/auth/login`; the
public feed remains reachable for them on `/`.
---
## Notifications vs follow requests
Two adjacent inboxes, two navbar entries:
| | `/notifications` | `/follows/requests` |
|---|---|---|
| Purpose | Read-only event log | Actionable Approve/Reject |
| Types covered | follow_received, follow_request_received, follow_request_approved, activity_published | Only Pending follows targeting you |
| Navbar surface | 🔔 icon + red unread badge | "Follow requests" text + red pending count |
| State | `read_at` per row | None — Approve/Reject mutates the follow row |
A `follow_request_received` notification points its "Review request" link at
`/follows/requests`, so the two surfaces are explicitly cross-linked rather
than merged. Mastodon makes the same split.
---
## Settings
`/settings` is a single scrollable page with five concerns stacked top to
bottom:
1. Profile (display name, bio, profile visibility)
2. Email change (with re-verification)
3. Passkeys (list, add, delete)
4. Connected services (Wahoo today, Strava/Garmin later)
5. Danger zone (delete account)
Spec was recently split into three (`profile-settings`, `account-management`,
`connected-services`) but the UI is still one page.
---
## Cross-app linking
- **Journal → Planner:** "Edit in Planner" on a saved route generates a JWT
callback URL and opens `planner.trails.cool/session/<id>`. Logged-out home
also has a "Try the Planner" link.
- **Planner → Journal:** the post-edit "Save" flow POSTs back to
`/api/routes/:id/callback` on the Journal that issued the JWT.
- The Planner has no link back to a specific Journal otherwise — it's
intentionally instance-agnostic.
---
## Planner sitemap
```
/ Anonymous home (CTA: "Plan a route")
/new Create a fresh anonymous session
/session/:id Collaborative editor
```
No accounts, no profiles, no settings. IA is essentially trivial.
---
## Observations worth discussing
These are tensions or surprises I noticed while mapping. None are bugs — but
each is a deliberate IA choice that's worth confirming.
1. **`/` is two different products.** Logged-in `/` is "your stuff," logged-out
`/` is "the instance." Discoverable? Or should logged-in `/` keep showing
*something* of the public surface (e.g., a "Discover" tab)?
2. ~~**Signed-in users can't see the public instance feed.**~~ *Resolved:
merged into `/feed` with a Followed / Public toggle (see above).*
3. **The navbar's account cluster is busy.** `<username>` + `Settings` + `Logout`
is three controls for the same concept. Already on the redesign list —
collapsing into an avatar dropdown is the natural fix.
4. **`/feed` is reachable from both the navbar AND a button on the home page.**
The button on logged-in `/` (currently labelled "Feed") is mildly redundant
with the navbar entry. Worth keeping if Personal and Followed feel like
separate destinations; worth dropping if the navbar's "Feed" link is
obvious enough on its own.
5. **🔔 vs "Follow requests" are visually inconsistent.** One is an icon, the
other is a text link. Either both icons (consistent) or both text
(discoverable) would be more legible. Mobile wrap is already going to be a
problem.
6. **Routes and Activities are siblings, not nested.** That's correct today —
an activity can exist without a route, and vice versa. Worth flagging only
because some apps merge them into a single "history" timeline.
7. **`/settings` is one page.** Fine for now (5 sections, ~6 controls per
section). At ~10 sections it becomes a tab strip; at ~15 it becomes a left
nav. Not urgent.
8. **No `/explore`, no `/users` directory, no search.** A signed-in user who
wants to *find* people to follow has no in-app path — they need a username
from outside. Federation (Phase 2) makes this worse before it gets better.
9. **No mobile breakpoint for the navbar.** The cluster of 7 links on the
right will wrap on phones today. Tied to the navbar redesign.
10. **Profile vs identity.** Your own profile is at `/users/<you>` not
`/me` or `/profile`. Reachable via the navbar self-link. Fine, but means
"view as logged-out visitor" is non-trivial — opening incognito is the
only way to see your locked stub.
---
## Open IA questions
If the answer to any of these is "I don't know yet," that's a signal it's
worth discussing before the navbar redesign locks in shapes:
- ~~Should `/` and `/feed` merge for signed-in users?~~ *Resolved:
no — `/` stays "you," `/feed` becomes "everyone else" with a
Followed / Public toggle.*
- ~~Where does an `/explore` or `/discover` page live?~~ *Resolved: it's
the Public view inside `/feed`, no new top-level destination needed.*
---
## Implementation backlog
Decisions captured above translate into two work-streams. Items marked
*needs decision* are blockers — confirm before starting that stream.
### Stream A — Merge Social and Public feeds into `/feed`
**Code changes:**
1. **`apps/journal/app/routes/feed.tsx`**
- Loader reads `?view=` query (`"followed"` default, `"public"` accepted;
anything else falls back to default).
- Branch the fetch: `listSocialFeed(user.id, 50)` for Followed,
`listRecentPublicActivities(50)` for Public — both already exist in
`apps/journal/app/lib/activities.server.ts`.
- Render a toggle (two pills/tabs) at the top of the page; active state
highlighted; toggle uses `<Link to="?view=public">` so it's plain HTTP,
SSR-friendly, bookmarkable, and works without JS.
- Per-view `<meta>` title: "Following — trails.cool" / "Public — trails.cool".
- Per-view empty state: Followed view's existing "see the public feed"
escape now links to `?view=public` instead of `/`.
2. **Translation keys (`apps/journal/app/locales/{en,de}/journal.json`)**
- `social.feed.toggle.followed`, `social.feed.toggle.public`
- `social.feed.public.heading`, `social.feed.public.empty`
- Drop `social.feed.publicFeedLink` once the empty-state link is rewired.
3. **No change** to `apps/journal/app/routes/home.tsx` — logged-in `/` keeps
showing the personal stream; the "Feed" button still targets `/feed`
(defaults to Followed view).
4. **No change** to anonymous `/feed` behavior — it continues to redirect to
`/auth/login`. The public feed remains visible to anonymous visitors on `/`.
**Spec updates after Stream A ships:**
- `openspec/specs/social-follows/spec.md` — the **Social activity feed**
requirement currently scopes `/feed` to followed users only. Update it (or
split into a new "Feed views" requirement) to add scenarios for the Public
view and the toggle behavior.
- `openspec/specs/activity-feed/spec.md` — the **Instance-wide public
activity feed** requirement says it powers the home page. Add a scenario
noting it now also powers `/feed?view=public` for signed-in users.
- `openspec/specs/journal-landing/spec.md` — no change. Logged-in `/`
already shows the personal stream; this is unchanged.
### Stream B — Merge Follow requests into Notifications
**Decision (2026-04-26):** fold `/follows/requests` into `/notifications`
as a tabbed sub-page (Activity / Requests). The bell icon stays the
single inbox surface; the Requests tab shows a dot when there are pending
follows still needing Approve/Reject. The Notifications spec's existing
"no inline Approve/Reject on the activity log" rule is preserved — the
Requests tab is the actionable surface, the Activity tab is the log.
**Code changes:**
1. **`apps/journal/app/routes/notifications.tsx`**
- Loader reads `?tab=` (`"activity"` default, `"requests"` accepted).
- For Activity tab: existing behavior (paginated rows, `?before=` cursor).
- For Requests tab: load `listPendingFollowRequests(user.id)` and
`countPendingFollowRequests(user.id)` (already exist).
- Page renders a tab strip at the top with both labels and an unread/
pending dot per tab; active tab highlighted.
- "Mark all read" button only appears on the Activity tab.
2. **`apps/journal/app/routes.ts`**
- `route("follows/requests", ...)` becomes a redirect to
`/notifications?tab=requests` (301). New file
`routes/follows.requests.tsx` reduces to one `loader` returning a
redirect, mirroring the existing `routes/privacy.tsx` pattern.
3. **`apps/journal/app/lib/notifications/link-for.ts`**
- `follow_request_received` now resolves to
`/notifications?tab=requests` instead of `/follows/requests`. Update
the unit test in `link-for.test.ts`.
4. **`apps/journal/app/root.tsx`**
- Drop the `Follow requests` navbar entry entirely.
- Drop `pendingFollowRequests` from the root loader (no longer needed
for navbar). The Requests tab loader inside `/notifications`
fetches it on-demand.
- Bell unread badge logic unchanged — `follow_request_received`
already creates an unread row, so the existing unread count
implicitly covers pending requests.
5. **i18n updates** (`packages/i18n/src/locales/{en,de}.ts`)
- New keys: `notifications.tabs.activity`, `notifications.tabs.requests`.
- The `settings.profile.visibility.privateHelp` string mentions
`/follows/requests` — update to point at the new surface.
6. **e2e tests**
- `e2e/social.test.ts`: the `/follows/requests redirects anonymous
visitors to login` test changes to verify the new redirect target,
and the "B sees the request in /follows/requests" step navigates
to `/notifications?tab=requests` instead.
- `e2e/notifications.test.ts`: same — replace `bPage.goto("/follows/requests")`
with `bPage.goto("/notifications?tab=requests")`.
**Spec updates after Stream B ships:**
- `openspec/specs/notifications/spec.md` — extend the **Notifications
page and unread count** requirement to describe the tabbed structure;
refine the "follow_request_received card links to /follows/requests,
not inline Approve/Reject" scenario to reflect the new URL and the tab
separation. The `linkFor` scenarios need the updated path.
- `openspec/specs/social-follows/spec.md` — update the **Pending follow
request management** requirement: navigation moves to
`/notifications?tab=requests`; the navbar count badge requirement is
retired (it's now a dot inside the Requests tab).
- `openspec/specs/journal-landing/spec.md` — the **Notifications entry
in the navbar** requirement should clarify that this is the only
inbox-style entry (no separate Follow requests entry).
### Stream C — Navbar redesign
*Pending tomorrow. Principles agreed in the IA review; specifics still
need decisions before implementation.*
**Needs decision:**
- **Avatar dropdown content.** Profile · Settings · Logout — any others?
(Theme toggle? Language toggle? Account switcher when federation lands?)
- **Mobile target.** Hamburger? Bottom tab bar? "Phase 2, just don't break"?
- **Self-link vs avatar.** Today the navbar has a `<username>` text link to
your profile. After the avatar dropdown, does the avatar take that role,
or does Profile live only inside the dropdown?
**Code changes (assuming avatar dropdown + icon-only Follow requests + no
mobile work yet):**
1. **`apps/journal/app/root.tsx`**
- Add `displayName` to the loader's `user` payload (currently only
`id` + `username`).
- Replace the `<username> · Settings · Logout` cluster with a single
avatar trigger + dropdown menu.
- Replace the "Follow requests" text link with an icon-only button +
badge (matching the bell's visual treatment).
- Confirm bell + Follow requests sit on the same baseline / same gap.
2. **New components in `apps/journal/app/components/`**
- `Avatar.tsx` — initials fallback when no image (none of our 4 prod
users have an avatar field today, so this is initials-only initially).
- `NavDropdown.tsx` — click-outside + Escape-to-close. Simple
headless impl, no library.
3. **Loader query**
- `getSessionUser` already returns `displayName`; just expose it in the
loader return shape.
**Spec updates after Stream B ships:**
- `openspec/specs/journal-landing/spec.md` — currently has only the
**Notifications entry in the navbar** requirement. Add a new
requirement (or extend that one) for the account dropdown shape and the
Follow-requests icon. Or factor the navbar out into its own spec —
plausible if the cluster keeps growing.
- `openspec/specs/social-follows/spec.md` — the **Pending follow request
management** requirement says "the 'Follow requests' link in the navbar
renders with a small red count badge". Update the wording to reflect
the icon treatment.
- `openspec/specs/notifications/spec.md` — the **Notifications page and
unread count** requirement already says "navbar entry renders with a
count badge"; verify the wording still applies cleanly to the icon-only
treatment.
### Out of scope (deliberately)
These came up in the IA review but aren't part of this round:
- **Search / `/explore` directory.** No way to find users in-app. Defer
until federation (Phase 2) makes it more painful.
- **Settings sectioning.** Single scrollable page is fine at 5 sections.
- **`/me` alias for own profile.** Marginal value; navbar self-link is
enough.
- **Mobile breakpoints across the app.** Bigger than just the navbar.
- **Is "Follow requests" a sub-page of Notifications or a sibling?** Today
it's a sibling (separate navbar item). Could be a tab inside `/notifications`.
- **Avatar dropdown content.** Profile · Settings · Logout — anything else?
(Theme toggle? Language toggle? Account switcher when federation lands?)
- **Mobile.** Hamburger menu? Bottom tab bar? Nothing yet — what's the target?
- **Search.** None today. Adding it changes the navbar. When does it land?

View file

@ -102,9 +102,10 @@ test.describe("Notifications", () => {
await page.getByRole("button", { name: /Request to follow/i }).click();
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
// B approves the request. After the fetcher.Form revalidates, the
// empty-state copy replaces the request row.
await bPage.goto("/follows/requests");
// B approves the request from the Requests tab on /notifications.
// After the fetcher.Form revalidates, the empty-state copy
// replaces the request row.
await bPage.goto("/notifications?tab=requests");
await bPage.getByRole("button", { name: "Approve" }).click();
await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible({ timeout: 10000 });

View file

@ -46,9 +46,26 @@ test.describe("Social follows + /feed", () => {
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("/follows/requests redirects anonymous visitors to login", async ({ page }) => {
test("/follows/requests redirects to /notifications?tab=requests", async ({ page, browser }) => {
// The route was folded into the Notifications page as a tab. The
// 301 still resolves; for anonymous visitors the notifications
// loader then redirects to /auth/login.
await page.goto("/follows/requests");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
// Signed-in visitors land on /notifications?tab=requests after the
// 301.
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const ctx = await browser.newContext();
const p2 = await ctx.newPage();
const p2cdp = await p2.context().newCDPSession(p2);
await setupVirtualAuthenticator(p2cdp);
await registerUser(p2, `redir-${stamp}@example.com`, `redir${stamp}`);
await p2.goto("/follows/requests");
await expect(p2).toHaveURL(/\/notifications\?tab=requests/, { timeout: 10000 });
await ctx.close();
});
test("Follow button toggles state on a public profile", async ({ page, browser }) => {
@ -123,8 +140,8 @@ test.describe("Social follows + /feed", () => {
await page.getByRole("button", { name: /Request to follow/i }).click();
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
// B sees the request in /follows/requests with badge in nav.
await bPage.goto("/follows/requests");
// B sees the request in the Requests tab on /notifications.
await bPage.goto("/notifications?tab=requests");
await expect(bPage.getByText(`@${aUsername}`)).toBeVisible();
await bPage.getByRole("button", { name: "Approve" }).click();
// Empty state after approval. The text-visibility assertion below

View file

@ -66,12 +66,16 @@ For signed-in users, the personal dashboard SHALL include a prominent link to th
### Requirement: Notifications entry in the navbar
The navbar SHALL render a "Notifications" entry for signed-in users, linking to `/notifications`. It SHALL render an unread count badge when the user has at least one unread notification, and no badge when the count is zero. This entry SHALL be distinct from the existing "Follow requests" entry — they cover different categories (informational vs. actionable). The badge live-updates via `sse-broker`; the loader-driven count is the SSR baseline.
The navbar SHALL render a single inbox entry — a bell icon — for signed-in users, linking to `/notifications`. It SHALL render an unread count badge when the user has at least one unread notification, and no badge when the count is zero. The badge live-updates via `sse-broker`; the loader-driven count is the SSR baseline. Follow-request actions (Approve / Reject) live as the Requests tab inside `/notifications` (see `notifications` spec); the navbar SHALL NOT render a separate "Follow requests" entry — the bell is the single inbox surface.
#### Scenario: Signed-in user sees the entry
- **WHEN** a signed-in user loads any page
- **THEN** the navbar includes a "Notifications" link to `/notifications`, and a count badge if the user has unread rows
- **THEN** the navbar includes a single bell entry linking to `/notifications`, and a count badge if the user has unread rows
#### Scenario: Anonymous user does not see the entry
- **WHEN** an unauthenticated visitor loads any page
- **THEN** the navbar does not include the Notifications entry (the route requires authentication anyway)
- **THEN** the navbar does not include the bell entry (the route requires authentication anyway)
#### Scenario: No standalone Follow requests entry
- **WHEN** a signed-in user with one or more pending follow requests loads any page
- **THEN** the navbar does NOT render a separate "Follow requests" link; the bell's unread badge already counts the corresponding `follow_request_received` notifications, and the Requests tab inside `/notifications` is the actionable surface

View file

@ -1,7 +1,7 @@
# notifications Specification
## Purpose
In-app notifications for things that happened to a user that they didn't trigger themselves: their follow request was approved, someone followed them, a friend posted a public activity. Distinct from `/follows/requests` (which is the actionable surface for *incoming* Pending requests) and `/feed` (which is content from people you follow). Includes the `notifications` table, generation hooks, the `/notifications` page, mark-read APIs, retention, and the SSE-driven live unread badge (the SSE transport itself lives in `sse-broker`).
In-app notifications for things that happened to a user that they didn't trigger themselves: their follow request was approved, someone followed them, a friend posted a public activity. The `/notifications` page is a tabbed inbox with two surfaces — the **Activity** tab (the read-only event log) and the **Requests** tab (the actionable surface for *incoming* Pending follow requests, where Approve / Reject lives). `/feed` is a separate destination for content from people you follow. Includes the `notifications` table, generation hooks, the `/notifications` page, mark-read APIs, retention, and the SSE-driven live unread badge (the SSE transport itself lives in `sse-broker`).
## Requirements
@ -32,9 +32,9 @@ The Journal SHALL maintain a `notifications` table where each row records a sing
- **WHEN** a target approves, rejects, or the requester cancels a Pending follow that previously emitted `follow_request_received`
- **THEN** the `follow_request_received` notification row stays in the database with its existing read-state intact (the event happened; the historical record is preserved independently of the request's eventual outcome)
#### Scenario: follow_request_received card links to /follows/requests, not inline Approve/Reject
- **WHEN** a recipient renders a `follow_request_received` notification on `/notifications`
- **THEN** the card shows a "Review request" link that navigates to `/follows/requests`; Approve / Reject buttons are NOT rendered on `/notifications`
#### Scenario: follow_request_received card on the Activity tab links to the Requests tab, not inline Approve/Reject
- **WHEN** a recipient renders a `follow_request_received` notification on the Activity tab of `/notifications`
- **THEN** the card shows a "Review request" link that navigates to `/notifications?tab=requests`; Approve / Reject buttons are NOT rendered on the Activity tab
#### Scenario: Read-state is independent from request resolution
- **WHEN** a recipient marks a `follow_request_received` notification read OR approves/rejects the request
@ -60,7 +60,7 @@ The Journal SHALL expose a single server-side helper `linkFor(notification)` ret
#### Scenario: Web loader resolves a click-through link
- **WHEN** the `/notifications` loader prepares a row for render
- **THEN** the row carries `linkFor(row).web` so the card's anchor can navigate to the right page (`/activities/...`, `/users/...`, or `/follows/requests`)
- **THEN** the row carries `linkFor(row).web` so the card's anchor can navigate to the right page (`/activities/...`, `/users/...`, or `/notifications?tab=requests`)
#### Scenario: Helper builds links from subject_id with payload fallback
- **WHEN** `linkFor` is invoked on a notification whose `subject_id` is non-null
@ -75,25 +75,42 @@ The Journal SHALL expose a single server-side helper `linkFor(notification)` ret
- **THEN** the existing notification rows remain in the database but are filtered out of the recipient's `/notifications` listing (the renderer skips rows whose subject the recipient can no longer see)
### Requirement: Notifications page and unread count
The Journal SHALL expose `/notifications` to signed-in users only. The page SHALL list the user's notifications reverse-chronological, with each row showing the actor's display name + handle, a type-specific summary line, the timestamp, and a clickable link to the relevant subject. The page SHALL paginate via opaque cursor (a `before` query parameter); when more rows exist past the current page, the page SHALL surface a "Load older" affordance that fetches the next page using the cursor. Page size defaults to 50 and is capped at 100. The navbar SHALL surface an unread count badge linking to `/notifications`; the count is `notifications.read_at IS NULL` for the current user. Logged-out visitors requesting `/notifications` SHALL be redirected to `/auth/login`. The live-update transport for the unread badge is `sse-broker` (`/api/events`); this spec only requires the badge to reflect the count.
The Journal SHALL expose `/notifications` to signed-in users only. The page is the user's single inbox surface and SHALL render two tabs selectable via `?tab=`: **Activity** (default, the read-only event log) and **Requests** (the actionable list of incoming Pending follow requests with Approve / Reject controls — the same surface previously at `/follows/requests`, which now 301-redirects here). The Activity tab SHALL list notifications reverse-chronological with each row showing the actor's display name + handle, a type-specific summary line, the timestamp, and a clickable link to the relevant subject. The Activity tab SHALL paginate via opaque cursor (a `before` query parameter); when more rows exist past the current page, it SHALL surface a "Load older" affordance that fetches the next page using the cursor. Page size defaults to 50 and is capped at 100. The Requests tab SHALL list pending rows reverse-chronological by request creation time and SHALL render Approve / Reject buttons per row (delegated to `/api/follows/:id/approve|reject`). The navbar SHALL surface a single bell entry with an unread count badge linking to `/notifications`; the count is `notifications.read_at IS NULL` for the current user. The Requests tab itself SHALL render its own count indicator (the pending-request count, regardless of read state) so the user can see they still have action items even if the underlying notifications have been read. Logged-out visitors requesting `/notifications` (any tab) SHALL be redirected to `/auth/login`. The live-update transport for the unread badge is `sse-broker` (`/api/events`); this spec only requires the badge to reflect the count.
#### Scenario: Logged-in user with notifications
- **WHEN** a signed-in user with N notifications (N ≤ page size) loads `/notifications`
- **THEN** the page lists all N rows reverse-chronological by `created_at`, with unread rows visually distinct from read rows
#### Scenario: Logged-in user with notifications (Activity tab)
- **WHEN** a signed-in user with N notifications (N ≤ page size) loads `/notifications` (or `/notifications?tab=activity`)
- **THEN** the Activity tab is selected and lists all N rows reverse-chronological by `created_at`, with unread rows visually distinct from read rows
#### Scenario: Logged-in user with no notifications
#### Scenario: Logged-in user with no notifications (Activity tab)
- **WHEN** a signed-in user with zero notifications loads `/notifications`
- **THEN** the page renders an empty-state message
- **THEN** the Activity tab renders an empty-state message; the Requests tab remains selectable
#### Scenario: Logged-in user with pending follow requests (Requests tab)
- **WHEN** a signed-in user with M Pending incoming follow requests loads `/notifications?tab=requests`
- **THEN** the Requests tab is selected and lists all M rows reverse-chronological by request creation time, with Approve and Reject buttons per row
#### Scenario: Logged-in user with no pending follow requests (Requests tab)
- **WHEN** a signed-in user with zero pending requests loads `/notifications?tab=requests`
- **THEN** the Requests tab renders the empty-state message
#### Scenario: /follows/requests still resolves to the Requests tab
- **WHEN** any visitor (anonymous or signed-in) requests `/follows/requests`
- **THEN** the server responds with HTTP 301 to `/notifications?tab=requests`, preserving deep-links from existing notifications, emails, and external bookmarks
#### Scenario: Anonymous request
- **WHEN** an unauthenticated visitor requests `/notifications`
- **WHEN** an unauthenticated visitor requests `/notifications` (any tab)
- **THEN** they are redirected to `/auth/login`
#### Scenario: Navbar badge reflects unread count
- **WHEN** a signed-in user has K > 0 unread notifications
- **THEN** the "Notifications" navbar entry renders with a count badge showing K
- **THEN** the bell entry in the navbar renders with a count badge showing K
- **AND** when K = 0, the entry renders without a badge
#### Scenario: Requests tab badge reflects pending count regardless of read state
- **WHEN** a signed-in user has M > 0 pending follow requests, even if all corresponding `follow_request_received` notifications have been read
- **THEN** the Requests tab in the page header renders with a count badge showing M (the user still has actions to take)
- **AND** when M = 0, the tab renders without a badge
#### Scenario: Paginates older notifications via cursor
- **WHEN** a signed-in user has more notifications than fit in one page and clicks "Load older"
- **THEN** the next request includes the previous response's cursor as `?before=<cursor>` and the page renders the next batch, strictly older than the cursor's `(created_at, id)` position

View file

@ -1,10 +1,10 @@
# social-follows Specification
## Purpose
Local social follow relationships between Journal users — the follow API, follower/following collections (with locked-account access rules), the Pending request lifecycle for private profiles, and the activity feed driven by accepted follows. Federation of follows across instances is out of scope here and lives in `social-federation`.
Local social follow relationships between Journal users — the follow API, follower/following collections (with locked-account access rules), the Pending request lifecycle for private profiles, and the activity feed driven by accepted follows. The actionable inbox for incoming Pending requests lives as the Requests tab on `/notifications` (see `notifications` spec); this spec covers the lifecycle and the data model behind it. Federation of follows across instances is out of scope here and lives in `social-federation`.
## Requirements
### Requirement: Follow another user
A signed-in user SHALL be able to follow another local user from a profile page. Public targets auto-accept (`accepted_at = now()`); private (locked) targets land in a Pending state (`accepted_at = NULL`) until the target approves the request from `/follows/requests`. Following remote ActivityPub actors is out of scope here and tracked in the `social-federation` change.
A signed-in user SHALL be able to follow another local user from a profile page. Public targets auto-accept (`accepted_at = now()`); private (locked) targets land in a Pending state (`accepted_at = NULL`) until the target approves the request from the Requests tab on `/notifications`. Following remote ActivityPub actors is out of scope here and tracked in the `social-federation` change.
#### Scenario: Follow a local public profile
- **WHEN** a signed-in user clicks "Follow" on a local user with `profile_visibility = 'public'`
@ -12,7 +12,7 @@ A signed-in user SHALL be able to follow another local user from a profile page.
#### Scenario: Follow a local private (locked) profile
- **WHEN** a signed-in user clicks "Request to follow" on a local user with `profile_visibility = 'private'`
- **THEN** a follow row is recorded with `accepted_at = NULL`, the button becomes "Requested" (cancellable), the request appears in the target's `/follows/requests` page, and the target's follower count does NOT increment
- **THEN** a follow row is recorded with `accepted_at = NULL`, the button becomes "Requested" (cancellable), the request appears in the target's Requests tab on `/notifications` (`/notifications?tab=requests`), and the target's follower count does NOT increment
#### Scenario: Cannot follow yourself
- **WHEN** a signed-in user attempts to follow their own profile
@ -63,24 +63,24 @@ Every local user SHALL expose follower and following counts on their profile and
- **THEN** the page lists the accepted relations in reverse-chronological order of acceptance, 50 per page
### Requirement: Pending follow request management
A signed-in user SHALL have a dedicated `/follows/requests` page listing every Pending follow request targeting them (where `followed_user_id = currentUser.id` AND `accepted_at IS NULL`). The page SHALL provide Approve and Reject actions for each request. The navbar SHALL surface a count badge linking to this page when at least one request is pending.
A signed-in user SHALL have an actionable surface listing every Pending follow request targeting them (where `followed_user_id = currentUser.id` AND `accepted_at IS NULL`), with Approve and Reject buttons per row. This surface lives as the Requests tab on `/notifications` (see `notifications` spec for the page-level behavior, including the tabbed layout and the per-tab pending-count indicator). The standalone `/follows/requests` URL is preserved as a 301 redirect to the new location so deep-links from prior notifications, emails, and bookmarks still resolve.
#### Scenario: Owner sees their pending requests
- **WHEN** a signed-in user with N Pending incoming requests loads `/follows/requests`
- **WHEN** a signed-in user with N Pending incoming requests loads `/notifications?tab=requests`
- **THEN** the page lists all N requests reverse-chronologically by request creation time, with Approve and Reject buttons per request
#### Scenario: Empty requests page
- **WHEN** a signed-in user with zero pending requests loads `/follows/requests`
- **THEN** the page renders an empty-state message
#### Scenario: Empty requests tab
- **WHEN** a signed-in user with zero pending requests loads `/notifications?tab=requests`
- **THEN** the tab renders an empty-state message
#### Scenario: Anonymous visitor cannot access requests page
- **WHEN** an unauthenticated visitor requests `/follows/requests`
#### Scenario: Legacy URL redirects to the Requests tab
- **WHEN** any visitor requests `/follows/requests`
- **THEN** the server responds with HTTP 301 to `/notifications?tab=requests`
#### Scenario: Anonymous visitor cannot reach the Requests tab
- **WHEN** an unauthenticated visitor requests `/notifications?tab=requests` (or `/follows/requests`, which redirects there)
- **THEN** they are redirected to `/auth/login`
#### Scenario: Navbar badge reflects pending count
- **WHEN** a signed-in user has N > 0 pending follow requests
- **THEN** the "Follow requests" link in the navbar renders with a small red count badge of N; when N = 0 the link still renders but without a badge
### Requirement: Social activity feed
The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, listing public activities from the local users they follow with an accepted relation. `private` and `unlisted` activities SHALL NOT appear regardless of follow state. Pending follows SHALL NOT contribute content to the feed.

View file

@ -38,7 +38,8 @@ export const users = journalSchema.table("users", {
// Profile visibility / lock setting. `public` means anyone can view
// the profile and follows auto-accept. `private` is Mastodon-style
// locked: the profile renders a stub for non-followers, and follows
// require manual approval (Pending → Accepted via /follows/requests).
// require manual approval (Pending → Accepted via the Requests tab on
// /notifications).
// New users default to `private` to match trails.cool's privacy-first
// content defaults; existing users were backfilled to `public` by an
// earlier migration so behavior didn't change for them.

View file

@ -247,6 +247,10 @@ export default {
markAllRead: "Alle als gelesen markieren",
loadOlder: "Ältere laden",
someone: "Jemand",
tabs: {
activity: "Aktivität",
requests: "Anfragen",
},
summary: {
followRequestReceived: "{{name}} möchte dir folgen",
followReceived: "{{name}} folgt dir jetzt",
@ -321,7 +325,7 @@ export default {
public: "Öffentlich",
publicHelp: "Alle sehen dein Profil und deine öffentlichen Inhalte. Folge-Anfragen werden automatisch akzeptiert.",
private: "Privat (gesperrt)",
privateHelp: "Besucher:innen sehen nur eine Hinweisseite mit Anfrage-Button. Nur akzeptierte Follower sehen deine öffentlichen Inhalte. Anfragen verwaltest du unter /follows/requests.",
privateHelp: "Besucher:innen sehen nur eine Hinweisseite mit Anfrage-Button. Nur akzeptierte Follower sehen deine öffentlichen Inhalte. Anfragen verwaltest du im Reiter „Anfragen“ unter /notifications.",
},
},
security: {

View file

@ -247,6 +247,10 @@ export default {
markAllRead: "Mark all read",
loadOlder: "Load older",
someone: "Someone",
tabs: {
activity: "Activity",
requests: "Requests",
},
summary: {
followRequestReceived: "{{name}} requested to follow you",
followReceived: "{{name}} started following you",
@ -321,7 +325,7 @@ export default {
public: "Public",
publicHelp: "Anyone can view your profile and your public content. Follows auto-accept.",
private: "Private (locked)",
privateHelp: "Visitors see a stub with a Request-to-follow button. Only accepted followers see your public content. You manage requests at /follows/requests.",
privateHelp: "Visitors see a stub with a Request-to-follow button. Only accepted followers see your public content. You manage requests in the Requests tab on /notifications.",
},
},
security: {