xrdwb17xu: Add the full-screen snap feed with media and quote slides on phones
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type Ref } from "react";
|
||||
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||
import { GrowButton } from "./Buttons";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { PlayIcon, PostMenu } from "./PostCard";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
import type { FeedViewProps } from "./FeedView";
|
||||
|
||||
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
|
||||
export interface CurrentSlide {
|
||||
index: number;
|
||||
post: CardPost | null;
|
||||
}
|
||||
|
||||
/** Share of a slide that must be on screen before it counts as the current one. */
|
||||
const CURRENT_RATIO = 0.6;
|
||||
|
||||
/** Smallest finger travel, in px, that counts as a swipe to the next or previous slide. */
|
||||
const SWIPE_MIN = 40;
|
||||
/** Wheel steps closer together than this belong to one gesture (trackpad inertia). */
|
||||
const WHEEL_QUIET_MS = 220;
|
||||
/** A wheel gesture moves at most one slide in this time. */
|
||||
const WHEEL_STEP_MS = 520;
|
||||
|
||||
/** The slide one step away from `from` in the direction of `delta`, kept inside the reel. */
|
||||
export function stepTarget(from: number, delta: number, count: number): number {
|
||||
if (!delta || count <= 0) return Math.min(Math.max(from, 0), Math.max(count - 1, 0));
|
||||
return Math.min(Math.max(from + Math.sign(delta), 0), count - 1);
|
||||
}
|
||||
|
||||
/** Index of the slide on screen, given which slides are now mostly visible. Keeps the old one when none is. */
|
||||
export function pickCurrent(previous: number, visible: number[]): number {
|
||||
if (!visible.length) return previous;
|
||||
return visible.includes(previous) ? previous : Math.min(...visible);
|
||||
}
|
||||
|
||||
function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||
if (typeof ref === "function") ref(value);
|
||||
else if (ref) (ref as { current: T | null }).current = value;
|
||||
}
|
||||
|
||||
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const playable = isPlayableVideo(media);
|
||||
if (playable) {
|
||||
const duration = formatDuration(media.durationSeconds);
|
||||
const start = () => {
|
||||
setStarted(true);
|
||||
void box.current?.querySelector("video")?.play().catch(() => {});
|
||||
};
|
||||
return (
|
||||
<div className="slide-media slide-video" ref={box}>
|
||||
<div className="slide-art">
|
||||
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
|
||||
</div>
|
||||
{started ? null : (
|
||||
<>
|
||||
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={start}>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
{duration ? <span className="post-dur slide-dur">{duration}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (media.src) {
|
||||
return (
|
||||
<div className="slide-media">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="slide-art" src={media.src} alt={media.kind === "video" ? "Video thumbnail" : ""} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="slide-media slide-pending" role="img" aria-label="Video is processing">
|
||||
<span>Video is processing</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
|
||||
const media = post.media.find((m) => m.kind === "video" || m.src) ?? null;
|
||||
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
|
||||
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}`;
|
||||
return (
|
||||
<section className={cls} data-index={index} data-testid="feed-slide" aria-label={`Post by ${post.authorName}`}>
|
||||
{media ? (
|
||||
<>
|
||||
<SlideMedia media={media} author={post.authorName} />
|
||||
<div className="slide-scrim-top" aria-hidden="true" />
|
||||
<div className="slide-scrim" aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
<div className="slide-quote-w">
|
||||
<p className="slide-quote">{post.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{own ? (
|
||||
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
||||
) : null}
|
||||
<div className="slide-meta">
|
||||
{p.deleteErrors?.has(post.key) ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
|
||||
<div className="slide-who">
|
||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||
<div>
|
||||
<strong>
|
||||
{post.authorName}
|
||||
{own ? <span className="post-you slide-you">You</span> : null}
|
||||
</strong>
|
||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
|
||||
</div>
|
||||
</div>
|
||||
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
|
||||
return (
|
||||
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
|
||||
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
|
||||
<div className="slide-load-body" role="status" aria-label="Loading more posts">
|
||||
<div className="slide-skel">
|
||||
<div className="slide-skel-head">
|
||||
<i className="slide-skel-hex" />
|
||||
<div className="slide-skel-lines">
|
||||
<i style={{ width: "40%" }} />
|
||||
<i style={{ width: "24%" }} />
|
||||
</div>
|
||||
</div>
|
||||
<i style={{ width: "92%" }} />
|
||||
<i style={{ width: "76%" }} />
|
||||
<i style={{ width: "54%" }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone feed (spec "Mobile layout" > "Full-screen feed"): one post per screen,
|
||||
* snapping. Uses the same posts, delete and paging as the desktop list: the
|
||||
* reel is the list ref (the infinite-scroll root) and the loading slide holds
|
||||
* the sentinel. Reports the slide on screen through onCurrentChange.
|
||||
*/
|
||||
export function MobileFeed(p: FeedViewProps) {
|
||||
const reel = useRef<HTMLDivElement | null>(null);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const onCurrentChange = p.onCurrentChange;
|
||||
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
|
||||
const slideCount = (p.status === "ready" ? p.posts.length : 0) + (showLoad ? 1 : 0);
|
||||
|
||||
// Track the slide on screen. A fresh observer per change picks up new slides.
|
||||
useEffect(() => {
|
||||
const root = reel.current;
|
||||
if (!root || typeof IntersectionObserver === "undefined") return;
|
||||
const ratios = new Map<number, number>();
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio);
|
||||
const visible = [...ratios].filter(([, r]) => r >= CURRENT_RATIO).map(([i]) => i);
|
||||
setCurrent((prev) => pickCurrent(prev, visible));
|
||||
},
|
||||
{ root, threshold: [0, CURRENT_RATIO, 1] },
|
||||
);
|
||||
root.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => io.observe(el));
|
||||
return () => io.disconnect();
|
||||
}, [slideCount]);
|
||||
|
||||
// One wheel notch or one swipe moves exactly one slide. Native snapping alone
|
||||
// sends a short or slow gesture back to the slide it started on.
|
||||
useEffect(() => {
|
||||
const root = reel.current;
|
||||
if (!root) return;
|
||||
const count = () => root.querySelectorAll(":scope > .slide").length;
|
||||
const at = () => Math.round(root.scrollTop / Math.max(root.clientHeight, 1));
|
||||
const go = (to: number) => {
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
root.scrollTo({ top: to * root.clientHeight, behavior: calm ? "auto" : "smooth" });
|
||||
};
|
||||
let lastWheel = 0;
|
||||
let lastStep = 0;
|
||||
let from = 0;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
|
||||
e.preventDefault();
|
||||
const now = performance.now();
|
||||
const fresh = now - lastWheel > WHEEL_QUIET_MS && now - lastStep > WHEEL_STEP_MS;
|
||||
lastWheel = now;
|
||||
if (!fresh || !e.deltaY) return;
|
||||
lastStep = now;
|
||||
go(stepTarget(at(), e.deltaY, count()));
|
||||
};
|
||||
let startY: number | null = null;
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
|
||||
from = at();
|
||||
};
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (startY === null || e.touches.length) return;
|
||||
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
|
||||
startY = null;
|
||||
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
|
||||
};
|
||||
root.addEventListener("wheel", onWheel, { passive: false });
|
||||
root.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
root.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
root.removeEventListener("wheel", onWheel);
|
||||
root.removeEventListener("touchstart", onTouchStart);
|
||||
root.removeEventListener("touchend", onTouchEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the index inside the list when posts are removed.
|
||||
const index = Math.min(current, Math.max(0, slideCount - 1));
|
||||
const currentPost = p.status === "ready" ? p.posts[index] ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
onCurrentChange?.({ index, post: currentPost });
|
||||
}, [index, currentPost, onCurrentChange]);
|
||||
|
||||
// A video keeps playing only while its slide is on screen.
|
||||
useEffect(() => {
|
||||
reel.current?.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => {
|
||||
if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause());
|
||||
});
|
||||
}, [index]);
|
||||
|
||||
return (
|
||||
<section className="reel-feed" aria-label="Home feed">
|
||||
{p.newCount > 0 && p.status === "ready" ? (
|
||||
<div className="reel-pill">
|
||||
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="reel"
|
||||
data-testid="feed-reel"
|
||||
data-current={index}
|
||||
ref={(el) => {
|
||||
reel.current = el;
|
||||
setRef(p.listRef, el);
|
||||
}}
|
||||
>
|
||||
{p.status === "error" ? (
|
||||
<section className="slide slide-message is-cur" data-index={0} role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</section>
|
||||
) : null}
|
||||
{p.status === "ready" && p.posts.length === 0 && !showLoad ? (
|
||||
<section className="slide slide-message is-cur" data-index={0}>
|
||||
<p>No posts yet.</p>
|
||||
</section>
|
||||
) : null}
|
||||
{p.status === "ready"
|
||||
? p.posts.map((post, i) => <PostSlide key={post.key} post={post} index={i} current={i === index} p={p} />)
|
||||
: null}
|
||||
{p.status === "ready" && p.loadMoreFailed ? (
|
||||
<section className="slide slide-message" data-index={p.posts.length} role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
</section>
|
||||
) : null}
|
||||
{showLoad ? (
|
||||
<LoadSlide
|
||||
index={p.status === "ready" ? p.posts.length : 0}
|
||||
current={index === (p.status === "ready" ? p.posts.length : 0)}
|
||||
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user