From 94c4f4389e54a043b1dee70c35090d67a0943610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 24 Apr 2026 22:02:48 +0200 Subject: [PATCH] Propose: social-feed (follows + /feed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposal + design + specs + tasks for the social layer. Adds a `follows` relation (local + federated via ActivityPub), Follow buttons on public profiles, and a `/feed` route showing public activities from followed users. Capabilities: - New: social-follows (the graph + /feed) - Modified: public-profiles (Follow button + counts) - Modified: journal-landing (Feed link on signed-in dashboard) Design picks: - Pull-based feed (no fan-out-on-write) — fine at trails.cool scale - follows keyed by actor IRI, local denorm FK for join perf - Fedify wires Follow/Accept/Undo; we handle the DB side - Remote activity ingestion via polling (not push) — tolerates our downtime without losing activities Co-Authored-By: Claude Opus 4.7 (1M context) --- openspec/changes/social-feed/.openspec.yaml | 2 + openspec/changes/social-feed/design.md | 117 ++++++++++++++++++ openspec/changes/social-feed/proposal.md | 40 ++++++ .../social-feed/specs/journal-landing/spec.md | 12 ++ .../social-feed/specs/public-profiles/spec.md | 49 ++++++++ .../social-feed/specs/social-follows/spec.md | 57 +++++++++ openspec/changes/social-feed/tasks.md | 53 ++++++++ 7 files changed, 330 insertions(+) create mode 100644 openspec/changes/social-feed/.openspec.yaml create mode 100644 openspec/changes/social-feed/design.md create mode 100644 openspec/changes/social-feed/proposal.md create mode 100644 openspec/changes/social-feed/specs/journal-landing/spec.md create mode 100644 openspec/changes/social-feed/specs/public-profiles/spec.md create mode 100644 openspec/changes/social-feed/specs/social-follows/spec.md create mode 100644 openspec/changes/social-feed/tasks.md diff --git a/openspec/changes/social-feed/.openspec.yaml b/openspec/changes/social-feed/.openspec.yaml new file mode 100644 index 0000000..9323e24 --- /dev/null +++ b/openspec/changes/social-feed/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-24 diff --git a/openspec/changes/social-feed/design.md b/openspec/changes/social-feed/design.md new file mode 100644 index 0000000..c858648 --- /dev/null +++ b/openspec/changes/social-feed/design.md @@ -0,0 +1,117 @@ +## Context + +trails.cool has the primitives for a social layer in place but nothing wired up: + +- Users have public profiles at `/users/:username` (post `public-content-visibility`). +- Activities and routes have `public | unlisted | private` visibility. +- There is no `follows` relation today, no follower/following lists, and no aggregated feed. +- **No ActivityPub yet.** The Journal does not currently speak ActivityPub. There are no actor objects, no inbox, no outbox, no HTTP-signing layer, no Fedify dependency. Federation is genuinely Phase 2 (per `CLAUDE.md`) and is being scoped separately as the `social-federation` change. + +This change is local-only social: follows between users on the same Journal instance, and the `/feed` aggregation. The schema is shaped so federation can layer on without a migration when it arrives. + +## Goals / Non-Goals + +**Goals:** +- A logged-in user can follow another local user with a public profile and see their public activities in a `/feed` view. +- Profile visibility is explicit (`users.profile_visibility`), gates followability deterministically, and pre-pays the model for federation and locked accounts. +- Follower and following counts + lists are public per local instance. +- Unfollow fully cleans up. +- Schema is forward-compatible: `follows` keyed by `actor_iri TEXT` so when remote follows land in `social-federation`, they slot in with no migration. + +**Non-Goals:** +- **All ActivityPub federation primitives** — Fedify integration, actor objects, WebFinger, signed inbox/outbox, remote actor caching, audience-aware ingestion. Deferred to `social-federation`. The `follows` schema is shaped to accept remote IRIs but no remote IRIs will land in this change. +- **Locked local accounts** (a manual approval flow for our own users). Out of scope; the `'public' | 'private'` enum on `profile_visibility` is intentionally minimal so a `locked` value or `users.locked` flag can extend it cleanly. Tracked as follow-up `locked-local-accounts`. +- Blocking, muting, or report flows. +- Direct messages or any non-activity ActivityPub object. +- Real-time WebSocket feed updates. The feed is loader-driven and refreshes on page load. +- A "people you may know" / suggestions surface. + +## Decisions + +### Decision: Make profile visibility explicit on the `users` row + +Today the Journal has no profile-level visibility setting. A profile is "public" implicitly: `/users/:username` returns 200 iff the user has at least one `public` route or activity, otherwise 404 (and the 404 doesn't distinguish "no such user" from "private content only" so existence isn't leaked). + +Add `users.profile_visibility: 'public' | 'private'` (NOT NULL, default `'public'`). + +The new rules: + +- `/users/:username` returns 200 iff `profile_visibility = 'public'` **AND** the user has at least one `public` route or activity. The "has public content" gate is preserved so a brand-new public-by-default account doesn't expose a 200 page that says "no posts yet" — that would leak existence. +- A user is followable iff `profile_visibility = 'public'`, regardless of whether they have content yet. (Following someone before they post is reasonable; the follower's feed just stays empty for that follow until the user posts.) + +When `social-federation` lands, the local user's ActivityPub actor object will gate on the same `profile_visibility = 'public'` check — private profiles will return 404 to federation lookups too. + +**Default `'public'`:** matches fediverse convention (Mastodon defaults to discoverable; "lock" is opt-in), aligns with the existing implicit behavior where any user *could* be public, and keeps onboarding smooth (post a public route → it's listed on your profile, no extra toggle). Activity-level privacy still defaults to `'private'`, so content stays private by default; *being findable on the network* is the part we default open. + +**Migration:** backfill all existing users to `'public'`. Their effective profile reachability is unchanged (still gated on having public content). Operators can flip themselves to `'private'` post-migration if desired. + +**Why explicit, why now:** with follows landing, the question "can someone follow you?" needs a deterministic answer. Deriving it from "do you have any public content?" is fragile (toggling content visibility silently flips followability). The toggle also pre-pays for `locked-local-accounts`, which will extend this enum or add a `users.locked` flag. + +**Alternative considered:** add a `'public' | 'unlisted' | 'private'` triple to mirror activity visibility. Rejected for now — `'unlisted'` for profiles ("actor object resolvable but profile page 404s") is a real fediverse pattern but a confusing UX surface to ship before users ask for it. Add as a third state if needed later. + +### Decision: Pull-based feed, not fan-out-on-write + +The feed query is: +```sql +SELECT a.* FROM journal.activities a + JOIN journal.follows f ON f.followed_user_id = a.owner_id + WHERE f.follower_id = :currentUser + AND f.accepted_at IS NOT NULL + AND a.visibility = 'public' + ORDER BY a.created_at DESC LIMIT 50; +``` + +We pull at read time rather than precomputing per-follower timelines at write time. + +**Why:** our scale is tiny (hundreds of users, not millions), and pull keeps us free of the consistency+cleanup headaches that fan-out-on-write brings (deletes, visibility changes, unfollows, backfill on new follow). A `(follower_id, created_at DESC)` index on `follows` + the existing `(created_at)` index on `activities` makes the join O(k log n) for k follows, which is fine to well past the scale trails.cool cares about for years. The same query naturally extends to remote activities once `social-federation` adds the remote-content cache. + +**Alternative considered:** write-side fan-out into a per-user timeline table. Rejected for the complexity vs. the scale we'll actually hit. + +### Decision: Store follows keyed by actor IRI even though all rows are local today + +Schema: +``` +follows( + id UUID PRIMARY KEY, + follower_id TEXT NOT NULL REFERENCES users(id), -- always a local user + followed_actor_iri TEXT NOT NULL, -- e.g. https://trails.cool/users/bruno; future: remote IRIs + followed_user_id TEXT REFERENCES users(id), -- non-null for local follows; FK for fast joins + accepted_at TIMESTAMPTZ, -- always set in this change (auto-accept for local public) + created_at TIMESTAMPTZ DEFAULT now() +) +UNIQUE (follower_id, followed_actor_iri) +INDEX (follower_id, created_at DESC) +INDEX (followed_actor_iri) +``` + +The follower is always a local user. The followed side is an actor IRI. In this change all IRIs are local (built as `https://{DOMAIN}/users/{username}`). `followed_user_id` is always populated, so queries that need the full user row do an indexed FK join. + +**Why keyed by IRI now:** ActivityPub is IRI-first, and the `social-federation` change will need this column to point at remote actors. Adding it now (instead of just a `followed_user_id` FK) costs one extra column and a tiny IRI-builder helper today, and saves a real schema migration + backfill when federation lands. The denorm `followed_user_id` keeps query performance identical to a pure-FK design for local-only operation. + +**Why `accepted_at` even though it's always set in this change:** the column models the eventual lifecycle (Pending → Accepted → Rejected/Undone) that arrives with federation. Marking the column nullable now means no migration later when remote follows can sit in Pending. + +### Decision: Feed lives at `/feed` (new route), not on `/` + +The signed-in home already is the personal dashboard. Overloading it with tabs now adds product complexity without much payoff. A dedicated `/feed` is discoverable via the nav and mirrors how Mastodon separates home and local timelines. + +**Alternative:** tabs on `/` (Personal / Following / Public). Rejected for this change to keep scope small; easy to move later. + +## Risks / Trade-offs + +- **Privacy of follow lists**: follower/following lists are public on the instance. → Explicit, documented in the privacy manifest; matches Mastodon norms; users who don't want to be discoverable as a follower should set `profile_visibility = 'private'`, which 404s their profile and (when federation arrives) their actor object. +- **Empty feed on small instances**: a typical local-only instance will have few people to follow. → Acknowledged; the empty-state copy points users to `/users/:username` profiles to find folks. Federation will widen the pool, but isn't part of this change. +- **Schema churn at federation time**: even with the forward-compatible IRI column, `social-federation` will likely add `remote_actors` + audience columns + per-user keypair storage. → Accepted; the federation change can iterate on its own schema. The local-only follows + feed survive untouched. +- **Feed query growth**: at 50k activities × 1k follows, the join could degrade. → The `(follower_id, created_at DESC)` index keeps this fine up to ~10k follows per user; above that we'd revisit fan-out-on-write. Punt. +- **Marketing copy currently overstates federation**: the Journal home blurb says "Federated by design — your Journal instance talks to other trails.cool servers via ActivityPub. Follow friends anywhere." → Out of scope for this PR; soften copy as a separate one-liner change before public launch, or leave as forward-looking until `social-federation` lands. + +## Migration Plan + +1. Land schema via `drizzle-kit push --force` — adds `follows` table and `users.profile_visibility` column. No row backfill needed (column default fills existing users with `'public'`). +2. Ship API + UI behind no flag — the feature is additive and a user with zero follows just sees an empty `/feed`. +3. Document the new relation + setting in the privacy manifest before user-visible release. +4. Rollback: revert the PR. The `follows` table can be dropped without affecting other data; the `profile_visibility` column can be dropped, returning the implicit "public iff has public content" behavior (loaders already check that). + +## Open Questions + +- Should the initial nav entry be labeled "Feed" or "Following"? "Feed" is shorter and matches the route; "Following" is clearer about what's in it. Tasks phase can pick — lean "Feed" for now. +- Do we surface the instance's local public feed anywhere for signed-in users post-change? Today they see it only when signed out. A "Local" tab alongside the Following feed could be a later follow-up; not in this change. diff --git a/openspec/changes/social-feed/proposal.md b/openspec/changes/social-feed/proposal.md new file mode 100644 index 0000000..d8e3cc1 --- /dev/null +++ b/openspec/changes/social-feed/proposal.md @@ -0,0 +1,40 @@ +## 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'`, default `'public'`). Today profile-publicness is implicit (derived from "has any public content"); making it explicit unifies the mental model with activity/route visibility, gives users a real toggle for "be discoverable at all," and gates future federation cleanly. Existing users migrate to `'public'`. +- Users SHALL be able to follow another *local* user (same instance) by clicking a Follow button on the user's profile page. Local public profiles auto-accept the follow. +- A signed-in user SHALL be able to view a **social feed** of public activities from the users they follow, 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. +- Public profile pages SHALL show follower and following counts and a Follow / Unfollow toggle for the viewing user. +- Privacy: the follow relationship on a single instance is queryable (follower/following lists are public) matching Mastodon-style conventions. Only `public` activities appear in the feed — `unlisted` and `private` stay out even if you follow the owner. +- 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. + - Locked local accounts (manual follow approval) — `locked-local-accounts`. The current `'public' | 'private'` enum is intentionally minimal so the locked enum value (or a separate `users.locked` flag) can be added cleanly later. + +## 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. diff --git a/openspec/changes/social-feed/specs/journal-landing/spec.md b/openspec/changes/social-feed/specs/journal-landing/spec.md new file mode 100644 index 0000000..36fd91d --- /dev/null +++ b/openspec/changes/social-feed/specs/journal-landing/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Social feed link for signed-in users +For signed-in users, the personal dashboard SHALL include a prominent link to the social feed at `/feed` (see `social-follows` spec, "Social activity feed") alongside the existing "New Activity" CTA. The link SHALL be visible regardless of whether the user follows anyone yet — the social feed's own empty state handles the zero-follows case. + +#### Scenario: Feed link on personal dashboard +- **WHEN** a signed-in user loads `/` +- **THEN** the dashboard header shows a "Feed" (or equivalent) link to `/feed` next to the "New Activity" CTA + +#### Scenario: Feed link is not shown to signed-out visitors +- **WHEN** an unauthenticated visitor loads `/` +- **THEN** the visitor-home layout does not expose a link to `/feed` (the route requires authentication) diff --git a/openspec/changes/social-feed/specs/public-profiles/spec.md b/openspec/changes/social-feed/specs/public-profiles/spec.md new file mode 100644 index 0000000..1df71cc --- /dev/null +++ b/openspec/changes/social-feed/specs/public-profiles/spec.md @@ -0,0 +1,49 @@ +## 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. + +#### Scenario: Default for a new account +- **WHEN** a user registers +- **THEN** their `profile_visibility` is `public` + +#### 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 + +#### 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) + +#### 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 + +## 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. + +#### 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 + +#### 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: 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) + +#### 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 page emits social-share metadata +- **WHEN** any visitor loads a populated `/users/:username` +- **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 diff --git a/openspec/changes/social-feed/specs/social-follows/spec.md b/openspec/changes/social-feed/specs/social-follows/spec.md new file mode 100644 index 0000000..d0c6e03 --- /dev/null +++ b/openspec/changes/social-feed/specs/social-follows/spec.md @@ -0,0 +1,57 @@ +## 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. + +#### 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: 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 + +### 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. + +#### 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 + +#### 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 + +### 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. + +#### 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: Empty feed state +- **WHEN** a signed-in user with zero 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 +- **WHEN** an unauthenticated visitor requests `/feed` +- **THEN** they are redirected to `/auth/login` + +### Requirement: Schema is forward-compatible with federation +The `follows` table SHALL key the followed side by an `actor_iri TEXT` column (not a plain user FK), so the `social-federation` change can store remote IRIs in the same column without migration. Local follows SHALL populate `actor_iri` with the local user's canonical actor IRI (`https://{DOMAIN}/users/{username}`). + +#### Scenario: Local follow stores both IRI and FK +- **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 diff --git a/openspec/changes/social-feed/tasks.md b/openspec/changes/social-feed/tasks.md new file mode 100644 index 0000000..3d7ab3d --- /dev/null +++ b/openspec/changes/social-feed/tasks.md @@ -0,0 +1,53 @@ +## 1. Schema + +- [ ] 1.1 Add `follows` table to `packages/db/src/schema/journal.ts`: `id UUID PK`, `follower_id TEXT NOT NULL REFERENCES users`, `followed_actor_iri TEXT NOT NULL` (forward-compatible with federation), `followed_user_id TEXT REFERENCES users` (always populated for local follows in this change), `accepted_at TIMESTAMPTZ` (always set to `now()` in this change but kept nullable for future Pending state), `created_at TIMESTAMPTZ DEFAULT now()`. Unique `(follower_id, followed_actor_iri)`. Indexes `(follower_id, created_at DESC)` and `(followed_actor_iri)` +- [ ] 1.2 Add `profile_visibility TEXT NOT NULL DEFAULT 'public'` (`'public' | 'private'`) to `journal.users`. Existing rows pick up the default (= public), preserving current effective behavior for everyone +- [ ] 1.3 Add a small helper `localActorIri(username)` returning `https://{DOMAIN}/users/{username}` to keep IRI construction consistent across the codebase +- [ ] 1.4 Run `pnpm db:push` locally and confirm migration is clean (no prompts, no ambiguity) +- [ ] 1.5 Update the privacy manifest to document the `follows` relation (which user follows whom on this instance, when) and the new `profile_visibility` setting + +## 2. Follow / unfollow API + +- [ ] 2.1 Add `follow.server.ts` with `followUser(followerId, targetUsername)` and `unfollowUser(followerId, targetUsername)` that resolve the local target, refuse if `profile_visibility = 'private'` or self-follow, write/delete the row, and return the new state +- [ ] 2.2 `POST /api/users/:username/follow` route: session-bound, calls `followUser`, returns 200 with new state or 4xx on refusal +- [ ] 2.3 `POST /api/users/:username/unfollow` route: session-bound, calls `unfollowUser`, returns 200 with new state +- [ ] 2.4 Unit tests: follow/unfollow happy path, refusal for private profile, self-follow rejected, idempotent follow/unfollow + +## 3. Follower / following collections + +- [ ] 3.1 Queries for follower list and following list of a given user, paginated (50/page), reverse-chron on `accepted_at` +- [ ] 3.2 Routes `/users/:username/followers` and `/users/:username/following` rendering paginated lists with display name + handle + small profile link +- [ ] 3.3 Query + UI for follower and following counts on `/users/:username` + +## 4. Social feed + +- [ ] 4.1 Query `listSocialFeed(followerId, limit, cursor)` joining `follows` → `activities`, returning rows where `accepted_at IS NOT NULL` AND `a.visibility = 'public'`, reverse-chronological by `created_at` +- [ ] 4.2 Route `/feed` (signed-in only; redirects to `/auth/login` when anonymous) rendering the aggregated cards; reuse the existing activity-card component from the home visitor feed +- [ ] 4.3 Empty state: "You're not following anyone yet" with a link to the instance public feed at `/` +- [ ] 4.4 Register the route in `apps/journal/app/routes.ts` +- [ ] 4.5 Add a "Feed" link to the signed-in nav + the personal dashboard header next to "New Activity" + +## 5. Profile page + visibility + +- [ ] 5.1 Follow / Unfollow button component — state pulled from the viewer's session + `follows` row, action submits to the new API route +- [ ] 5.2 Integrate on `/users/:username` above the public route/activity list; hide for the profile owner +- [ ] 5.3 Follower/following count display linking to the new collection pages +- [ ] 5.4 Update `/users/:username` loader to gate on `profile_visibility = 'public'` AND has-public-content (preserve indistinguishable 404) +- [ ] 5.5 Add a "Profile visibility" toggle (Public / Private) to `/settings/profile`, with a one-line explainer of what each mode does +- [ ] 5.6 Owner-only redirect or "your profile isn't public yet" view for owners whose profile would 404 to visitors + +## 6. Testing + +- [ ] 6.1 Unit tests for `listSocialFeed` query: only follows of `accepted_at IS NOT NULL`, only `public` activities, pagination boundary +- [ ] 6.2 E2E: register two local users, A follows B, B posts a public activity → A sees it on `/feed`; B posts a private activity → A does not +- [ ] 6.3 E2E: Follow button transitions (not following → Follow → Unfollow → not following), profile counts update +- [ ] 6.4 E2E: `/feed` redirects anonymous visitors to `/auth/login` +- [ ] 6.5 E2E: empty `/feed` renders the empty state and link to public feed +- [ ] 6.6 E2E (profile_visibility): owner flips to private → `/users/:username` returns 404 even with public content, the Follow button vanishes for any prospective follower. Owner flips back to public → previous behavior restored + +## 7. Rollout + +- [ ] 7.1 Schema ships via `drizzle-kit push --force` — additive only, no row backfill needed (column defaults handle existing users) +- [ ] 7.2 Post-deploy: smoke-follow bruno (demo-bot) from a test account, confirm bruno's public activity appears on `/feed` +- [ ] 7.3 (Follow-up change) `social-federation` adds Fedify, actor objects, signed inbox/outbox, remote follow + ingestion. The `follows` schema in this change supports those without migration +- [ ] 7.4 (Follow-up change) Consider a "Local" tab alongside the Following feed that surfaces the instance public feed for signed-in users — not in this change