fix: user post feed view

This commit is contained in:
gosangam
2026-06-21 18:35:48 +05:30
parent 25db9d0eee
commit 5679f7a639
5 changed files with 195 additions and 95 deletions
+1 -1
View File
@@ -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 ?? []);
})
+22 -48
View File
@@ -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>
);
}
+23 -38
View File
@@ -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
View File
@@ -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) {