From 79f0bd9223e4f307b15d3e257ececd5ab7bcd42b Mon Sep 17 00:00:00 2001 From: gosangam Date: Sun, 14 Jun 2026 19:52:16 +0530 Subject: [PATCH] feat: hide post --- app/(app)/u/me/hidden/page.tsx | 76 +++++++++++++++++++++ app/(app)/u/me/layout.tsx | 12 ++++ components/social/post/post-card-menu.tsx | 40 ++++++++++- components/social/post/post-scroll-card.tsx | 10 ++- lib/social.ts | 18 +++++ 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 app/(app)/u/me/hidden/page.tsx diff --git a/app/(app)/u/me/hidden/page.tsx b/app/(app)/u/me/hidden/page.tsx new file mode 100644 index 00000000..51b85e77 --- /dev/null +++ b/app/(app)/u/me/hidden/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

+ Hidden posts don't appear on your timeline, in the feed, or in + public collections — but stay in your private collections. Use a card's + menu to unhide. +

+ + {error ? ( +

{error}

+ ) : loading ? ( +

Loading…

+ ) : feed.length === 0 ? ( +

No hidden posts.

+ ) : ( + feed.map((p) => ( + + )) + )} +
+ ); +} diff --git a/app/(app)/u/me/layout.tsx b/app/(app)/u/me/layout.tsx index ec784856..5fd1bc13 100644 --- a/app/(app)/u/me/layout.tsx +++ b/app/(app)/u/me/layout.tsx @@ -18,6 +18,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { AtSign, Bookmark, + EyeOff, FolderClosed, LayoutGrid, } from "lucide-react"; @@ -51,6 +52,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { posts: number; followers: number; following: number; + hidden: number; } | null>(null); const handle = user?.handle; useEffect(() => { @@ -64,6 +66,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { posts: p.post_count, followers: p.follower_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: , + }); + } + return (
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 { requireAuth } = useAuthGate(); @@ -78,6 +82,9 @@ export function PostCardMenu({ const [open, setOpen] = useState(false); const [blockOpen, setBlockOpen] = 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(null); const popoverRef = useRef(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 // early-return doesn't break the rules-of-hooks contract on // re-renders where viewer state changes. @@ -220,6 +244,20 @@ export function PostCardMenu({ onClick: copyCaption, }); 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 ? ( + + ) : ( + + ), + onClick: () => { + void toggleHidden(); + }, + }); if (onDelete) { items.push({ key: "delete", diff --git a/components/social/post/post-scroll-card.tsx b/components/social/post/post-scroll-card.tsx index 23b72830..85948a79 100644 --- a/components/social/post/post-scroll-card.tsx +++ b/components/social/post/post-scroll-card.tsx @@ -47,11 +47,15 @@ export function PostScrollCard({ post, onDelete, onUpdated, + onHiddenChange, enableFollow = false, }: { post: Post; onDelete?: (id: string) => 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 * authors the viewer doesn't already follow. Off everywhere else. */ enableFollow?: boolean; @@ -147,7 +151,11 @@ export function PostScrollCard({ followsYou={!!post.author_follows_viewer} /> ) : null} - + {/* Media — image carousel or gradient-with-quote */} diff --git a/lib/social.ts b/lib/social.ts index e8b63696..87f66f10 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -94,6 +94,9 @@ export type Post = { viewer_follows_author?: boolean; /** Home-feed only: does the author follow the viewer (drives "Follow back")? */ 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 @@ -180,6 +183,9 @@ export type PublicProfile = { /** ISO-3166-1 alpha-2 account country, or null when unknown. */ country: string | null; 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; following_count: number; relationship: RelationshipState | null; @@ -247,6 +253,14 @@ export const posts = { delete(id: string) { return call(`/posts/${id}`, { method: "DELETE" }); }, + /** Hide the post from timelines + public collections (owner-only). */ + hide(id: string) { + return call(`/posts/${id}/hide`, { method: "POST" }); + }, + /** Restore a hidden post to timelines (owner-only). */ + unhide(id: string) { + return call(`/posts/${id}/hide`, { method: "DELETE" }); + }, /** Swap the post's image set. Empty array removes all images. */ replaceImages(id: string, image_urls: string[]) { return call(`/posts/${id}/images`, { @@ -257,6 +271,10 @@ export const posts = { mine() { return call("/posts/mine"); }, + /** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */ + hidden() { + return call("/posts/hidden"); + }, /** Record a reshare. Idempotent — calling twice is a no-op server * side. Resharing your own post is rejected with 400 (not currently * surfaced as a distinct error type because the UI only ever offers