feat: hide post

This commit is contained in:
gosangam
2026-06-14 19:52:16 +05:30
parent 7585e6cb31
commit 79f0bd9223
5 changed files with 154 additions and 2 deletions
+76
View File
@@ -0,0 +1,76 @@
"use client";
/**
* /u/me/hidden — the owner's hidden posts.
*
* Posts hidden from timelines + public collections live here (and in any
* private collections). Each card's overflow menu offers "Unhide from
* timeline"; on unhide we drop the post from this list since it's no longer
* hidden. The tab itself is only shown (by the /u/me layout) when the user
* has at least one hidden post.
*/
import { useEffect, useState } from "react";
import { useAuth } from "@/components/auth/auth-provider";
import { PostScrollCard } from "@/components/social/post/post-scroll-card";
import { posts, SocialError, type Post } from "@/lib/social";
export default function MyHiddenPostsPage() {
const { user } = useAuth();
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
posts
.hidden()
.then((rows) => setFeed(rows ?? []))
.catch((err) =>
setError(
err instanceof SocialError
? err.message
: "Could not load hidden posts.",
),
)
.finally(() => setLoading(false));
}, [user]);
function onPostUpdated(updated: Post) {
setFeed((rows) => rows.map((p) => (p.id === updated.id ? updated : p)));
}
// Unhiding a post means it's no longer hidden → remove it from this list.
function onHiddenChange(id: string, hidden: boolean) {
if (!hidden) setFeed((rows) => rows.filter((p) => p.id !== id));
}
if (!user) return null;
return (
<div className="max-w-xl w-full mx-auto space-y-6">
<p className="text-sm text-muted-foreground">
Hidden posts don&apos;t appear on your timeline, in the feed, or in
public collections — but stay in your private collections. Use a card&apos;s
menu to unhide.
</p>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : feed.length === 0 ? (
<p className="text-sm text-muted-foreground">No hidden posts.</p>
) : (
feed.map((p) => (
<PostScrollCard
key={p.id}
post={p}
onUpdated={onPostUpdated}
onHiddenChange={onHiddenChange}
/>
))
)}
</div>
);
}
+12
View File
@@ -18,6 +18,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { import {
AtSign, AtSign,
Bookmark, Bookmark,
EyeOff,
FolderClosed, FolderClosed,
LayoutGrid, LayoutGrid,
} from "lucide-react"; } from "lucide-react";
@@ -51,6 +52,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
posts: number; posts: number;
followers: number; followers: number;
following: number; following: number;
hidden: number;
} | null>(null); } | null>(null);
const handle = user?.handle; const handle = user?.handle;
useEffect(() => { useEffect(() => {
@@ -64,6 +66,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
posts: p.post_count, posts: p.post_count,
followers: p.follower_count, followers: p.follower_count,
following: p.following_count, following: p.following_count,
hidden: p.hidden_count ?? 0,
}); });
} }
}) })
@@ -109,6 +112,15 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
}, },
]; ];
// Surface the "Hidden" tab only when the user actually has hidden posts.
if ((counts?.hidden ?? 0) > 0) {
tabs.push({
href: "/u/me/hidden",
label: "Hidden",
icon: <EyeOff className="size-5" />,
});
}
return ( return (
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6"> <main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
<ProfileHeader <ProfileHeader
+39 -1
View File
@@ -21,7 +21,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Ban, Copy, Flag, MoreVertical, Trash2 } from "lucide-react"; import { Ban, Copy, Eye, EyeOff, Flag, MoreVertical, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Drawer, Drawer,
@@ -64,6 +64,7 @@ type MenuItem = {
export function PostCardMenu({ export function PostCardMenu({
post, post,
onDelete, onDelete,
onHiddenChange,
}: { }: {
post: Post; post: Post;
/** Owner-only: when provided AND the viewer is the author, the menu /** Owner-only: when provided AND the viewer is the author, the menu
@@ -71,6 +72,9 @@ export function PostCardMenu({
* expected to confirm + invoke the delete itself (matches the * expected to confirm + invoke the delete itself (matches the
* existing /u/me Delete-with-modal pattern). */ * existing /u/me Delete-with-modal pattern). */
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
/** Owner-only: called after a successful hide/unhide so the parent can
* update its local copy of the post (e.g. show a "Hidden" badge). */
onHiddenChange?: (id: string, hidden: boolean) => void;
}) { }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const { requireAuth } = useAuthGate(); const { requireAuth } = useAuthGate();
@@ -78,6 +82,9 @@ export function PostCardMenu({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [blockOpen, setBlockOpen] = useState(false); const [blockOpen, setBlockOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false);
// Optimistic local mirror of post.is_hidden so the menu label flips
// instantly on toggle without waiting for a parent refetch.
const [hidden, setHidden] = useState(!!post.is_hidden);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const [popPos, setPopPos] = useState<{ top: number; left: number } | null>( const [popPos, setPopPos] = useState<{ top: number; left: number } | null>(
@@ -208,6 +215,23 @@ export function PostCardMenu({
}); });
} }
async function toggleHidden() {
setOpen(false);
const next = !hidden;
setHidden(next); // optimistic
try {
if (next) await posts.hide(post.id);
else await posts.unhide(post.id);
toast(next ? "Hidden from your timeline" : "Restored to your timeline", {
duration: 2500,
});
onHiddenChange?.(post.id, next);
} catch {
setHidden(!next); // revert
toast.error("Could not update post visibility");
}
}
// Build the items list AFTER all hooks have run so a conditional // Build the items list AFTER all hooks have run so a conditional
// early-return doesn't break the rules-of-hooks contract on // early-return doesn't break the rules-of-hooks contract on
// re-renders where viewer state changes. // re-renders where viewer state changes.
@@ -220,6 +244,20 @@ export function PostCardMenu({
onClick: copyCaption, onClick: copyCaption,
}); });
if (isOwner) { if (isOwner) {
// Hide / unhide from timelines + public collections (independent of the
// delete-with-modal surface, so it's always available to the owner).
items.push({
key: "hide",
label: hidden ? "Unhide from timeline" : "Hide from timeline",
icon: hidden ? (
<Eye className="size-4" />
) : (
<EyeOff className="size-4" />
),
onClick: () => {
void toggleHidden();
},
});
if (onDelete) { if (onDelete) {
items.push({ items.push({
key: "delete", key: "delete",
+9 -1
View File
@@ -47,11 +47,15 @@ export function PostScrollCard({
post, post,
onDelete, onDelete,
onUpdated, onUpdated,
onHiddenChange,
enableFollow = false, enableFollow = false,
}: { }: {
post: Post; post: Post;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void; onUpdated?: (updated: Post) => void;
/** Owner-only: forwarded to the overflow menu so a parent can react to a
* hide/unhide (e.g. drop the post from the Hidden tab on unhide). */
onHiddenChange?: (id: string, hidden: boolean) => void;
/** Home-feed only: show a Follow / Follow back button in the header for /** Home-feed only: show a Follow / Follow back button in the header for
* authors the viewer doesn't already follow. Off everywhere else. */ * authors the viewer doesn't already follow. Off everywhere else. */
enableFollow?: boolean; enableFollow?: boolean;
@@ -147,7 +151,11 @@ export function PostScrollCard({
followsYou={!!post.author_follows_viewer} followsYou={!!post.author_follows_viewer}
/> />
) : null} ) : null}
<PostCardMenu post={post} onDelete={onDelete} /> <PostCardMenu
post={post}
onDelete={onDelete}
onHiddenChange={onHiddenChange}
/>
</header> </header>
{/* Media — image carousel or gradient-with-quote */} {/* Media — image carousel or gradient-with-quote */}
+18
View File
@@ -94,6 +94,9 @@ export type Post = {
viewer_follows_author?: boolean; viewer_follows_author?: boolean;
/** Home-feed only: does the author follow the viewer (drives "Follow back")? */ /** Home-feed only: does the author follow the viewer (drives "Follow back")? */
author_follows_viewer?: boolean; author_follows_viewer?: boolean;
/** Owner-only: post is hidden from timelines + public collections. Still
* shown to the owner on their own profile and in private collections. */
is_hidden?: boolean;
}; };
/** A comment or reply on a post. Threading is one level deep: `parent_id` is /** A comment or reply on a post. Threading is one level deep: `parent_id` is
@@ -180,6 +183,9 @@ export type PublicProfile = {
/** ISO-3166-1 alpha-2 account country, or null when unknown. */ /** ISO-3166-1 alpha-2 account country, or null when unknown. */
country: string | null; country: string | null;
post_count: number; post_count: number;
/** Owner-only: number of the caller's hidden posts (0 for other viewers).
* Drives the /u/me "Hidden" tab's visibility. */
hidden_count?: number;
follower_count: number; follower_count: number;
following_count: number; following_count: number;
relationship: RelationshipState | null; relationship: RelationshipState | null;
@@ -247,6 +253,14 @@ export const posts = {
delete(id: string) { delete(id: string) {
return call<void>(`/posts/${id}`, { method: "DELETE" }); return call<void>(`/posts/${id}`, { method: "DELETE" });
}, },
/** Hide the post from timelines + public collections (owner-only). */
hide(id: string) {
return call<void>(`/posts/${id}/hide`, { method: "POST" });
},
/** Restore a hidden post to timelines (owner-only). */
unhide(id: string) {
return call<void>(`/posts/${id}/hide`, { method: "DELETE" });
},
/** Swap the post's image set. Empty array removes all images. */ /** Swap the post's image set. Empty array removes all images. */
replaceImages(id: string, image_urls: string[]) { replaceImages(id: string, image_urls: string[]) {
return call<Post>(`/posts/${id}/images`, { return call<Post>(`/posts/${id}/images`, {
@@ -257,6 +271,10 @@ export const posts = {
mine() { mine() {
return call<Post[]>("/posts/mine"); return call<Post[]>("/posts/mine");
}, },
/** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */
hidden() {
return call<Post[]>("/posts/hidden");
},
/** Record a reshare. Idempotent — calling twice is a no-op server /** Record a reshare. Idempotent — calling twice is a no-op server
* side. Resharing your own post is rejected with 400 (not currently * side. Resharing your own post is rejected with 400 (not currently
* surfaced as a distinct error type because the UI only ever offers * surfaced as a distinct error type because the UI only ever offers