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

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

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

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

View file

@ -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.