Feed browser: Mobile immersive feed #4

Merged
calcazar merged 9 commits from feature/feed-browser-mobile-immersive-feed into develop 2026-10-20 08:54:40 -06:00
9 changed files with 243 additions and 26 deletions
Showing only changes of commit bbd84f38a0 - Show all commits
+10 -4
View File
@@ -5,7 +5,7 @@ import { loadSession, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { AppShell } from "./AppShell";
import { Feed } from "./Feed";
import { DiscoverRails } from "./DiscoverRails";
import { DiscoverRailsView, useDiscover } from "./DiscoverRails";
import { ExpertSearch } from "./ExpertSearch";
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
@@ -39,12 +39,18 @@ export function App() {
});
}} />;
}
return <SignedInHome person={state.person} onSignedOut={() => setState({ kind: "out" })} />;
}
/** The signed-in shell. Discover loads once and feeds both the right rail and the phone feed. */
function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: () => void }) {
const discover = useDiscover();
return (
<AppShell
person={state.person}
person={person}
search={<ExpertSearch />}
feed={<Feed feedUserId={state.person.feedUserId} person={state.person} onSignedOut={() => setState({ kind: "out" })} />}
discover={<DiscoverRails />}
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} />}
discover={<DiscoverRailsView data={discover} />}
/>
);
}
+7 -3
View File
@@ -16,13 +16,17 @@ export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
);
}
/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
export function DiscoverRails() {
/** Loads /api/discover once. Failure leaves it null; the feed is unaffected. */
export function useDiscover(): DiscoverData | null {
const [data, setData] = useState<DiscoverData | null>(null);
useEffect(() => {
let live = true;
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
return () => { live = false; };
}, []);
return <DiscoverRailsView data={data} />;
return data;
}
export function DiscoverRails() {
return <DiscoverRailsView data={useDiscover()} />;
}
+15 -9
View File
@@ -46,9 +46,11 @@ export interface FeaturedCarouselProps {
experts: DiscoverExpert[];
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
size?: number;
/** "slide": no tile or heading, for the phone featured slide that brings its own title. */
variant?: "panel" | "slide";
}
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
export function FeaturedCarousel({ experts, size = 96, variant = "panel" }: FeaturedCarouselProps) {
const count = experts.length;
const [index, setIndex] = useState(0);
const reduced = useReducedMotion();
@@ -79,23 +81,27 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
const current = index % count;
const cap = experts[current];
const style = { "--cfw": `${size}px` } as CSSProperties;
const bare = variant === "slide";
return (
<section
className="tile panel rail rail-featured featured"
aria-labelledby="featured-title"
className={bare ? "featured featured-bare" : "tile panel rail rail-featured featured"}
aria-labelledby={bare ? undefined : "featured-title"}
aria-label={bare ? "Featured experts" : undefined}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
data-paused={paused ? "true" : undefined}
>
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
<Star />Featured experts
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
{bare ? null : (
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
<Star />Featured experts
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
)}
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
{experts.map((e, i) => {
const slot = featuredSlot(i, current, count);
+5
View File
@@ -6,6 +6,7 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import type { DiscoverExpert } from "@/lib/discover";
import type { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
import { useCompactLayout } from "./layout-context";
@@ -16,11 +17,14 @@ export function Feed({
onSignedOut,
feedUserId,
person,
featuredExperts,
}: {
onSignedOut: () => void;
feedUserId?: string | null;
/** The signed-in person, shown beside the composer. */
person?: ComposerProps["person"];
/** Shown as a slide after the second post on phones. */
featuredExperts?: DiscoverExpert[];
}) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
@@ -280,6 +284,7 @@ export function Feed({
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
featuredExperts={featuredExperts}
hasMore={!!nextCursor}
sentinelRef={sentinel}
listRef={list}
+3
View File
@@ -2,6 +2,7 @@
import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
import type { DiscoverExpert } from "@/lib/discover";
import { Composer, type ComposerProps } from "./Composer";
import { GrowButton, IconButton } from "./Buttons";
import { PostCard } from "./PostCard";
@@ -40,6 +41,8 @@ export interface FeedViewProps {
/** 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). */
/** Phone layout only: experts for the featured slide after the second post. */
featuredExperts?: DiscoverExpert[];
onCurrentChange?: (slide: CurrentSlide) => void;
}
+104 -9
View File
@@ -1,19 +1,47 @@
"use client";
import { useEffect, useRef, useState, type Ref } from "react";
import { useEffect, useRef, useState, type MouseEvent, 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 { FeaturedCarousel } from "./FeaturedCarousel";
import type { DiscoverExpert } from "@/lib/discover";
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" is a real post; "featured" and "load" are not posts (the dock disables like and comment on them). */
kind: "post" | "featured" | "load";
post: CardPost | null;
}
/** The featured experts slide comes after this many posts. */
export const FEATURED_AFTER = 2;
/** Two taps this close together (ms) make a double-tap. */
export const DOUBLE_TAP_MS = 300;
/** How long the heart burst stays on screen (ms). */
export const BURST_MS = 950;
/** True when a tap at `now` follows a tap at `last` closely enough to be a double-tap. */
export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_TAP_MS): boolean {
return last !== null && now - last >= 0 && now - last <= windowMs;
}
/** Taps on controls (buttons, links, video, menus, fields) never count toward a double-tap. */
export function isTapTarget(target: EventTarget | null): boolean {
const el = target as Element | null;
if (!el || typeof el.closest !== "function") return true;
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem']");
}
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
export function featuredSlideIndex(postCount: number, expertCount: number): number {
return expertCount > 0 && postCount >= FEATURED_AFTER ? FEATURED_AFTER : -1;
}
/** Share of a slide that must be on screen before it counts as the current one. */
const CURRENT_RATIO = 0.6;
@@ -122,11 +150,29 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
// While a video plays, the author and text step aside so the native controls stay clear.
const [playing, setPlaying] = useState(false);
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
const lastTap = useRef<number | null>(null);
const [burst, setBurst] = useState(0);
useEffect(() => {
if (!burst) return;
const t = window.setTimeout(() => setBurst(0), BURST_MS);
return () => window.clearTimeout(t);
}, [burst]);
// Double-tap likes (never unlikes) and plays the burst. A tap that scrolls or swipes never reaches click.
const onTap = (e: MouseEvent<HTMLElement>) => {
if (!isTapTarget(e.target)) { lastTap.current = null; return; }
const now = performance.now();
if (!isDoubleTap(lastTap.current, now)) { lastTap.current = now; return; }
lastTap.current = null;
if (!post.isLiked && post.id && p.onToggleLike && !p.likeBusy?.has(post.key)) p.onToggleLike(post);
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setBurst((n) => n + 1);
};
return (
<section
className={cls}
data-index={index}
data-testid="feed-slide"
data-liked={post.isLiked ? "true" : "false"}
onClick={onTap}
aria-label={`Post by ${post.authorName}`}
onPlayCapture={() => setPlaying(true)}
onPauseCapture={() => setPlaying(false)}
@@ -143,6 +189,11 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
<p className="slide-quote">{post.text}</p>
</div>
)}
{burst ? (
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" fill="currentColor" />
</svg>
) : null}
{own ? (
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
) : null}
@@ -164,6 +215,37 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
);
}
function ChevronDown() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/** Not a post: the dock disables like and comment here (data-slide-kind="featured"). */
function FeaturedSlide({ index, current, experts }: { index: number; current: boolean; experts: DiscoverExpert[] }) {
return (
<section
className={`slide slide-featured${current ? " is-cur" : ""}`}
data-index={index}
data-slide-kind="featured"
data-testid="feed-slide-featured"
aria-label="Featured experts"
>
<span className="type-eyebrow slide-feat-eyebrow">Featured experts</span>
<h2 className="type-title type-title-strong slide-feat-title">Ask the people everyone is talking about</h2>
<div className="slide-feat-cf">
<FeaturedCarousel experts={experts} size={156} variant="slide" />
</div>
<span className="slide-hint">
<ChevronDown />
Keep scrolling
</span>
</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">
@@ -197,7 +279,13 @@ export function MobileFeed(p: FeedViewProps) {
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);
const experts = p.featuredExperts ?? [];
const postCount = p.status === "ready" ? p.posts.length : 0;
const featuredAt = p.status === "ready" ? featuredSlideIndex(postCount, experts.length) : -1;
const slideCount = postCount + (featuredAt >= 0 ? 1 : 0) + (showLoad ? 1 : 0);
/** Slide index of post `i`: posts after the featured slide shift down by one. */
const slideOf = (i: number) => (featuredAt >= 0 && i >= featuredAt ? i + 1 : i);
const postAt = (slide: number) => (featuredAt >= 0 && slide === featuredAt ? -1 : featuredAt >= 0 && slide > featuredAt ? slide - 1 : slide);
// Track the slide on screen. A fresh observer per change picks up new slides.
useEffect(() => {
@@ -263,11 +351,13 @@ export function MobileFeed(p: FeedViewProps) {
// 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;
const currentPostIndex = postAt(index);
const currentPost = p.status === "ready" && currentPostIndex >= 0 ? p.posts[currentPostIndex] ?? null : null;
const kind: CurrentSlide["kind"] = currentPost ? "post" : featuredAt >= 0 && index === featuredAt ? "featured" : "load";
useEffect(() => {
onCurrentChange?.({ index, post: currentPost });
}, [index, currentPost, onCurrentChange]);
onCurrentChange?.({ index, kind, post: currentPost });
}, [index, kind, currentPost, onCurrentChange]);
// A video keeps playing only while its slide is on screen.
useEffect(() => {
@@ -304,18 +394,23 @@ export function MobileFeed(p: FeedViewProps) {
</section>
) : null}
{p.status === "ready"
? p.posts.map((post, i) => <PostSlide key={post.key} post={post} index={i} current={i === index} p={p} />)
? p.posts.flatMap((post, i) => {
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} />;
return i === featuredAt - 1
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
: [node];
})
: null}
{p.status === "ready" && p.loadMoreFailed ? (
<section className="slide slide-message" data-index={p.posts.length} role="alert">
<section className="slide slide-message" data-index={slideCount} 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)}
index={slideCount - 1}
current={index === slideCount - 1}
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
/>
) : null}
+1
View File
@@ -125,6 +125,7 @@ export function FeedPreview() {
status="ready"
loadingMore={loadingMore || paging}
hasMore={hasMore}
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
sentinelRef={sentinel}
listRef={listRef}
loadMoreFailed={false}
+55
View File
@@ -203,6 +203,59 @@
}
.slide-menu .post-pop { min-width: 230px; }
/* Double-tap heart burst over a post slide (mockup .burst), crimson accent. */
@keyframes slide-burst {
0% { opacity: 0; transform: scale(.2); }
25% { opacity: 1; transform: scale(1.15); }
60% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.25) translateY(-20px); }
}
.slide-burst {
position: absolute;
left: 50%;
top: 44%;
width: 120px;
height: 120px;
margin: -60px 0 0 -60px;
color: var(--accent);
z-index: 5;
pointer-events: none;
animation: slide-burst .95s cubic-bezier(.2, .9, .3, 1.3) both;
filter: drop-shadow(0 12px 30px color-mix(in oklab, var(--accent), transparent 45%));
}
/* Featured experts slide: not a post. Eyebrow, title, carousel at 156px, hint. */
@keyframes slide-nudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
.slide-featured {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 70px 0 110px;
background: radial-gradient(90% 55% at 50% 46%, color-mix(in oklab, var(--accent), transparent 84%), transparent 70%), var(--bg);
}
.slide-feat-title {
margin: 10px 24px 0;
text-align: center;
font-size: 32px;
line-height: 1.1;
color: var(--ink);
}
.slide-feat-cf { width: 100%; margin-top: 26px; }
.featured-bare { background: none; }
.featured-bare .featured-cap { padding-top: 16px; }
.slide-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 14px;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
color: var(--ink-3);
}
.slide-hint svg { width: 16px; height: 16px; animation: slide-nudge 1.6s ease-in-out infinite; }
/* Loading slide: a skeleton card in the middle while the next page loads. */
.slide-load { background: var(--bg); }
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
@@ -271,6 +324,8 @@
.is-cur .slide-meta,
.is-cur .slide-quote-w,
.slide-skel i,
.slide-burst,
.slide-hint svg,
.reel-pill { animation: none !important; }
.slide-art { transition: none !important; }
}
+43 -1
View File
@@ -3,7 +3,8 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { pickCurrent } from "@/components/MobileFeed";
import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex } from "@/components/MobileFeed";
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
import { toCardPosts } from "@/lib/feed-model";
@@ -129,3 +130,44 @@ describe("video slide before play", () => {
expect(html).toContain('class="post-play slide-play"');
});
});
describe("double-tap detection", () => {
it("needs two taps within 300ms", () => {
expect(isDoubleTap(null, 100)).toBe(false);
expect(isDoubleTap(100, 250)).toBe(true);
expect(isDoubleTap(100, 400)).toBe(true);
expect(isDoubleTap(100, 401)).toBe(false);
});
it("ignores taps on controls", () => {
const el = (html: string) => {
const fake = (match: boolean) => ({ closest: () => (match ? {} : null) });
return html === "slide" ? fake(false) : fake(true);
};
expect(isTapTarget(el("slide") as unknown as EventTarget)).toBe(true);
expect(isTapTarget(el("button") as unknown as EventTarget)).toBe(false);
});
});
describe("featured experts slide", () => {
it("sits after the second post and only with experts", () => {
expect(featuredSlideIndex(3, 5)).toBe(2);
expect(featuredSlideIndex(2, 5)).toBe(2);
expect(featuredSlideIndex(1, 5)).toBe(-1);
expect(featuredSlideIndex(3, 0)).toBe(-1);
});
it("renders the title, eyebrow, hint and a 156px carousel as a non-post slide", () => {
const out = render({ featuredExperts: FIXTURE_DISCOVER.featuredExperts });
expect(out).toContain('data-slide-kind="featured"');
expect(out).toContain("Featured experts");
expect(out).toContain("Ask the people everyone is talking about");
expect(out).toContain("--cfw:156px");
expect(out).toContain("Keep scrolling");
expect(out.match(/data-testid="feed-slide"/g)).toHaveLength(3);
// After the second post, before the third.
expect(out.indexOf("Author i1")).toBeLessThan(out.indexOf("feed-slide-featured"));
expect(out.indexOf("feed-slide-featured")).toBeLessThan(out.indexOf("Author v1"));
});
it("is absent without experts", () => {
expect(render({})).not.toContain("feed-slide-featured");
});
});