xrdwb17xu: Add the full-screen snap feed with media and quote slides on phones

This commit is contained in:
2026-10-20 08:21:28 -06:00
parent 25fd360a2b
commit bf5e0ed475
12 changed files with 815 additions and 61 deletions
+21 -2
View File
@@ -1,10 +1,11 @@
"use client";
import type { ReactNode } from "react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Person } from "@/lib/auth-client";
import { useTheme } from "@/lib/use-theme";
import { HexAvatar } from "./HexAvatar";
import { Logo } from "./Logo";
import { CompactLayoutContext, isCompactWidth } from "./layout-context";
const iconProps = {
viewBox: "0 0 24 24",
@@ -73,8 +74,25 @@ export interface AppShellProps {
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
export function AppShell({ person, search, feed, discover }: AppShellProps) {
const root = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
// The CSS switches layout with a container query on .app; the feed also
// needs to know, because the phone feed is different markup (full-screen slides).
useLayoutEffect(() => {
const el = root.current;
if (!el) return;
const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width));
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div className="app">
<CompactLayoutContext.Provider value={compact}>
<div className="app" ref={root} data-layout={compact ? "compact" : "wide"}>
<main className="bento">
<div className="bento-col bento-left">
<AccountPanel person={person} />
@@ -86,5 +104,6 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
</aside>
</main>
</div>
</CompactLayoutContext.Provider>
);
}
+6 -1
View File
@@ -8,6 +8,7 @@ import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import type { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
import { useCompactLayout } from "./layout-context";
const POLL_MS = 45_000;
@@ -44,6 +45,9 @@ export function Feed({
const list = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
// The list element changes between the panel and the phone reel; the
// infinite-scroll observer re-attaches to the new one.
const compact = useCompactLayout();
useEffect(() => {
shown.current = new Set(posts.map((p) => p.key));
@@ -117,7 +121,7 @@ export function Feed({
);
io.observe(el);
return () => io.disconnect();
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]);
// Real-time: poll the first page while the tab is visible.
useEffect(() => {
@@ -276,6 +280,7 @@ export function Feed({
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
hasMore={!!nextCursor}
sentinelRef={sentinel}
listRef={list}
/>
+11
View File
@@ -1,8 +1,12 @@
"use client";
import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer";
import { GrowButton, IconButton } from "./Buttons";
import { PostCard } from "./PostCard";
import { useCompactLayout } from "./layout-context";
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
export type FeedStatus = "loading" | "ready" | "error";
@@ -33,6 +37,10 @@ export interface FeedViewProps {
/** The scrolling feed list; the page itself never scrolls. */
listRef?: Ref<HTMLDivElement>;
now?: number;
/** Another page can load; the phone feed ends with a loading slide. */
hasMore?: boolean;
/** Phone layout only: the slide on screen changed (for the dock). */
onCurrentChange?: (slide: CurrentSlide) => void;
}
function Spinner() {
@@ -104,6 +112,9 @@ function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { chi
}
export function FeedView(p: FeedViewProps) {
// At 760px and below the feed is the full-screen snap reel instead of the panel.
const compact = useCompactLayout();
if (compact) return <MobileFeed {...p} />;
if (p.status === "loading") {
return (
<FeedPanel onRefresh={p.onRefresh} busy>
+281
View File
@@ -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>
);
}
+68 -52
View File
@@ -59,7 +59,7 @@ function TrashIcon() {
);
}
function PlayIcon() {
export function PlayIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
@@ -91,13 +91,21 @@ function PostVideo({ media, author }: { media: CardMedia; author: string }) {
);
}
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
export interface PostMenuProps {
post: CardPost;
onDelete?: (post: CardPost) => void;
deleteBusy?: boolean;
/** Extra class on the wrapper, e.g. the mobile slide's frosted position. */
className?: string;
/** Class for the "..." button; defaults to the card's button. */
buttonClassName?: string;
}
/** The own-post "..." menu with the delete confirm. Shared by the card and the mobile slide. */
export function PostMenu({ post, onDelete, deleteBusy, className, buttonClassName = "post-menu-btn" }: PostMenuProps) {
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
const [beat, setBeat] = useState(false);
const menuBox = useRef<HTMLDivElement>(null);
const own = !!(canDelete && onDelete);
useEffect(() => {
if (!menu) return;
@@ -112,6 +120,60 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
};
}, [menu]);
return (
<div className={className ? `post-menu ${className}` : "post-menu"} ref={menuBox}>
<button
type="button"
className={menu ? `${buttonClassName} ${buttonClassName}-open` : buttonClassName}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{menu ? (
<div className="post-pop" role="menu">
{confirming ? (
<div className="post-ask">
<p className="post-ask-title">Delete this post?</p>
<p className="post-ask-sub">This cannot be undone.</p>
<div className="post-ask-row">
<SweepButton
label="Cancel"
role="menuitem"
onClick={() => { setConfirming(false); setMenu(false); }}
/>
<IconButton
label="Delete"
role="menuitem"
icon={<TrashIcon />}
size="sm"
disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }}
/>
</div>
</div>
) : (
<button type="button" role="menuitem" className="post-mi post-mi-danger" onClick={() => setConfirming(true)}>
<TrashIcon />
Delete post
</button>
)}
</div>
) : null}
</div>
);
}
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [beat, setBeat] = useState(false);
const own = !!(canDelete && onDelete);
return (
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
<header className="post-head">
@@ -123,53 +185,7 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{own ? (
<div className="post-menu" ref={menuBox}>
<button
type="button"
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{menu ? (
<div className="post-pop" role="menu">
{confirming ? (
<div className="post-ask">
<p className="post-ask-title">Delete this post?</p>
<p className="post-ask-sub">This cannot be undone.</p>
<div className="post-ask-row">
<SweepButton
label="Cancel"
role="menuitem"
onClick={() => { setConfirming(false); setMenu(false); }}
/>
<IconButton
label="Delete"
role="menuitem"
icon={<TrashIcon />}
size="sm"
disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }}
/>
</div>
</div>
) : (
<button type="button" role="menuitem" className="post-mi post-mi-danger" onClick={() => setConfirming(true)}>
<TrashIcon />
Delete post
</button>
)}
</div>
) : null}
</div>
) : null}
{own ? <PostMenu post={post} onDelete={onDelete} deleteBusy={deleteBusy} /> : null}
</header>
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { createContext, useContext } from "react";
/** Widest `.app` width that gets the phone layout (spec "Mobile layout", ADR 0001 section 3). */
export const COMPACT_MAX_WIDTH = 760;
/** True when `.app` is 760px wide or less. Matches `@container app (max-width: 760px)`. */
export function isCompactWidth(width: number): boolean {
return width > 0 && width <= COMPACT_MAX_WIDTH;
}
/**
* Whether the signed-in shell is in the phone layout. AppShell measures `.app`
* and provides it; outside the shell (tests, other pages) it is false, so the
* desktop markup renders.
*/
export const CompactLayoutContext = createContext(false);
export function useCompactLayout(): boolean {
return useContext(CompactLayoutContext);
}