From 0306d90de8228f8b5584a2005d10dc925224ecdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 08:24:59 +0200 Subject: [PATCH] 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) --- apps/journal/app/lib/follow.server.ts | 8 +- .../app/lib/notifications/link-for.test.ts | 4 +- .../journal/app/lib/notifications/link-for.ts | 11 +- apps/journal/app/root.tsx | 34 +- apps/journal/app/routes/follows.requests.tsx | 103 +--- apps/journal/app/routes/notifications.tsx | 191 ++++++-- docs/information-architecture.md | 438 ++++++++++++++++++ e2e/notifications.test.ts | 7 +- e2e/social.test.ts | 23 +- openspec/specs/journal-landing/spec.md | 10 +- openspec/specs/notifications/spec.md | 43 +- openspec/specs/social-follows/spec.md | 28 +- packages/db/src/schema/journal.ts | 3 +- packages/i18n/src/locales/de.ts | 6 +- packages/i18n/src/locales/en.ts | 6 +- 15 files changed, 715 insertions(+), 200 deletions(-) create mode 100644 docs/information-architecture.md diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index 48351d2..7508a63 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -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 { @@ -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 { @@ -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 { const db = getDb(); diff --git a/apps/journal/app/lib/notifications/link-for.test.ts b/apps/journal/app/lib/notifications/link-for.test.ts index 0b501ce..1c6a744 100644 --- a/apps/journal/app/lib/notifications/link-for.test.ts +++ b/apps/journal/app/lib/notifications/link-for.test.ts @@ -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", () => { diff --git a/apps/journal/app/lib/notifications/link-for.ts b/apps/journal/app/lib/notifications/link-for.ts index e551854..d199673 100644 --- a/apps/journal/app/lib/notifications/link-for.ts +++ b/apps/journal/app/lib/notifications/link-for.ts @@ -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}` : "/"; diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 6101cad..e30c5db 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -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({ )} - - {t("social.requests.title")} - {pendingFollowRequests > 0 && ( - - {pendingFollowRequests} - - )} - { if (user) { @@ -231,7 +212,6 @@ export default function App({ loaderData }: Route.ComponentProps) { diff --git a/apps/journal/app/routes/follows.requests.tsx b/apps/journal/app/routes/follows.requests.tsx index 0cd2bd9..b6a546b 100644 --- a/apps/journal/app/routes/follows.requests.tsx +++ b/apps/journal/app/routes/follows.requests.tsx @@ -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 ( -
  • -
    - - {row.followerDisplayName ?? row.followerUsername} - -

    - @{row.followerUsername}@{row.followerDomain} ·{" "} - -

    -
    -
    - - - - - - -
    -
  • - ); -} - -export default function FollowRequests({ loaderData }: Route.ComponentProps) { - const { requests } = loaderData; - const { t } = useTranslation("journal"); - - return ( -
    -

    {t("social.requests.title")}

    - - {requests.length === 0 ? ( -

    {t("social.requests.empty")}

    - ) : ( -
      - {requests.map((r) => ( - - ))} -
    - )} -
    - ); +// 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); } diff --git a/apps/journal/app/routes/notifications.tsx b/apps/journal/app/routes/notifications.tsx index 7311a46..5306d16 100644 --- a/apps/journal/app/routes/notifications.tsx +++ b/apps/journal/app/routes/notifications.tsx @@ -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 | null; } -function summary(t: (key: string, opts?: Record) => 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, 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, 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 ( +
  • +
    + + {row.followerDisplayName ?? row.followerUsername} + +

    + @{row.followerUsername}@{row.followerDomain} ·{" "} + +

    +
    +
    + + + + + + +
    +
  • + ); +} + +function TabLink({ + to, + active, + label, + badge, +}: { + to: string; + active: boolean; + label: string; + badge: number; +}) { + return ( + + {label} + {badge > 0 && ( + + {badge} + + )} + + ); +} + 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) {

    {t("notifications.title")}

    - {hasUnread && ( + {tab === "activity" && hasUnread && (
    ); diff --git a/docs/information-architecture.md b/docs/information-architecture.md new file mode 100644 index 0000000..cd0ab0f --- /dev/null +++ b/docs/information-architecture.md @@ -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 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/`. 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/`. 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.** `` + `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/` 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 `` so it's plain HTTP, + SSR-friendly, bookmarkable, and works without JS. + - Per-view `` 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 `` 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 ` · 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? diff --git a/e2e/notifications.test.ts b/e2e/notifications.test.ts index 4d9e704..39aa17e 100644 --- a/e2e/notifications.test.ts +++ b/e2e/notifications.test.ts @@ -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 }); diff --git a/e2e/social.test.ts b/e2e/social.test.ts index b5651f0..270cf0b 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -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 diff --git a/openspec/specs/journal-landing/spec.md b/openspec/specs/journal-landing/spec.md index c680857..c8cea3e 100644 --- a/openspec/specs/journal-landing/spec.md +++ b/openspec/specs/journal-landing/spec.md @@ -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 diff --git a/openspec/specs/notifications/spec.md b/openspec/specs/notifications/spec.md index 6a87862..4d11367 100644 --- a/openspec/specs/notifications/spec.md +++ b/openspec/specs/notifications/spec.md @@ -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=` and the page renders the next batch, strictly older than the cursor's `(created_at, id)` position diff --git a/openspec/specs/social-follows/spec.md b/openspec/specs/social-follows/spec.md index 2b65385..56628d2 100644 --- a/openspec/specs/social-follows/spec.md +++ b/openspec/specs/social-follows/spec.md @@ -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. diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 1c28664..f4679c3 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -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. diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9ac2307..3f7aff8 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 2b91f3c..b3ab391 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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: {