fix: user post feed view
This commit is contained in:
@@ -30,7 +30,7 @@ export function PostsGrid({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
profile
|
||||
.feed(handle, 30)
|
||||
.feed(handle, { limit: 30 })
|
||||
.then((rows) => {
|
||||
if (!cancelled) setFeed(rows ?? []);
|
||||
})
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
*
|
||||
* Split out from page.tsx so the page itself can be a server component
|
||||
* exporting `generateMetadata` for the OG card. The layout owns the
|
||||
* ProfileHeader + tabs + outer container; this component only renders
|
||||
* the inner scroll column (the Instagram-style feed of full posts).
|
||||
* ProfileHeader + tabs + outer container (and 404s for unknown handles);
|
||||
* this component only renders the inner scroll column.
|
||||
*
|
||||
* Hash-anchor behaviour: when arriving with `#post-<id>`, we scroll to
|
||||
* that post once the feed has loaded. Used by the SharePostSheet flow
|
||||
* — share short URL resolves to /u/<handle>/posts#post-<id>, recipient
|
||||
* lands here, and we scroll to the post that was actually shared.
|
||||
* Anchor-first + keyset infinite scroll via usePostScrollFeed: arriving with
|
||||
* `#post-<id>` (from a grid thumbnail or a shared link) fetches that post
|
||||
* directly so it lands at the top instantly — even for very old posts — then
|
||||
* pages older posts beneath it as you scroll.
|
||||
*/
|
||||
|
||||
import { use, useEffect, useRef, useState } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { profile, SocialError, type Post } from "@/lib/social";
|
||||
import { use } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { profile, type Post } from "@/lib/social";
|
||||
import { PostScrollCard } from "@/components/social/post/post-scroll-card";
|
||||
import { usePostScrollFeed } from "@/components/social/post/use-post-scroll-feed";
|
||||
|
||||
export function ScrollFeed({
|
||||
params,
|
||||
@@ -25,54 +26,21 @@ export function ScrollFeed({
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const { handle } = use(params);
|
||||
const [feed, setFeed] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [missing, setMissing] = useState(false);
|
||||
const scrolledToHashRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
profile
|
||||
.feed(handle, 60)
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
setFeed(rows ?? []);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof SocialError && err.status === 404) {
|
||||
setMissing(true);
|
||||
}
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || feed.length === 0 || scrolledToHashRef.current) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return;
|
||||
scrolledToHashRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
|
||||
});
|
||||
}, [loading, feed.length]);
|
||||
|
||||
if (missing) notFound();
|
||||
const { feed, setFeed, loading, hasMore, sentinelRef } = usePostScrollFeed(
|
||||
(before, limit) =>
|
||||
profile.feed(handle, { limit, before: before ?? undefined }),
|
||||
);
|
||||
|
||||
// When the owner views their own profile here and hides a post, drop it
|
||||
// from the feed immediately (it moves to their Hidden tab). No-op for
|
||||
// other viewers, who never see the Hide control.
|
||||
function onHiddenChange(id: string, hidden: boolean) {
|
||||
if (hidden) setFeed((rows) => rows.filter((p) => p.id !== id));
|
||||
if (hidden) setFeed((rows: Post[]) => rows.filter((p) => p.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl w-full mx-auto space-y-6">
|
||||
{loading ? (
|
||||
{loading && feed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : feed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No posts yet.</p>
|
||||
@@ -81,6 +49,12 @@ export function ScrollFeed({
|
||||
<PostScrollCard key={p.id} post={p} onHiddenChange={onHiddenChange} />
|
||||
))
|
||||
)}
|
||||
|
||||
{hasMore && feed.length > 0 ? (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,53 +4,34 @@
|
||||
* /u/me/posts — the owner's Instagram-style scroll feed.
|
||||
*
|
||||
* Reached by tapping a thumbnail from the /u/me Posts grid. The clicked
|
||||
* post's id arrives as a `#post-<id>` hash, and `scroll-mt-*` on each
|
||||
* card spaces it below the sticky header so the anchor lands cleanly.
|
||||
* post's id arrives as a `#post-<id>` hash; usePostScrollFeed fetches that
|
||||
* post directly so it lands at the top instantly (even for old posts), then
|
||||
* keyset-pages older posts beneath it as you scroll.
|
||||
*
|
||||
* Owner controls (Delete + Update background) are mounted per-card.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { PostScrollCard } from "@/components/social/post/post-scroll-card";
|
||||
import { usePostScrollFeed } from "@/components/social/post/use-post-scroll-feed";
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { posts, SocialError, type Post } from "@/lib/social";
|
||||
|
||||
export default function MyPostsFeedPage() {
|
||||
const { user } = useAuth();
|
||||
const [feed, setFeed] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Defer the feed (which calls the authed /posts/mine) until we have a user.
|
||||
if (!user) return null;
|
||||
return <OwnerScrollFeed />;
|
||||
}
|
||||
|
||||
function OwnerScrollFeed() {
|
||||
const { feed, setFeed, loading, hasMore, error, setError, sentinelRef } =
|
||||
usePostScrollFeed((before, limit) =>
|
||||
posts.mine({ limit, before: before ?? undefined }),
|
||||
);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const scrolledToHashRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
posts
|
||||
.mine()
|
||||
.then((rows) => setFeed(rows ?? []))
|
||||
.catch((err) =>
|
||||
setError(
|
||||
err instanceof SocialError ? err.message : "Could not load posts.",
|
||||
),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [user]);
|
||||
|
||||
// Hash anchor: when the feed mounts, jump to the post identified in the
|
||||
// URL hash. Defer one tick so the cards have laid out and `scroll-mt-*`
|
||||
// can position the anchor below the sticky header.
|
||||
useEffect(() => {
|
||||
if (loading || feed.length === 0 || scrolledToHashRef.current) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return;
|
||||
scrolledToHashRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(hash.slice(1));
|
||||
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
|
||||
});
|
||||
}, [loading, feed.length]);
|
||||
|
||||
async function performDelete() {
|
||||
const id = pendingDelete;
|
||||
@@ -79,13 +60,11 @@ export default function MyPostsFeedPage() {
|
||||
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">
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : loading ? (
|
||||
) : loading && feed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : feed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No posts yet.</p>
|
||||
@@ -101,6 +80,12 @@ export default function MyPostsFeedPage() {
|
||||
))
|
||||
)}
|
||||
|
||||
{hasMore && feed.length > 0 ? (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!pendingDelete}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* usePostScrollFeed — anchor-first, keyset-paginated loader for the
|
||||
* Instagram-style profile scroll feeds (/u/me/posts and /u/[handle]/posts).
|
||||
*
|
||||
* Why: the grid thumbnails link to `…/posts#post-<id>`. Loading the whole
|
||||
* timeline and then scrolling to the hash fails for old posts (they aren't
|
||||
* loaded yet). Instead we:
|
||||
* • read `#post-<id>` and fetch THAT post directly (posts.get) so it renders
|
||||
* at the top instantly, then page the posts older than it;
|
||||
* • with no hash, start from the newest page;
|
||||
* • load older pages as a bottom sentinel nears the viewport (dedupe by id,
|
||||
* stop when a page is short) — the same idiom as the home feed.
|
||||
*
|
||||
* `fetchPage(before, limit)` is supplied by the caller so the same hook serves
|
||||
* the owner feed (posts.mine) and the public feed (profile.feed). `setFeed` is
|
||||
* returned so the owner page can optimistically prune on delete/hide.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { posts as postsApi, type FeedCursor, type Post } from "@/lib/social";
|
||||
|
||||
const PAGE = 10;
|
||||
|
||||
type FetchPage = (before: FeedCursor | null, limit: number) => Promise<Post[]>;
|
||||
|
||||
const cursorOf = (p: Post): FeedCursor => ({
|
||||
created_at: p.created_at,
|
||||
id: p.id,
|
||||
});
|
||||
|
||||
export function usePostScrollFeed(fetchPage: FetchPage) {
|
||||
const [feed, setFeed] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
const startedRef = useRef(false);
|
||||
const cursorRef = useRef<FeedCursor | null>(null);
|
||||
const anchorIdRef = useRef<string | null>(null);
|
||||
|
||||
// Append the next older page, keyed off the last loaded post.
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (fetchingRef.current || !hasMore) return;
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const rows = (await fetchPage(cursorRef.current, PAGE)) ?? [];
|
||||
setFeed((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
const merged = [...prev, ...rows.filter((p) => !seen.has(p.id))];
|
||||
if (merged.length > 0) cursorRef.current = cursorOf(merged[merged.length - 1]);
|
||||
return merged;
|
||||
});
|
||||
if (rows.length < PAGE) setHasMore(false);
|
||||
} catch {
|
||||
setHasMore(false);
|
||||
setError((e) => e ?? "Could not load posts.");
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchPage, hasMore]);
|
||||
|
||||
// First load: anchor-first when arriving with #post-<id>, else newest page.
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
const hash =
|
||||
typeof window !== "undefined" ? window.location.hash : "";
|
||||
const anchorId = hash.startsWith("#post-") ? hash.slice("#post-".length) : "";
|
||||
|
||||
(async () => {
|
||||
if (anchorId) {
|
||||
try {
|
||||
const anchor = await postsApi.get(anchorId);
|
||||
if (anchor) {
|
||||
anchorIdRef.current = anchor.id;
|
||||
cursorRef.current = cursorOf(anchor);
|
||||
setFeed([anchor]);
|
||||
setLoading(false);
|
||||
await loadOlder(); // posts older than the anchor
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Anchor missing/deleted — fall through to the newest page.
|
||||
}
|
||||
}
|
||||
await loadOlder();
|
||||
})();
|
||||
}, [loadOlder]);
|
||||
|
||||
// Scroll the anchored post into view once it's in the DOM.
|
||||
useEffect(() => {
|
||||
const id = anchorIdRef.current;
|
||||
if (!id || feed.length === 0) return;
|
||||
anchorIdRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(`post-${id}`);
|
||||
if (el) el.scrollIntoView({ block: "start", behavior: "auto" });
|
||||
});
|
||||
}, [feed.length]);
|
||||
|
||||
// Infinite scroll: load older as the sentinel nears the viewport.
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasMore) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) void loadOlder();
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadOlder, hasMore]);
|
||||
|
||||
return { feed, setFeed, loading, hasMore, error, setError, sentinelRef };
|
||||
}
|
||||
+27
-8
@@ -206,6 +206,22 @@ export type Feedback = {
|
||||
|
||||
// ─── Posts ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Keyset cursor for the scroll feed — the last seen post's sort key. */
|
||||
export type FeedCursor = { created_at: string; id: string };
|
||||
|
||||
/** Paging options for a scroll feed: a keyset `before` cursor (preferred) or
|
||||
* a plain `limit` for the first/grid page. */
|
||||
export type FeedPageParams = { limit?: number; before?: FeedCursor };
|
||||
|
||||
function feedPageQuery({ limit = 20, before }: FeedPageParams): string {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (before) {
|
||||
qs.set("before", before.created_at);
|
||||
qs.set("before_id", before.id);
|
||||
}
|
||||
return `?${qs}`;
|
||||
}
|
||||
|
||||
export const posts = {
|
||||
create(input: {
|
||||
caption: string;
|
||||
@@ -271,8 +287,10 @@ export const posts = {
|
||||
body: JSON.stringify({ image_urls }),
|
||||
});
|
||||
},
|
||||
mine() {
|
||||
return call<Post[]>("/posts/mine");
|
||||
/** The caller's own posts (scroll feed). With `before` it keyset-pages
|
||||
* posts older than that cursor; without it, the newest page. */
|
||||
mine(opts: FeedPageParams = {}) {
|
||||
return call<Post[]>(`/posts/mine${feedPageQuery(opts)}`);
|
||||
},
|
||||
/** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */
|
||||
hidden() {
|
||||
@@ -549,12 +567,13 @@ export const profile = {
|
||||
get(handle: string) {
|
||||
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
||||
},
|
||||
feed(handle: string, limit = 20, offset = 0) {
|
||||
const qs = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
|
||||
/** A user's public scroll feed. With `before` it keyset-pages posts older
|
||||
* than that cursor (infinite scroll); otherwise the newest page. The grid
|
||||
* still passes plain `limit`. */
|
||||
feed(handle: string, opts: FeedPageParams = {}) {
|
||||
return call<Post[]>(
|
||||
`/u/${encodeURIComponent(handle)}/feed${feedPageQuery(opts)}`,
|
||||
);
|
||||
},
|
||||
/** Posts that tag this user (the Mentions tab), newest first. */
|
||||
mentionsFeed(handle: string, limit = 30, offset = 0) {
|
||||
|
||||
Reference in New Issue
Block a user