trails/openspec/changes/social-feed/proposal.md
Ullrich Schäfer 5da7ffa037 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>
2026-04-25 23:38:26 +02:00

5 KiB

Why

trails.cool has public activities and public profiles, but no way for users to build a network. A signed-in user's home today shows only their own activities; the visitor-facing home shows every instance's public activity indiscriminately. There's no "activities from people I care about" view, which is table stakes for a social outdoor journal and the differentiator that gives users a reason to sign up over just using the Planner.

This change ships the local social layer: follows between users on the same Journal instance and a personal /feed aggregating their public activities. ActivityPub federation (cross-instance follows, inbox/outbox dispatch, Fedify integration) is deliberately deferred to a follow-up change social-federation so this lands in days rather than weeks. The schema is forward-compatible: when federation arrives, remote follows slot in without migration.

What Changes

  • Add an explicit users.profile_visibility setting ('public' | 'private'). New accounts default to 'private' (matches trails.cool's privacy-first content defaults); existing accounts are backfilled to 'public' so deploy doesn't lock anyone down. private is functionally Mastodon's "locked" account: profile renders a stub with a Request-to-follow button for non-followers; only accepted followers see content; follows land Pending until the owner approves.
  • Users SHALL be able to follow another local user (same instance) by clicking a Follow / Request-to-follow button on the user's profile page. Public targets auto-accept; private targets create a Pending row that the owner approves or rejects from /follows/requests.
  • A signed-in user SHALL have a /follows/requests page listing all incoming Pending requests with Approve / Reject actions; the navbar shows a count badge linking to that page.
  • A signed-in user SHALL be able to view a social feed of public activities from the users they follow with an accepted relation, reverse-chronological, at a dedicated /feed route linked from the nav.
  • The logged-in home (/) SHALL link prominently to the new social feed; personal dashboard content stays as-is for now.
  • Profile pages SHALL show follower and following counts (accepted-only) and a context-aware Follow control (Follow / Request to follow / Requested / Unfollow).
  • Privacy: follower/following counts and lists are public per instance. Only public activities appear in the feed; pending follows don't contribute content.
  • Privacy manifest: documents the new follows relation as data the instance retains about who follows whom.
  • Forward-compatible schema: follows is keyed by an actor_iri TEXT column even though all rows are local now (local user IRIs look like https://{DOMAIN}/users/{username}). When social-federation lands, remote IRIs go in the same column with no migration.
  • Out of scope (tracked as follow-ups):
    • ActivityPub federation primitives — social-federation. Goal there is "Mastodon can follow a trails account" inbound and "trails can follow other trails instances" outbound.
    • A unified notification UI. The follow-request count badge is a one-off; broader notifications (e.g., "your follow request was approved", "new public activity from people you follow") will be designed in a follow-up.

Capabilities

New Capabilities

  • social-follows: the follow/unfollow graph (local now, federated later) and the aggregated social activity feed surfaced at /feed.

Modified Capabilities

  • public-profiles: add a profile_visibility setting on users, gate /users/:username on it, and add Follow / Unfollow button + follower and following counts to the profile page.
  • journal-landing: add a prominent link from the signed-in home to the new /feed route; personal dashboard content stays.

Impact

  • Code: new follows table (Drizzle + migration), new users.profile_visibility column. Queries for follower/following lists, aggregated feed query, new routes (/feed, /users/:username/followers, /users/:username/following), profile-visibility setting in account settings.
  • API: new endpoints POST /api/users/:username/follow, POST /api/users/:username/unfollow; existing public-profile and feed endpoints gain follower/following counts.
  • UI: Follow button + counts on /users/:username; new /feed route with card list (reuses the home-feed card component); nav item "Feed" visible to signed-in users; profile-visibility toggle in /settings/profile.
  • Federation: deferred to social-federation. The schema's IRI key + users.profile_visibility are placed now so the federation change is additive (no schema rewrite).
  • Dependencies: none new; drizzle-kit push handles the schema migration.
  • Privacy manifest: new entry documenting the follows relation (which user follows whom on this instance, when).
  • Operational: the social feed query joins follows with activities; the existing index on activities(created_at) plus a new (follower_id, created_at DESC) on follows keeps the join cheap to thousands of follows per user.