Docker Build and Publish / build-and-push (push) Failing after 9s
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
151 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
};
|