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 {
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user