Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked accounts. A private profile now returns 200 with a stub layout and gates content behind follow approval. Default for new users flips from 'public' to 'private' to align with trails.cool's privacy-first content defaults. Schema: - users.profile_visibility default flipped to 'private'. Existing rows remain 'public' (backfill on first migration handled them). Follow API (follow.server.ts): - followUser now creates Pending (accepted_at = NULL) against private targets and Accepted against public targets — no more refusal. - New: countPendingFollowRequests, listPendingFollowRequests, approveFollowRequest, rejectFollowRequest. Approve/reject are owner-bound: only the followed user can act on their own incoming requests. - countFollowers / countFollowing / listFollowers / listFollowing now filter to accepted-only relations. Loader (users.$username.tsx): - Drops the 404 paths. New canSeeContent flag = isOwn || profile_visibility='public' || (followState.following === true). - When canSeeContent=false, render a stub: header + 🔒 badge + body copy + Request-to-follow / sign-in CTA. Routes/activities sections are not rendered. UI: - FollowButton gains a "Request to follow" / "Requested" state for private targets via a new isPrivateTarget prop. Cancel-request reuses the unfollow endpoint. - New /follows/requests page lists incoming Pending requests with Approve / Reject buttons. - New API routes: POST /api/follows/:id/approve and /reject. - Navbar shows a count badge linking to /follows/requests when pending > 0. Privacy manifest already documents the follows relation; no changes needed (the locked-account semantics don't add new data — same row, different lifecycle). Specs / design (social-feed change): - public-profiles delta rewritten around the four-mode locked model (public, private+anon, private+pending, private+accepted) with scenarios for each. - social-follows delta gains Pending lifecycle requirements (auto vs. manual accept, approve/reject endpoints, pending request management, Pending follows do not contribute to feed). - design.md decision section reflects the new model and rationale for default-private; non-goal "locked-local-accounts as a follow-up" is removed since this change ships it. Tests: - follow.integration.test.ts: pending-against-private, approve flips to accepted, reject deletes, owner-bound enforcement. - e2e/social.test.ts: full Request → Pending → Approve → full-view flow, plus stub-for-anonymous and /follows/requests auth gate. Supersedes PR #309 (closed): the empty-public-profile 200 is now a side-effect of the new render path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ede68712a3
commit
5da7ffa037
17 changed files with 656 additions and 181 deletions
|
|
@ -1,49 +1,58 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Profile visibility setting
|
||||
Every user SHALL have an explicit `profile_visibility` of `public` or `private`. New accounts SHALL default to `public`. Users SHALL be able to change their profile visibility from account settings at any time.
|
||||
### Requirement: Profile visibility setting (locked accounts)
|
||||
Every user SHALL have an explicit `profile_visibility` of `public` or `private`. New accounts SHALL default to `private` (locked: profile is reachable but content is gated behind follow approval). Users SHALL be able to change their profile visibility from account settings at any time. `private` is functionally Mastodon's "locked" / "manually approves followers" — visitors see a stub with a Request-to-follow button, follows land in a Pending state, and content is only revealed to accepted followers.
|
||||
|
||||
#### Scenario: Default for a new account
|
||||
- **WHEN** a user registers
|
||||
- **THEN** their `profile_visibility` is `public`
|
||||
- **THEN** their `profile_visibility` is `private`
|
||||
|
||||
#### Scenario: Existing user backfilled to public
|
||||
- **WHEN** the migration that introduces `profile_visibility` runs against pre-existing rows
|
||||
- **THEN** every existing user's `profile_visibility` is set to `public`, preserving current effective behavior
|
||||
- **THEN** every existing user's `profile_visibility` is set to `public`, preserving current effective behavior (no surprise lock-down at deploy)
|
||||
|
||||
#### Scenario: User toggles profile to private
|
||||
- **WHEN** a user changes their profile visibility to `private` in settings and saves
|
||||
- **THEN** subsequent requests to `/users/:username` return HTTP 404 (regardless of how much public content they have), and they become unfollowable (existing follow rows are unaffected, but no new follows can be created)
|
||||
- **THEN** subsequent visitor requests to `/users/:username` return HTTP 200 but render a stub page (header + Request-to-follow button) with no content; existing follow rows are unaffected; new follows from anyone other than already-accepted followers are recorded as Pending
|
||||
|
||||
#### Scenario: User toggles profile back to public
|
||||
- **WHEN** a previously-private user switches `profile_visibility` to `public` and saves
|
||||
- **THEN** their `/users/:username` becomes reachable again (subject to the existing "has public content" gate) and Follow buttons reappear for visitors
|
||||
- **THEN** their `/users/:username` renders the full profile to anyone again, and any incoming follows auto-accept going forward
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public profile page
|
||||
The Journal SHALL serve a public profile page at `/users/:username` that lists the user's public routes and activities in reverse chronological order, viewable without authentication. The page SHALL render only when the user's `profile_visibility` is `public` AND they have at least one `public` route or activity. For signed-in viewers other than the owner, the page SHALL display a Follow / Unfollow toggle that mirrors the current follow relation (see `social-follows` spec). The page SHALL also display follower and following counts with links to the respective collections.
|
||||
The Journal SHALL serve a profile page at `/users/:username` for any user who exists. The render SHALL depend on the viewer relationship to the profile owner:
|
||||
- If the username does not exist, return HTTP 404.
|
||||
- If the owner's `profile_visibility = 'public'`, render the full profile (display name, handle, follower/following counts, public routes, public activities) regardless of who is viewing.
|
||||
- If the owner's `profile_visibility = 'private'`, render a stub page (header + handle + counts + lock badge) for non-owner viewers who do NOT have an accepted follow relation. Render the full profile for the owner themselves and for accepted followers.
|
||||
|
||||
#### Scenario: Logged-out visitor views a public profile with public content
|
||||
- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user whose `profile_visibility` is `public` and who has at least one `public` route or activity
|
||||
- **THEN** the page renders that user's display name (falling back to username), the `@username@domain` handle, follower and following counts, and a reverse-chronological list of their `public` routes and `public` activities
|
||||
- **AND** items marked `unlisted` or `private` do NOT appear in the list
|
||||
The page SHALL display follower and following counts (always, regardless of stub vs. full). For signed-in viewers other than the owner, the page SHALL render a Follow button whose label depends on the relation: "Follow" against a public profile with no relation, "Request to follow" against a private profile with no relation, "Requested" (cancellable) when a Pending request exists, "Unfollow" when an accepted relation exists.
|
||||
|
||||
#### Scenario: Profile 404 cases are indistinguishable
|
||||
- **WHEN** a visitor navigates to `/users/:username` for any of: a user with `profile_visibility = 'private'`, a user with `profile_visibility = 'public'` but zero public items, a user whose content is all `private` or `unlisted`, or a username that does not exist
|
||||
- **THEN** the server responds with HTTP 404
|
||||
- **AND** the response does NOT distinguish the cases, so existence of a private account is not leaked
|
||||
#### Scenario: Logged-out visitor views a public profile
|
||||
- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user whose `profile_visibility` is `public`
|
||||
- **THEN** the page renders the full profile (display name, handle, counts, public routes, public activities)
|
||||
|
||||
#### Scenario: Logged-out visitor views a private profile
|
||||
- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user whose `profile_visibility` is `private`
|
||||
- **THEN** the page returns HTTP 200 and renders the stub layout — header with display name, handle, and counts; a 🔒 "Private" badge; a body block with "This profile is private" and a sign-in prompt; no routes or activities are rendered
|
||||
|
||||
#### Scenario: Signed-in visitor views a private profile they don't follow
|
||||
- **WHEN** a signed-in user other than the owner navigates to `/users/:username` for a private profile, with no follow row OR a Pending follow row
|
||||
- **THEN** the page returns 200 and renders the stub layout, plus a Follow button (or Pending indicator) so the viewer can request access
|
||||
|
||||
#### Scenario: Signed-in viewer with accepted follow sees full private profile
|
||||
- **WHEN** a signed-in user with an accepted follow against a private user navigates to that user's profile
|
||||
- **THEN** the page returns 200 and renders the full profile — same as a public profile would render — plus an Unfollow button
|
||||
|
||||
#### Scenario: Owner sees their own profile
|
||||
- **WHEN** a user navigates to their own `/users/:username` while logged in
|
||||
- **THEN** if their `profile_visibility = 'public'` and they have at least one public item, the page renders exactly the same as for a logged-out visitor, plus a small owner-only control strip linking to settings
|
||||
- **AND** if their profile would 404 for visitors (private or no public content), they are redirected to settings or shown an owner-only "your profile isn't public yet" view (implementation detail)
|
||||
- **AND** no Follow button is shown (users cannot follow themselves)
|
||||
- **THEN** the full profile renders regardless of `profile_visibility`, plus a small owner-only banner (blue "ownNote" for public profiles, amber "private/locked" explanation for private profiles), plus a link to settings; no Follow button is shown
|
||||
|
||||
#### Scenario: Signed-in viewer sees a Follow control on a public profile
|
||||
- **WHEN** a signed-in user other than the owner loads a profile that returns 200
|
||||
- **THEN** the page renders a Follow button if no follow row exists for them against this user, and an Unfollow button if one does
|
||||
#### Scenario: Profile 404 only for nonexistent users
|
||||
- **WHEN** a visitor navigates to `/users/:username` for a username that does not exist
|
||||
- **THEN** the server responds with HTTP 404
|
||||
|
||||
#### Scenario: Profile page emits social-share metadata
|
||||
- **WHEN** any visitor loads a populated `/users/:username`
|
||||
- **WHEN** any visitor loads a populated `/users/:username` (full or stub)
|
||||
- **THEN** the page emits Open Graph tags (`og:title`, `og:site_name`, `og:type="profile"`) so links shared on social platforms render a meaningful preview
|
||||
|
|
|
|||
|
|
@ -1,44 +1,79 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Follow another user
|
||||
A signed-in user SHALL be able to follow another local user from a profile page. Local users with `profile_visibility = 'public'` auto-accept the follow. Local users with `profile_visibility = 'private'` SHALL NOT be followable. Following remote ActivityPub actors is out of scope 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 `/follows/requests`. 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'`
|
||||
- **THEN** a follow row is recorded with `accepted_at = now()`, the button becomes "Unfollow", and the target's follower count increments
|
||||
|
||||
#### Scenario: Cannot follow a private profile
|
||||
- **WHEN** a signed-in user attempts to follow a local user with `profile_visibility = 'private'`
|
||||
- **THEN** the follow API returns an error (4xx) and no follow row is created
|
||||
#### 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
|
||||
|
||||
#### Scenario: Cannot follow yourself
|
||||
- **WHEN** a signed-in user attempts to follow their own profile
|
||||
- **THEN** the follow API returns an error (4xx) and no follow row is created
|
||||
|
||||
#### Scenario: Unfollow
|
||||
- **WHEN** the follower clicks "Unfollow" on a profile they already follow
|
||||
- **THEN** the follow row is deleted and the target's follower count decrements
|
||||
#### Scenario: Unfollow (or cancel a Pending request)
|
||||
- **WHEN** the follower clicks "Unfollow" or "Requested" on a profile they currently have a follow row against
|
||||
- **THEN** the follow row is deleted regardless of state; the target's follower count decrements only if the row had been accepted
|
||||
|
||||
#### Scenario: Owner approves a Pending request
|
||||
- **WHEN** a private user POSTs to `/api/follows/:id/approve` for a Pending row targeting them
|
||||
- **THEN** the row's `accepted_at` is set to `now()`, the requester transitions from "Requested" to "Unfollow" view, and the target's follower count increments
|
||||
|
||||
#### Scenario: Owner rejects a Pending request
|
||||
- **WHEN** a private user POSTs to `/api/follows/:id/reject` for a Pending row targeting them
|
||||
- **THEN** the row is deleted; the requester sees the "Request to follow" state again and can re-request later
|
||||
|
||||
#### Scenario: Approve/reject is owner-bound
|
||||
- **WHEN** any user other than the followed party POSTs `/api/follows/:id/approve` or `/reject`
|
||||
- **THEN** the API returns 404 (no leak of who the row targets) and the row state is unchanged
|
||||
|
||||
### Requirement: Follower and following collections
|
||||
Every local user with `profile_visibility = 'public'` SHALL expose a publicly queryable list of followers and a list of who they follow.
|
||||
Every local user SHALL expose follower and following counts on their profile and paginated collection pages, listing only **accepted** relations. Pending requests do not count toward the public tallies.
|
||||
|
||||
#### Scenario: Follower count on profile
|
||||
- **WHEN** any visitor loads a public profile
|
||||
- **THEN** the page displays the follower and following counts, linking to paginated `/users/:username/followers` and `/users/:username/following` pages
|
||||
- **WHEN** any visitor loads a profile (public or private stub)
|
||||
- **THEN** the page displays the follower and following counts of accepted relations, linking to paginated `/users/:username/followers` and `/users/:username/following` pages
|
||||
|
||||
#### Scenario: Collection pagination
|
||||
- **WHEN** a visitor loads `/users/:username/followers` or `/users/:username/following`
|
||||
- **THEN** the page lists the relations in reverse-chronological order of acceptance, 50 per page
|
||||
- **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.
|
||||
|
||||
#### Scenario: Owner sees their pending requests
|
||||
- **WHEN** a signed-in user with N Pending incoming requests loads `/follows/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: Anonymous visitor cannot access requests page
|
||||
- **WHEN** an unauthenticated visitor requests `/follows/requests`
|
||||
- **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. `private` and `unlisted` activities SHALL NOT appear in this feed even if the viewer follows the owner.
|
||||
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.
|
||||
|
||||
#### Scenario: Feed aggregates followed users' public activities
|
||||
- **WHEN** a signed-in user with one or more follows loads `/feed`
|
||||
- **THEN** the page shows the most recent public activities across all users they follow, reverse-chronological, up to 50 per page, with owner attribution, distance, date, and a map thumbnail
|
||||
#### Scenario: Feed aggregates accepted-followed users' public activities
|
||||
- **WHEN** a signed-in user with one or more accepted follows loads `/feed`
|
||||
- **THEN** the page shows the most recent public activities across all accepted-followed users, reverse-chronological, up to 50 per page, with owner attribution, distance, date, and a map thumbnail
|
||||
|
||||
#### Scenario: Pending follows don't contribute to the feed
|
||||
- **WHEN** a signed-in user has only Pending follows (no acceptance yet)
|
||||
- **THEN** the feed renders the empty state — no Pending-target content is fetched or shown
|
||||
|
||||
#### Scenario: Empty feed state
|
||||
- **WHEN** a signed-in user with zero follows loads `/feed`
|
||||
- **WHEN** a signed-in user with zero accepted follows loads `/feed`
|
||||
- **THEN** the page shows an empty-state message pointing them to `/users/:username` pages to follow someone, and suggests the instance public feed as an alternative
|
||||
|
||||
#### Scenario: Logged-out visitor cannot access the feed
|
||||
|
|
@ -52,6 +87,6 @@ The `follows` table SHALL key the followed side by an `actor_iri TEXT` column (n
|
|||
- **WHEN** a local user A follows local user B
|
||||
- **THEN** the follow row has `followed_actor_iri = 'https://{DOMAIN}/users/{B.username}'` AND `followed_user_id = B.id`
|
||||
|
||||
#### Scenario: Lifecycle column ready for Pending state
|
||||
- **WHEN** any local follow row is created in this change
|
||||
- **THEN** `accepted_at` is set to `now()` (auto-accepted), but the column remains nullable so future remote follows can land in a Pending state without schema change
|
||||
#### Scenario: Lifecycle column already supports Pending
|
||||
- **WHEN** any local follow row is created against a private target in this change
|
||||
- **THEN** `accepted_at` is set to `NULL`; against a public target it is set to `now()`. Federation's remote-Pending state lands here without schema change
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue