Implement social-feed: local follows + /feed + profile visibility

Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.

Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
  federation forward-compat. Local IRIs look like
  `${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
  Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
  'public'). Existing users land 'public' via the default; current
  effective behavior is unchanged.

Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
  truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
  countFollowers / countFollowing / listFollowers / listFollowing.
  Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
  follows → activities WHERE visibility='public', reverse-chrono.

Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
  for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.

UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
  "New Activity".

Privacy manifest updated to document the new follows relation and
profile_visibility setting.

Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.

Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-25 22:51:18 +02:00
parent 6440631be4
commit 811d5f62f5
23 changed files with 1115 additions and 40 deletions

View file

@ -7,6 +7,8 @@ import {
jsonb,
boolean,
customType,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
const bytea = customType<{ data: Buffer }>({
@ -23,6 +25,8 @@ const lineString = customType<{ data: string }>({
export const journalSchema = pgSchema("journal");
export type ProfileVisibility = "public" | "private";
export const users = journalSchema.table("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
@ -30,6 +34,11 @@ export const users = journalSchema.table("users", {
displayName: text("display_name"),
bio: text("bio"),
domain: text("domain").notNull(),
// Whether the user is discoverable on this instance and (later) over
// ActivityPub. `private` 404s the profile and disables follows; `public`
// means /users/:username renders when the user has any public content.
// See spec: journal-landing + public-profiles + social-follows.
profileVisibility: text("profile_visibility").$type<ProfileVisibility>().notNull().default("public"),
termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
termsVersion: text("terms_version"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@ -195,3 +204,27 @@ export const syncImports = journalSchema.table("sync_imports", {
activityId: text("activity_id").references(() => activities.id, { onDelete: "set null" }),
importedAt: timestamp("imported_at", { withTimezone: true }).notNull().defaultNow(),
});
// Social follow relation. Always originates from a local user (`followerId`).
// The followed side is keyed by an actor IRI for federation forward-compat —
// today every IRI is local (`https://{DOMAIN}/users/{username}`); future
// `social-federation` change extends this to remote IRIs without migration.
// `followedUserId` is denormalized for fast local joins; populated for every
// row in this change. `acceptedAt` is always set today (auto-accept for
// public local profiles); the column stays nullable so federation's Pending
// state lands cleanly.
export const follows = journalSchema.table("follows", {
id: text("id").primaryKey(),
followerId: text("follower_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
followedActorIri: text("followed_actor_iri").notNull(),
followedUserId: text("followed_user_id").references(() => users.id, { onDelete: "cascade" }),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
followerActorUnique: uniqueIndex("follows_follower_actor_unique").on(t.followerId, t.followedActorIri),
followerCreatedIdx: index("follows_follower_created_idx").on(t.followerId, t.createdAt.desc()),
followedActorIdx: index("follows_followed_actor_idx").on(t.followedActorIri),
followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId),
}));