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>
89 lines
2.8 KiB
TypeScript
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>
|
|
);
|
|
}
|