trails/apps/journal/app/components/FollowButton.tsx
Ullrich Schäfer 5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00

89 lines
2.8 KiB
TypeScript

import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
interface FollowState {
following: boolean;
pending: boolean;
}
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;
}
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<FollowState>(
initialState ?? { following: false, pending: false },
);
const [isInFlight, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const display = displayFor(state, isPrivateTarget);
const onClick = () => {
setError(null);
startTransition(async () => {
// 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 {
const res = await fetch(path, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error ?? "Failed");
return;
}
const body = (await res.json()) as { following: boolean; pending: boolean };
setState({ following: body.following, pending: body.pending });
} catch (e) {
setError((e as Error).message);
}
});
};
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 (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={onClick}
disabled={isInFlight}
className={baseClass}
>
{isInFlight ? "…" : label}
</button>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}