Merge pull request #306 from trails-cool/proposal/social-feed
Propose: social-feed (follows + /feed)
This commit is contained in:
commit
aab9c35d7f
7 changed files with 330 additions and 0 deletions
2
openspec/changes/social-feed/.openspec.yaml
Normal file
2
openspec/changes/social-feed/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-04-24
|
||||||
117
openspec/changes/social-feed/design.md
Normal file
117
openspec/changes/social-feed/design.md
Normal file
|
|
@ -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.
|
||||||
40
openspec/changes/social-feed/proposal.md
Normal file
40
openspec/changes/social-feed/proposal.md
Normal file
|
|
@ -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.
|
||||||
12
openspec/changes/social-feed/specs/journal-landing/spec.md
Normal file
12
openspec/changes/social-feed/specs/journal-landing/spec.md
Normal file
|
|
@ -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)
|
||||||
49
openspec/changes/social-feed/specs/public-profiles/spec.md
Normal file
49
openspec/changes/social-feed/specs/public-profiles/spec.md
Normal file
|
|
@ -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
|
||||||
57
openspec/changes/social-feed/specs/social-follows/spec.md
Normal file
57
openspec/changes/social-feed/specs/social-follows/spec.md
Normal file
|
|
@ -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
|
||||||
53
openspec/changes/social-feed/tasks.md
Normal file
53
openspec/changes/social-feed/tasks.md
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue