Files
instaarchive-viewer/src/components/PostFeed.tsx
T
ergosteurandClaude Opus 5 9173504190
Docker Build and Publish / build-and-push (push) Failing after 9s
feat: mobile opens posts as a scrolling feed instead of a modal
Tapping a post on a phone now opens a real feed page — header, media, actions,
caption, next post peeking in below — scrolled with the browser's own vertical
scrolling rather than swipe gestures. Desktop keeps the modal, where a centred
sheet with side arrows suits a pointer.

Only a window of posts is mounted: a profile here holds up to 1129 posts and
mounting them all would mean as many full-size images. The window grows in both
directions as you scroll. Growing upwards shifts everything below it, so the
scroll offset is corrected in the same frame, before paint — measured against
the real archive, an anchored post moves exactly one screen per scroll with no
jump.

Only the post crossing the viewport centre plays its video; the rest stay
paused, so a feed of reels doesn't play ten at once. The URL tracks that same
post, so scrolling updates /<archive>/p/<shortcode>/ the way Instagram does,
and the back button returns to the grid with its scroll position intact.

Feed video sizes to the container width rather than its intrinsic size: a
<video> reports 300x150 until metadata loads, which made it render narrow and
then jump to full width. It also gets a taller height ceiling than the modal so
ordinary portrait media fills the width instead of sitting in side bars.

The carousel is extracted into a shared MediaCarousel used by both surfaces, so
horizontal paging behaves identically; touch-action keeps vertical scrolling
passing through to the feed. PostModal loses its now-dead mobile swipe branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uBWhwV3wFQ5MBCcMHHem7
2026-08-14 10:23:33 -04:00

151 lines
5.7 KiB
TypeScript

import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Post } from '../types';
import { FeedPost } from './FeedPost';
interface PostFeedProps {
posts: Post[];
/** Post the feed should open at. */
initialPostId: string;
profilePic: string | null;
onClose: () => void;
/** Fires as the post crossing the viewport centre changes. */
onActivePostChange: (post: Post) => void;
title?: string;
}
/** Posts added each time the feed grows in either direction. */
const BATCH = 6;
/** Render this many ahead of the entry point so the first scroll is smooth. */
const LOOKAHEAD = 3;
/**
* The mobile post view: a real scrolling feed, not a modal.
*
* Only a window of posts around the entry point is mounted — a profile can hold
* thousands, and mounting them all would mean thousands of full-size images.
* The window grows in both directions as you scroll; growing *upwards* shifts
* everything below it, so the scroll position is corrected in the same frame to
* keep the content under your thumb still.
*/
export const PostFeed: React.FC<PostFeedProps> = ({
posts, initialPostId, profilePic, onClose, onActivePostChange, title,
}) => {
const initialIndex = useMemo(() => {
const found = posts.findIndex(p => p.id === initialPostId);
return found === -1 ? 0 : found;
}, [posts, initialPostId]);
const [range, setRange] = useState(() => ({
start: initialIndex,
end: Math.min(posts.length, initialIndex + LOOKAHEAD + 1),
}));
const [activeId, setActiveId] = useState(initialPostId);
const scrollRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);
/** Distance from the bottom of the content, captured before a prepend. */
const anchorRef = useRef<number | null>(null);
const visible = posts.slice(range.start, range.end);
const extendDown = useCallback(() => {
setRange(r => (r.end >= posts.length ? r : { ...r, end: Math.min(posts.length, r.end + BATCH) }));
}, [posts.length]);
const extendUp = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setRange(r => {
if (r.start === 0) return r;
// Measure from the bottom: prepending changes scrollHeight, but the
// distance between our position and the end of the content does not.
anchorRef.current = el.scrollHeight - el.scrollTop;
return { ...r, start: Math.max(0, r.start - BATCH) };
});
}, []);
// Restore the scroll position in the same frame the prepended posts appear,
// before the browser paints, so nothing visibly jumps.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && anchorRef.current !== null) {
el.scrollTop = el.scrollHeight - anchorRef.current;
anchorRef.current = null;
}
}, [range.start]);
// Grow the window when either end comes into view.
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (entry.target === bottomSentinelRef.current) extendDown();
if (entry.target === topSentinelRef.current) extendUp();
}
}, { root, rootMargin: '600px 0px' });
if (topSentinelRef.current) observer.observe(topSentinelRef.current);
if (bottomSentinelRef.current) observer.observe(bottomSentinelRef.current);
return () => observer.disconnect();
}, [extendDown, extendUp]);
// Track the post crossing the viewport centre. The negative margins collapse
// the root to a thin band, so exactly one post qualifies at a time.
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const id = (entry.target as HTMLElement).dataset.postId;
if (id) setActiveId(id);
}
}, { root, rootMargin: '-45% 0px -45% 0px', threshold: 0 });
root.querySelectorAll('[data-post-id]').forEach(el => observer.observe(el));
return () => observer.disconnect();
}, [visible.length, range.start]);
useEffect(() => {
const post = posts.find(p => p.id === activeId);
if (post) onActivePostChange(post);
}, [activeId, posts, onActivePostChange]);
// Escape closes, matching the modal it replaces.
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 bg-white flex flex-col">
<header className="flex items-center gap-3 px-2 h-12 border-b border-gray-200 bg-white/95 backdrop-blur-md shrink-0">
<button onClick={onClose} aria-label="Back" className="p-2 -ml-1 active:opacity-60">
<ChevronLeft size={24} />
</button>
<span className="font-semibold text-base">{title ?? 'Posts'}</span>
</header>
<div ref={scrollRef} className="flex-1 overflow-y-auto overscroll-contain">
<div ref={topSentinelRef} aria-hidden />
{visible.map(post => (
<div key={post.id} data-post-id={post.id}>
<FeedPost post={post} profilePic={profilePic} paused={post.id !== activeId} />
</div>
))}
<div ref={bottomSentinelRef} aria-hidden />
{range.end >= posts.length && (
<div className="py-10 text-center text-xs uppercase tracking-widest text-gray-400">
End of {title?.toLowerCase() ?? 'posts'}
</div>
)}
</div>
</div>
);
};