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),
}));

View file

@ -229,10 +229,38 @@ export default {
},
profile: {
ownNote: "Das ist dein Profil — Besucher:innen sehen nur Inhalte, die du als öffentlich markiert hast.",
privateNote: "Dein Profil ist auf Privat gestellt. Besucher:innen sehen 404; öffentliche Inhalte sind weiterhin per direkter URL erreichbar, aber du kannst nicht gefolgt werden.",
goToSettings: "Zu den Einstellungen",
noPublicRoutes: "Noch keine öffentlichen Routen.",
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
},
social: {
follow: "Folgen",
unfollow: "Entfolgen",
followers: {
label: "Follower",
heading: "Follower von {{user}}",
count: "{{count}} Follower",
count_other: "{{count}} Follower",
empty: "Noch niemand.",
},
following: {
label: "Folgt",
heading: "{{user}} folgt",
count: "Folgt {{count}}",
count_other: "Folgt {{count}}",
empty: "Folgt noch niemandem.",
},
prevPage: "Zurück",
nextPage: "Weiter",
pageOfTotal: "Seite {{page}} von {{totalPages}}",
feed: {
title: "Feed",
heading: "Folge ich",
empty: "Du folgst noch niemandem. Stöbere Profile durch und klicke auf Folgen, um deinen Feed aufzubauen.",
publicFeedLink: "Oder durchstöbere den öffentlichen Feed dieser Instanz →",
},
},
demo: {
badge: "🐕 Demo-Konto",
},
@ -259,6 +287,13 @@ export default {
displayName: "Anzeigename",
bio: "Bio",
saved: "Profil gespeichert.",
visibility: {
label: "Profil-Sichtbarkeit",
public: "Öffentlich",
publicHelp: "Deine Profilseite ist für alle sichtbar (sobald du öffentliche Inhalte hast). Du kannst gefolgt werden.",
private: "Privat",
privateHelp: "Deine Profilseite gibt 404 zurück. Öffentliche Inhalte sind weiterhin per direkter URL erreichbar, aber du kannst nicht gefolgt werden.",
},
},
security: {
title: "Sicherheit",

View file

@ -229,10 +229,38 @@ export default {
},
profile: {
ownNote: "This is your profile — visitors see only what you've marked public.",
privateNote: "Your profile is set to private. Visitors see a 404; public posts are still reachable by direct URL but you can't be followed.",
goToSettings: "Go to settings",
noPublicRoutes: "No public routes yet.",
noPublicActivities: "No public activities yet.",
},
social: {
follow: "Follow",
unfollow: "Unfollow",
followers: {
label: "Followers",
heading: "Followers of {{user}}",
count: "{{count}} follower",
count_other: "{{count}} followers",
empty: "Nobody yet.",
},
following: {
label: "Following",
heading: "{{user}} is following",
count: "Following {{count}}",
count_other: "Following {{count}}",
empty: "Not following anyone yet.",
},
prevPage: "Previous",
nextPage: "Next",
pageOfTotal: "Page {{page}} of {{totalPages}}",
feed: {
title: "Feed",
heading: "Following",
empty: "You're not following anyone yet. Browse profiles and tap Follow to start building your feed.",
publicFeedLink: "Or browse the instance public feed →",
},
},
demo: {
badge: "🐕 Demo account",
},
@ -259,6 +287,13 @@ export default {
displayName: "Display Name",
bio: "Bio",
saved: "Profile saved.",
visibility: {
label: "Profile visibility",
public: "Public",
publicHelp: "Your profile page is visible to anyone (when you have any public content). You can be followed.",
private: "Private",
privateHelp: "Your profile page returns 404. Public posts are still reachable by direct URL but you can't be followed.",
},
},
security: {
title: "Security",