fix: user post feed view
This commit is contained in:
@@ -30,7 +30,7 @@ export function PostsGrid({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
profile
|
profile
|
||||||
.feed(handle, 30)
|
.feed(handle, { limit: 30 })
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (!cancelled) setFeed(rows ?? []);
|
if (!cancelled) setFeed(rows ?? []);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,19 +5,20 @@
|
|||||||
*
|
*
|
||||||
* Split out from page.tsx so the page itself can be a server component
|
* Split out from page.tsx so the page itself can be a server component
|
||||||
* exporting `generateMetadata` for the OG card. The layout owns the
|
* exporting `generateMetadata` for the OG card. The layout owns the
|
||||||
* ProfileHeader + tabs + outer container; this component only renders
|
* ProfileHeader + tabs + outer container (and 404s for unknown handles);
|
||||||
* the inner scroll column (the Instagram-style feed of full posts).
|
* this component only renders the inner scroll column.
|
||||||
*
|
*
|
||||||
* Hash-anchor behaviour: when arriving with `#post-<id>`, we scroll to
|
* Anchor-first + keyset infinite scroll via usePostScrollFeed: arriving with
|
||||||
* that post once the feed has loaded. Used by the SharePostSheet flow
|
* `#post-<id>` (from a grid thumbnail or a shared link) fetches that post
|
||||||
* — share short URL resolves to /u/<handle>/posts#post-<id>, recipient
|
* directly so it lands at the top instantly — even for very old posts — then
|
||||||
* lands here, and we scroll to the post that was actually shared.
|
* pages older posts beneath it as you scroll.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { use, useEffect, useRef, useState } from "react";
|
import { use } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { Loader2 } from "lucide-react";
|
||||||
import { profile, SocialError, type Post } from "@/lib/social";
|
import { profile, type Post } from "@/lib/social";
|
||||||
import { PostScrollCard } from "@/components/social/post/post-scroll-card";
|
import { PostScrollCard } from "@/components/social/post/post-scroll-card";
|
||||||
|
import { usePostScrollFeed } from "@/components/social/post/use-post-scroll-feed";
|
||||||
|
|
||||||
export function ScrollFeed({
|
export function ScrollFeed({
|
||||||
params,
|
params,
|
||||||
@@ -25,54 +26,21 @@ export function ScrollFeed({
|
|||||||
params: Promise<{ handle: string }>;
|
params: Promise<{ handle: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { handle } = use(params);
|
const { handle } = use(params);
|
||||||
const [feed, setFeed] = useState<Post[]>([]);
|
const { feed, setFeed, loading, hasMore, sentinelRef } = usePostScrollFeed(
|
||||||
const [loading, setLoading] = useState(true);
|
(before, limit) =>
|
||||||
const [missing, setMissing] = useState(false);
|
profile.feed(handle, { limit, before: before ?? undefined }),
|
||||||
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();
|
|
||||||
|
|
||||||
// When the owner views their own profile here and hides a post, drop it
|
// 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
|
// from the feed immediately (it moves to their Hidden tab). No-op for
|
||||||
// other viewers, who never see the Hide control.
|
// other viewers, who never see the Hide control.
|
||||||
function onHiddenChange(id: string, hidden: boolean) {
|
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 (
|
return (
|
||||||
<div className="max-w-xl w-full mx-auto space-y-6">
|
<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>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
) : feed.length === 0 ? (
|
) : feed.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No posts yet.</p>
|
<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} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,53 +4,34 @@
|
|||||||
* /u/me/posts — the owner's Instagram-style scroll feed.
|
* /u/me/posts — the owner's Instagram-style scroll feed.
|
||||||
*
|
*
|
||||||
* Reached by tapping a thumbnail from the /u/me Posts grid. The clicked
|
* 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
|
* post's id arrives as a `#post-<id>` hash; usePostScrollFeed fetches that
|
||||||
* card spaces it below the sticky header so the anchor lands cleanly.
|
* 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.
|
* 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 { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { PostScrollCard } from "@/components/social/post/post-scroll-card";
|
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 { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||||
import { posts, SocialError, type Post } from "@/lib/social";
|
import { posts, SocialError, type Post } from "@/lib/social";
|
||||||
|
|
||||||
export default function MyPostsFeedPage() {
|
export default function MyPostsFeedPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [feed, setFeed] = useState<Post[]>([]);
|
// Defer the feed (which calls the authed /posts/mine) until we have a user.
|
||||||
const [loading, setLoading] = useState(true);
|
if (!user) return null;
|
||||||
const [error, setError] = useState<string | null>(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 [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() {
|
async function performDelete() {
|
||||||
const id = pendingDelete;
|
const id = pendingDelete;
|
||||||
@@ -79,13 +60,11 @@ export default function MyPostsFeedPage() {
|
|||||||
if (hidden) setFeed((rows) => rows.filter((p) => p.id !== id));
|
if (hidden) setFeed((rows) => rows.filter((p) => p.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl w-full mx-auto space-y-6">
|
<div className="max-w-xl w-full mx-auto space-y-6">
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
) : loading ? (
|
) : loading && feed.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
) : feed.length === 0 ? (
|
) : feed.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No posts yet.</p>
|
<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
|
<ConfirmModal
|
||||||
open={!!pendingDelete}
|
open={!!pendingDelete}
|
||||||
onClose={() => setPendingDelete(null)}
|
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 ───────────────────────────────────────────────────────────────
|
// ─── 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 = {
|
export const posts = {
|
||||||
create(input: {
|
create(input: {
|
||||||
caption: string;
|
caption: string;
|
||||||
@@ -271,8 +287,10 @@ export const posts = {
|
|||||||
body: JSON.stringify({ image_urls }),
|
body: JSON.stringify({ image_urls }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mine() {
|
/** The caller's own posts (scroll feed). With `before` it keyset-pages
|
||||||
return call<Post[]>("/posts/mine");
|
* 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. */
|
/** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */
|
||||||
hidden() {
|
hidden() {
|
||||||
@@ -549,12 +567,13 @@ export const profile = {
|
|||||||
get(handle: string) {
|
get(handle: string) {
|
||||||
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
return call<PublicProfile>(`/u/${encodeURIComponent(handle)}`);
|
||||||
},
|
},
|
||||||
feed(handle: string, limit = 20, offset = 0) {
|
/** A user's public scroll feed. With `before` it keyset-pages posts older
|
||||||
const qs = new URLSearchParams({
|
* than that cursor (infinite scroll); otherwise the newest page. The grid
|
||||||
limit: String(limit),
|
* still passes plain `limit`. */
|
||||||
offset: String(offset),
|
feed(handle: string, opts: FeedPageParams = {}) {
|
||||||
});
|
return call<Post[]>(
|
||||||
return call<Post[]>(`/u/${encodeURIComponent(handle)}/feed?${qs}`);
|
`/u/${encodeURIComponent(handle)}/feed${feedPageQuery(opts)}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
/** Posts that tag this user (the Mentions tab), newest first. */
|
/** Posts that tag this user (the Mentions tab), newest first. */
|
||||||
mentionsFeed(handle: string, limit = 30, offset = 0) {
|
mentionsFeed(handle: string, limit = 30, offset = 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user