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:
parent
6440631be4
commit
811d5f62f5
23 changed files with 1115 additions and 40 deletions
|
|
@ -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),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue