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

@ -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",