feat: add feed
This commit is contained in:
+45
-44
@@ -1,29 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Compass, LogIn, Sparkles } from "lucide-react";
|
||||
import { Compass, Loader2, LogIn, Sparkles } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
||||
import { HomeFeed } from "@/components/feed/home-feed";
|
||||
|
||||
/**
|
||||
* / — the Home (Feed) gate.
|
||||
*
|
||||
* The Instagram-inspired infinite-scroll feed of posts from people you
|
||||
* follow will live here. Until that ships we show two paths:
|
||||
*
|
||||
* • Sign in — for visitors who don't have an account yet; the eventual
|
||||
* feed is personalised, so an account is the prerequisite.
|
||||
* • Explore the Knowledgebase — Śrīla Prabhupāda's full library is
|
||||
* ready today, regardless of feed status.
|
||||
*
|
||||
* The "Sign in" CTA hides once the user is authenticated — at that point
|
||||
* there's nothing to gate, just a "feed coming soon" note above the
|
||||
* always-on Knowledgebase entry.
|
||||
* • Signed in → the daily sādhana streak on top, then the global,
|
||||
* personalised infinite-scroll feed of community posts.
|
||||
* • Anonymous → a welcome screen: sign in (the feed is personalised, so an
|
||||
* account is the prerequisite) or jump into the Knowledgebase.
|
||||
*/
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, loading } = useAuth();
|
||||
const authed = !loading && !!user;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex-1 flex items-center justify-center py-24">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6">
|
||||
<div className="mb-6">
|
||||
<SadhnaStreakCTA />
|
||||
</div>
|
||||
<HomeFeed />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24 select-none">
|
||||
@@ -36,51 +47,41 @@ export default function HomePage() {
|
||||
</span>
|
||||
|
||||
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-3xl text-foreground leading-tight text-balance">
|
||||
{authed
|
||||
? "Your feed is coming soon"
|
||||
: "Welcome to Prabhupāda Says"}
|
||||
Welcome to Prabhupāda Says
|
||||
</h1>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
|
||||
{authed
|
||||
? "We're building a feed of quotes shared by the community. Until it lands, dive into the Knowledgebase — Śrīla Prabhupāda's books, letters, lectures, and teachings."
|
||||
: "Sign in to follow seekers and see their quotes here once the feed launches, or jump straight into the Knowledgebase — the original, unedited writings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda."}
|
||||
Sign in to follow seekers and see their quotes in your feed, or jump
|
||||
straight into the Knowledgebase — the original, unedited writings of
|
||||
His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 mt-2">
|
||||
{!authed ? (
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
|
||||
>
|
||||
<LogIn className="size-4" />
|
||||
Sign in to view feed
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
|
||||
>
|
||||
<LogIn className="size-4" />
|
||||
Sign in to view feed
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className={
|
||||
authed
|
||||
? "inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-5 h-11 text-sm font-medium hover:bg-primary/90 transition-colors min-w-[12rem]"
|
||||
: "inline-flex items-center justify-center gap-2 rounded-lg border border-border text-foreground px-5 h-11 text-sm font-medium hover:bg-muted transition-colors min-w-[12rem]"
|
||||
}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-border text-foreground px-5 h-11 text-sm font-medium hover:bg-muted transition-colors min-w-[12rem]"
|
||||
>
|
||||
<Compass className="size-4" />
|
||||
Explore Knowledgebase
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!authed ? (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
No account?{" "}
|
||||
<Link href="/signup" className="text-foreground hover:underline">
|
||||
Create one
|
||||
</Link>{" "}
|
||||
— it's free.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
No account?{" "}
|
||||
<Link href="/signup" className="text-foreground hover:underline">
|
||||
Create one
|
||||
</Link>{" "}
|
||||
— it's free.
|
||||
</p>
|
||||
|
||||
{/* Daily sādhana streak — a simple entry point until the feed lands. */}
|
||||
{/* Daily sādhana streak — a simple entry point for visitors. */}
|
||||
<div className="mt-6 w-full max-w-md">
|
||||
<SadhnaStreakCTA />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { auth, type User } from "@/lib/auth";
|
||||
import { feed } from "@/lib/social";
|
||||
|
||||
/**
|
||||
* AuthProvider holds the current-user state and exposes the auth actions
|
||||
@@ -89,6 +90,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
const logOut = useCallback(async () => {
|
||||
// Drop the home-feed seen-set first, while the auth cookie is still set
|
||||
// (the reset endpoint is auth-gated). Best-effort — a stale set would
|
||||
// otherwise expire on its own TTL.
|
||||
await feed.reset().catch(() => {});
|
||||
await auth.logOut();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HomeFeed — the global, personalised feed of community posts.
|
||||
*
|
||||
* Architecture:
|
||||
* • Window-virtualised list (dynamic row heights via measureElement) so a
|
||||
* long feed stays cheap to render.
|
||||
* • Infinite scroll: 10 posts per page, fetched as a bottom sentinel nears
|
||||
* the viewport. The server orders not-yet-liked posts from people you
|
||||
* follow first, then everyone else — seamless, no visible boundary.
|
||||
* • When the feed is exhausted, a "new on the platform" strip (≤5 accounts
|
||||
* that joined in the last 24h) caps it off — hidden if there are none.
|
||||
*
|
||||
* The card's Follow / Follow back affordance is enabled here (enableFollow);
|
||||
* the server hydrates each post's viewer↔author relationship.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { feed, type Post, type UserSummary } from "@/lib/social";
|
||||
import { PostCard } from "@/components/social/post/post-card";
|
||||
import { NewUsersStrip } from "./new-users-strip";
|
||||
|
||||
const PAGE = 10;
|
||||
|
||||
export function HomeFeed() {
|
||||
const [items, setItems] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [newUsers, setNewUsers] = useState<UserSummary[]>([]);
|
||||
|
||||
const fetchingRef = useRef(false);
|
||||
const offsetRef = useRef(0);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [offsetTop, setOffsetTop] = useState(0);
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const rows = (await feed.home(PAGE, offsetRef.current)) ?? [];
|
||||
offsetRef.current += rows.length;
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
return [...prev, ...rows.filter((p) => !seen.has(p.id))];
|
||||
});
|
||||
if (rows.length < PAGE) {
|
||||
setHasMore(false);
|
||||
// Feed exhausted → load the discovery strip once.
|
||||
feed
|
||||
.newUsers()
|
||||
.then((u) => setNewUsers(u ?? []))
|
||||
.catch(() => setNewUsers([]));
|
||||
}
|
||||
} catch {
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial page + measure where the list sits in the document (so the window
|
||||
// virtualiser offsets correctly below the streak component).
|
||||
useEffect(() => {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
useEffect(() => {
|
||||
if (parentRef.current) setOffsetTop(parentRef.current.offsetTop);
|
||||
}, [loading]);
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: items.length,
|
||||
estimateSize: () => 460,
|
||||
overscan: 4,
|
||||
scrollMargin: offsetTop,
|
||||
});
|
||||
|
||||
// Infinite scroll — fetch the next page as the sentinel approaches.
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node || !hasMore) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !fetchingRef.current) {
|
||||
void loadPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "600px 0px" },
|
||||
);
|
||||
io.observe(node);
|
||||
return () => io.disconnect();
|
||||
}, [hasMore, loadPage, items.length]);
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="rounded-xl bg-card ring-1 ring-foreground/10 px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
Your feed is quiet for now. Follow a few devotees, or share a verse to
|
||||
get things started.
|
||||
</p>
|
||||
<NewUsersStrip users={newUsers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualItems.map((vi) => {
|
||||
const post = items[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${vi.start - offsetTop}px)` }}
|
||||
>
|
||||
<div className="pb-4">
|
||||
<PostCard post={post} enableFollow />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hasMore ? (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<NewUsersStrip users={newUsers} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NewUsersStrip — the "new on the platform" discovery row shown at the very
|
||||
* end of the home feed. Up to 5 accounts that joined in the last 24h; each
|
||||
* avatar links to the user's profile. Renders nothing when the list is empty
|
||||
* (the feed simply ends).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type { UserSummary } from "@/lib/social";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
|
||||
export function NewUsersStrip({ users }: { users: UserSummary[] }) {
|
||||
if (users.length === 0) return null;
|
||||
return (
|
||||
<section className="mt-2 rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||
<div className="mb-4 flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary shrink-0"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
New to Prabhupāda Says
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Welcome them — follow the devotees who just joined.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-4">
|
||||
{users.map((u) => (
|
||||
<li key={u.handle}>
|
||||
<Link
|
||||
href={`/u/${u.handle}`}
|
||||
className="group flex w-20 flex-col items-center gap-1.5 text-center"
|
||||
>
|
||||
<UserAvatar
|
||||
src={u.avatar_url}
|
||||
name={u.name}
|
||||
className="size-14 group-hover:ring-foreground/30 transition"
|
||||
/>
|
||||
<span className="block w-full truncate text-xs font-medium text-foreground">
|
||||
{u.name}
|
||||
</span>
|
||||
<span className="-mt-1 block w-full truncate text-[11px] text-muted-foreground">
|
||||
@{u.handle}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { notifications } from "@/lib/social";
|
||||
import { feed, notifications } from "@/lib/social";
|
||||
import { sadhna } from "@/lib/sadhna";
|
||||
|
||||
type NotificationsContextValue = {
|
||||
@@ -113,8 +113,24 @@ export function NotificationsProvider({
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
// Open the realtime connection while signed in.
|
||||
const userId = user?.id;
|
||||
|
||||
// When the session ends — the page unloads / the realtime socket drops as
|
||||
// the user navigates away or closes the tab — clear the home-feed seen-set
|
||||
// so it doesn't linger in Redis. `keepalive` lets the request outlive the
|
||||
// page. (Genuine network drops with no unload are caught by the set's TTL;
|
||||
// we deliberately don't clear on transient socket blips, which would re-show
|
||||
// posts mid-scroll.)
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const onLeave = () => {
|
||||
void feed.reset().catch(() => {});
|
||||
};
|
||||
window.addEventListener("pagehide", onLeave);
|
||||
return () => window.removeEventListener("pagehide", onLeave);
|
||||
}, [userId]);
|
||||
|
||||
// Open the realtime connection while signed in.
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
|
||||
|
||||
@@ -206,6 +206,10 @@ export function CollectionsDrawer({
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
direction={direction}
|
||||
// Let the browser handle the on-screen keyboard. vaul's default
|
||||
// input-repositioning resizes/translates the sheet and gets stuck on
|
||||
// iOS after the keyboard hides (matches the other input-bearing drawers).
|
||||
repositionInputs={false}
|
||||
>
|
||||
<DrawerContent
|
||||
direction={direction}
|
||||
@@ -316,7 +320,9 @@ export function CollectionsDrawer({
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Collection title"
|
||||
maxLength={120}
|
||||
className="w-full bg-white/5 text-white text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
|
||||
// 16px on mobile (text-base) so iOS Safari doesn't auto-zoom
|
||||
// on focus; back to 14px from sm: up.
|
||||
className="w-full bg-white/5 text-white text-base sm:text-sm rounded-md px-3 py-2 placeholder:text-white/30 outline-none focus:bg-white/10"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-white/60">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FeedFollowButton — the inline Follow / Follow back affordance shown in a
|
||||
* home-feed PostCard header for authors the viewer doesn't already follow.
|
||||
*
|
||||
* Follows are request-based, so a tap sends a request and the control settles
|
||||
* into a non-interactive "Requested" pill (matching the profile action row).
|
||||
* Anonymous taps route through the auth gate first.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Clock, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { relationships, SocialError } from "@/lib/social";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
|
||||
export function FeedFollowButton({
|
||||
handle,
|
||||
followsYou,
|
||||
}: {
|
||||
handle: string;
|
||||
/** The author already follows the viewer → label becomes "Follow back". */
|
||||
followsYou: boolean;
|
||||
}) {
|
||||
const { requireAuth } = useAuthGate();
|
||||
const [requested, setRequested] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function follow() {
|
||||
if (busy || requested) return;
|
||||
if (!(await requireAuth())) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await relationships.follow(handle);
|
||||
setRequested(true);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof SocialError ? err.message : "Could not follow",
|
||||
{ duration: 3000 },
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (requested) {
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-3 h-8 text-xs font-medium text-muted-foreground">
|
||||
<Clock className="size-3.5" />
|
||||
Requested
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={follow}
|
||||
disabled={busy}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full bg-primary px-3 h-8 text-xs font-medium text-primary-foreground dark:text-white hover:bg-primary/90 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<UserPlus className="size-3.5" />
|
||||
{followsYou ? "Follow back" : "Follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -30,13 +30,18 @@ import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import { PostActionsFooter } from "./post-actions-footer";
|
||||
import { PostCardMenu } from "./post-card-menu";
|
||||
import { FeedFollowButton } from "./feed-follow-button";
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
onDelete,
|
||||
enableFollow = false,
|
||||
}: {
|
||||
post: Post;
|
||||
onDelete?: (id: string) => void;
|
||||
/** Home-feed only: show a Follow / Follow back button in the header for
|
||||
* authors the viewer doesn't already follow. */
|
||||
enableFollow?: boolean;
|
||||
}) {
|
||||
const sourceHref =
|
||||
hasHighlight(post.source_url) || !post.caption
|
||||
@@ -94,6 +99,12 @@ export function PostCard({
|
||||
{timeAgo(post.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{enableFollow && authorHandle && !post.viewer_follows_author ? (
|
||||
<FeedFollowButton
|
||||
handle={authorHandle}
|
||||
followsYou={!!post.author_follows_viewer}
|
||||
/>
|
||||
) : null}
|
||||
<PostCardMenu post={post} onDelete={onDelete} />
|
||||
</header>
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ export type Post = {
|
||||
pinned_comment?: Comment | null;
|
||||
/** Total comments + replies on the post (drives the footer comment count). */
|
||||
comments_count?: number;
|
||||
/** Home-feed only: does the viewer follow this post's author (accepted)? */
|
||||
viewer_follows_author?: boolean;
|
||||
/** Home-feed only: does the author follow the viewer (drives "Follow back")? */
|
||||
author_follows_viewer?: boolean;
|
||||
};
|
||||
|
||||
/** A comment or reply on a post. Threading is one level deep: `parent_id` is
|
||||
@@ -541,6 +545,31 @@ export const profile = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Home feed (global, personalised) ──────────────────────────────────────
|
||||
|
||||
export const feed = {
|
||||
/** One page of the global home feed (auth required). Returns at most
|
||||
* `limit` posts; callers infer "more" from a full page. */
|
||||
home(limit = 10, offset = 0) {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return call<Post[]>(`/feed?${qs}`);
|
||||
},
|
||||
/** Up to 5 accounts that joined in the last 24h — the feed's discovery
|
||||
* strip. Empty array when there are none. */
|
||||
newUsers() {
|
||||
return call<UserSummary[]>(`/feed/new-users`);
|
||||
},
|
||||
/** Clear the server-side "seen" set so the feed starts fresh. Called on
|
||||
* logout and on page unload; `keepalive` lets the request outlive the
|
||||
* unloading page (like a beacon). */
|
||||
reset() {
|
||||
return call<void>(`/feed/reset`, { method: "POST", keepalive: true });
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Relationships (follow / block / report) ───────────────────────────────
|
||||
|
||||
/** Allowed user-report categories — mirrors the BFF allow-list. */
|
||||
|
||||
Reference in New Issue
Block a user