feat: hide post
This commit is contained in:
@@ -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'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.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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: <EyeOff className="size-5" />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 max-w-3xl w-full mx-auto px-4 sm:px-6">
|
||||
<ProfileHeader
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 {
|
||||
Drawer,
|
||||
@@ -64,6 +64,7 @@ type MenuItem = {
|
||||
export function PostCardMenu({
|
||||
post,
|
||||
onDelete,
|
||||
onHiddenChange,
|
||||
}: {
|
||||
post: Post;
|
||||
/** 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
|
||||
* existing /u/me Delete-with-modal pattern). */
|
||||
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 { 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<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(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 ? (
|
||||
<Eye className="size-4" />
|
||||
) : (
|
||||
<EyeOff className="size-4" />
|
||||
),
|
||||
onClick: () => {
|
||||
void toggleHidden();
|
||||
},
|
||||
});
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
key: "delete",
|
||||
|
||||
@@ -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}
|
||||
<PostCardMenu post={post} onDelete={onDelete} />
|
||||
<PostCardMenu
|
||||
post={post}
|
||||
onDelete={onDelete}
|
||||
onHiddenChange={onHiddenChange}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* Media — image carousel or gradient-with-quote */}
|
||||
|
||||
@@ -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<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. */
|
||||
replaceImages(id: string, image_urls: string[]) {
|
||||
return call<Post>(`/posts/${id}/images`, {
|
||||
@@ -257,6 +271,10 @@ export const 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
|
||||
* side. Resharing your own post is rejected with 400 (not currently
|
||||
* surfaced as a distinct error type because the UI only ever offers
|
||||
|
||||
Reference in New Issue
Block a user