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>
This commit is contained in:
parent
ede68712a3
commit
5da7ffa037
17 changed files with 656 additions and 181 deletions
|
|
@ -62,10 +62,30 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
return { user: user ? { id: user.id, username: user.username } : null, locale };
|
||||
// Pending follow-request count for the navbar badge. Cheap (a single
|
||||
// `count(*) WHERE accepted_at IS NULL`); only computed for signed-in
|
||||
// users. Hidden behind a dynamic import so the root layout doesn't
|
||||
// pull in the follow module on anonymous renders.
|
||||
let pendingFollowRequests = 0;
|
||||
if (user) {
|
||||
const { countPendingFollowRequests } = await import("./lib/follow.server.ts");
|
||||
pendingFollowRequests = await countPendingFollowRequests(user.id);
|
||||
}
|
||||
|
||||
return {
|
||||
user: user ? { id: user.id, username: user.username } : null,
|
||||
locale,
|
||||
pendingFollowRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function NavBar({ user }: { user: { id: string; username: string } | null }) {
|
||||
function NavBar({
|
||||
user,
|
||||
pendingFollowRequests,
|
||||
}: {
|
||||
user: { id: string; username: string } | null;
|
||||
pendingFollowRequests: number;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
const location = useLocation();
|
||||
|
||||
|
|
@ -103,6 +123,18 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
|
|||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<Link
|
||||
to="/follows/requests"
|
||||
className={`relative ${linkClass("/follows/requests")}`}
|
||||
title={t("social.requests.title")}
|
||||
>
|
||||
{t("social.requests.title")}
|
||||
{pendingFollowRequests > 0 && (
|
||||
<span className="ml-1 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-red-500 px-1.5 text-xs font-semibold text-white">
|
||||
{pendingFollowRequests}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/users/${user.username}`}
|
||||
className={linkClass(`/users/${user.username}`)}
|
||||
|
|
@ -143,6 +175,7 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
|
|||
export default function App({ loaderData }: Route.ComponentProps) {
|
||||
const user = loaderData?.user;
|
||||
const locale = loaderData?.locale ?? "en";
|
||||
const pendingFollowRequests = loaderData?.pendingFollowRequests ?? 0;
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
initSentryClient();
|
||||
|
|
@ -156,7 +189,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
return (
|
||||
<LocaleProvider locale={locale}>
|
||||
<AlphaBanner />
|
||||
<NavBar user={user ?? null} />
|
||||
<NavBar user={user ?? null} pendingFollowRequests={pendingFollowRequests} />
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</LocaleProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue