diff --git a/apps/journal/app/components/FollowButton.tsx b/apps/journal/app/components/FollowButton.tsx index 1a0d466..78b40be 100644 --- a/apps/journal/app/components/FollowButton.tsx +++ b/apps/journal/app/components/FollowButton.tsx @@ -8,21 +8,36 @@ interface FollowState { interface Props { username: string; + // Whether the followed profile is private/locked. Drives the "Request to + // follow" label vs. plain "Follow" before any click happens. + isPrivateTarget: boolean; initialState: FollowState | null; } -export function FollowButton({ username, initialState }: Props) { +type Display = "follow" | "request" | "pending" | "unfollow"; + +function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display { + if (state?.following) return "unfollow"; + if (state?.pending) return "pending"; + return isPrivateTarget ? "request" : "follow"; +} + +export function FollowButton({ username, isPrivateTarget, initialState }: Props) { const { t } = useTranslation("journal"); const [state, setState] = useState( initialState ?? { following: false, pending: false }, ); - const [isPending, startTransition] = useTransition(); + const [isInFlight, startTransition] = useTransition(); const [error, setError] = useState(null); + const display = displayFor(state, isPrivateTarget); + const onClick = () => { setError(null); startTransition(async () => { - const path = state.following + // For "pending" we treat the click as cancel-request: same /unfollow + // endpoint deletes the row whether it's accepted or pending. + const path = state.following || state.pending ? `/api/users/${username}/unfollow` : `/api/users/${username}/follow`; try { @@ -40,21 +55,33 @@ export function FollowButton({ username, initialState }: Props) { }); }; - const label = state.following ? t("social.unfollow") : t("social.follow"); + const label = (() => { + switch (display) { + case "unfollow": + return t("social.unfollow"); + case "pending": + return t("social.pendingCancel"); + case "request": + return t("social.requestToFollow"); + case "follow": + default: + return t("social.follow"); + } + })(); + + const baseClass = display === "follow" || display === "request" + ? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" + : "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"; return (
{error &&

{error}

}
diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index f99f952..92e6a67 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -279,6 +279,11 @@ export async function ensureDemoUser( displayName: persona.displayName, bio: persona.bio, domain, + // The demo persona is meant to be discoverable to anyone — its + // entire purpose is to populate empty instances with public + // content. Force `public` even though new accounts default to + // `private` (locked-account model). + profileVisibility: "public", termsAcceptedAt: new Date(), termsVersion: TERMS_VERSION, }) diff --git a/apps/journal/app/lib/follow.integration.test.ts b/apps/journal/app/lib/follow.integration.test.ts index 9ae2c11..2030eba 100644 --- a/apps/journal/app/lib/follow.integration.test.ts +++ b/apps/journal/app/lib/follow.integration.test.ts @@ -9,7 +9,10 @@ import { getFollowState, countFollowers, countFollowing, - FollowError, + countPendingFollowRequests, + listPendingFollowRequests, + approveFollowRequest, + rejectFollowRequest, } from "./follow.server.ts"; // Opt-in: these talk to real Postgres. Gated by an env flag so laptop @@ -74,12 +77,14 @@ describe.skipIf(!runIntegration)("follow.server integration", () => { expect(aRow.username.startsWith("f_a_")).toBe(true); }); - it("refuses to follow a private profile", async () => { + it("creates a Pending follow against a private profile (not a refusal)", async () => { const a = await makeUser({ username: `f_pa_${Date.now()}` }); const b = await makeUser({ username: `f_pb_${Date.now()}`, profileVisibility: "private" }); const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; - await expect(followUser(a, bRow.username)).rejects.toBeInstanceOf(FollowError); - await expect(followUser(a, bRow.username)).rejects.toMatchObject({ code: "private_profile" }); + const s = await followUser(a, bRow.username); + expect(s).toEqual({ following: false, pending: true }); + // Pending is excluded from accepted-only counts. + expect(await countFollowers(b)).toBe(0); expect(await countFollowing(a)).toBe(0); }); @@ -89,6 +94,48 @@ describe.skipIf(!runIntegration)("follow.server integration", () => { await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" }); }); + it("approve flips Pending → Accepted; reject deletes the request", async () => { + const a = await makeUser({ username: `f_apr_${Date.now()}` }); + const b = await makeUser({ username: `f_apb_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + await followUser(a, bRow.username); + expect(await countPendingFollowRequests(b)).toBe(1); + + const reqs = await listPendingFollowRequests(b); + expect(reqs.length).toBe(1); + const reqId = reqs[0]!.id; + + const approved = await approveFollowRequest(b, reqId); + expect(approved).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await countFollowers(b)).toBe(1); + expect(await getFollowState(a, bRow.username)).toEqual({ following: true, pending: false }); + + // Idempotent: approving again is a no-op. + expect(await approveFollowRequest(b, reqId)).toBe(false); + + // Reject path: a fresh request from a 3rd user, B rejects. + const c = await makeUser({ username: `f_apc_${Date.now()}` }); + await followUser(c, bRow.username); + const reqs2 = await listPendingFollowRequests(b); + expect(reqs2.length).toBe(1); + expect(await rejectFollowRequest(b, reqs2[0]!.id)).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await getFollowState(c, bRow.username)).toBeNull(); + }); + + it("approve/reject is owner-bound (other users can't approve someone else's request)", async () => { + const a = await makeUser({ username: `f_obA_${Date.now()}` }); + const b = await makeUser({ username: `f_obB_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + const c = await makeUser({ username: `f_obC_${Date.now()}` }); + await followUser(a, bRow.username); + const reqs = await listPendingFollowRequests(b); + // C tries to approve B's incoming request — should be a no-op. + expect(await approveFollowRequest(c, reqs[0]!.id)).toBe(false); + expect(await countPendingFollowRequests(b)).toBe(1); + }); + it("404s on unknown username", async () => { const a = await makeUser({ username: `f_404_${Date.now()}` }); await expect(followUser(a, "no_such_user_xyz")).rejects.toMatchObject({ code: "user_not_found" }); diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index cc198b0..5165e2c 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; -import { eq, and, count, desc } from "drizzle-orm"; +import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { users, follows } from "@trails-cool/db/schema/journal"; import { localActorIri } from "./actor-iri.ts"; export class FollowError extends Error { - readonly code: "self_follow" | "private_profile" | "user_not_found" | "not_found"; + readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden"; constructor(code: FollowError["code"], message: string) { super(message); this.name = "FollowError"; @@ -34,18 +34,16 @@ async function loadFollowableTarget(targetUsername: string) { /** * Create a follow row from `followerId` to the local user with username - * `targetUsername`. Auto-accepted because the target is local + public. - * Idempotent: re-following an already-followed user returns the same state - * without creating a duplicate row. + * `targetUsername`. Public targets auto-accept (`accepted_at = now()`), + * private (locked) targets land Pending (`accepted_at = NULL`) and + * appear in the target's /follows/requests list for manual approval. + * Idempotent: re-following keeps the existing row's state. */ export async function followUser(followerId: string, targetUsername: string): Promise { const target = await loadFollowableTarget(targetUsername); if (target.id === followerId) { throw new FollowError("self_follow", "Users cannot follow themselves"); } - if (target.profileVisibility !== "public") { - throw new FollowError("private_profile", "This profile is not followable"); - } const db = getDb(); const followedActorIri = localActorIri(target.username); @@ -57,15 +55,19 @@ export async function followUser(followerId: string, targetUsername: string): Pr return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; } + const acceptedAt = target.profileVisibility === "public" ? new Date() : null; await db.insert(follows).values({ id: randomUUID(), followerId, followedActorIri, followedUserId: target.id, - acceptedAt: new Date(), + acceptedAt, }); - return { following: true, pending: false }; + return { + following: acceptedAt !== null, + pending: acceptedAt === null, + }; } /** @@ -101,12 +103,16 @@ export async function getFollowState( return { following: row.acceptedAt !== null, pending: row.acceptedAt === null }; } +// Counts include only accepted relations — Pending requests don't count +// toward the public follower/following tallies (a request not yet +// approved isn't a real follow). + export async function countFollowers(userId: string): Promise { const db = getDb(); const [row] = await db .select({ n: count() }) .from(follows) - .where(eq(follows.followedUserId, userId)); + .where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt))); return row?.n ?? 0; } @@ -115,10 +121,91 @@ export async function countFollowing(userId: string): Promise { const [row] = await db .select({ n: count() }) .from(follows) - .where(eq(follows.followerId, userId)); + .where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt))); return row?.n ?? 0; } +/** + * Count of incoming Pending follow requests for `userId`. Drives the + * navbar badge. Distinct from countFollowers (which is accepted-only). + */ +export async function countPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))); + return row?.n ?? 0; +} + +export interface FollowRequest { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: Date; +} + +/** + * Pending incoming follow requests for `userId`. Used by /follows/requests. + * Reverse-chronological by request creation time. + */ +export async function listPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + id: follows.id, + followerUsername: users.username, + followerDisplayName: users.displayName, + followerDomain: users.domain, + createdAt: follows.createdAt, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))) + .orderBy(desc(follows.createdAt)); + return rows; +} + +/** + * Approve a Pending follow request. Owner-bound: `ownerId` must equal + * `follows.followedUserId` for the row, otherwise the call is a no-op. + */ +export async function approveFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .update(follows) + .set({ acceptedAt: new Date() }) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + +/** + * Reject a Pending follow request. Deletes the row entirely so the + * follower can re-request later if they want. + */ +export async function rejectFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .delete(follows) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + export interface CollectionEntry { username: string; displayName: string | null; @@ -128,7 +215,8 @@ export interface CollectionEntry { const COLLECTION_PAGE_SIZE = 50; /** - * Paginated list of users who follow `userId`. Newest acceptance first. + * Paginated list of accepted followers of `userId`. Newest acceptance first. + * Pending requests are excluded — they live in /follows/requests. */ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); @@ -141,7 +229,7 @@ export async function listFollowers(userId: string, page: number = 1): Promise { const db = getDb(); @@ -164,7 +251,7 @@ export async function listFollowing(userId: string, page: number = 1): Promise