From bbd84f38a058ae2dda5911b4dc11ffa9ec8ca561 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Tue, 20 Oct 2026 08:31:04 -0600 Subject: [PATCH] rznb9xaih: Add double-tap to like and the featured experts slide to the phone feed --- src/components/App.tsx | 14 +++- src/components/DiscoverRails.tsx | 10 ++- src/components/FeaturedCarousel.tsx | 24 +++--- src/components/Feed.tsx | 5 ++ src/components/FeedView.tsx | 3 + src/components/MobileFeed.tsx | 113 +++++++++++++++++++++++++--- src/dev/FeedPreview.tsx | 1 + src/styles/mobile.css | 55 ++++++++++++++ tests/mobile-feed.test.ts | 44 ++++++++++- 9 files changed, 243 insertions(+), 26 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 1ce4491..0084213 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 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 ( } - feed={ setState({ kind: "out" })} />} - discover={} + feed={} + discover={} /> ); } diff --git a/src/components/DiscoverRails.tsx b/src/components/DiscoverRails.tsx index cc931a5..c45b8e3 100644 --- a/src/components/DiscoverRails.tsx +++ b/src/components/DiscoverRails.tsx @@ -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(null); useEffect(() => { let live = true; fetchDiscover().then((d) => { if (live) setData(d); }, () => {}); return () => { live = false; }; }, []); - return ; + return data; +} + +export function DiscoverRails() { + return ; } diff --git a/src/components/FeaturedCarousel.tsx b/src/components/FeaturedCarousel.tsx index 3f3ea72..d44838b 100644 --- a/src/components/FeaturedCarousel.tsx +++ b/src/components/FeaturedCarousel.tsx @@ -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 (
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} > -
- - {`${current + 1} / ${count}`} -
+ {bare ? null : ( +
+ + {`${current + 1} / ${count}`} +
+ )}
{experts.map((e, i) => { const slot = featuredSlot(i, current, count); diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 7d359f6..e311479 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -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([]); const [status, setStatus] = useState("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} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 0a2fbc6..f3dfdba 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -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; } diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index 56de67d..36020c5 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -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(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) => { + 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 (
setPlaying(true)} onPauseCapture={() => setPlaying(false)} @@ -143,6 +189,11 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;

{post.text}

)} + {burst ? ( + + ) : null} {own ? ( ) : null} @@ -164,6 +215,37 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number; ); } +function ChevronDown() { + return ( + + ); +} + +/** 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 ( +
+ Featured experts +

Ask the people everyone is talking about

+
+ +
+ + + Keep scrolling + +
+ ); +} + function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref }) { return (
@@ -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) {
) : null} {p.status === "ready" - ? p.posts.map((post, i) => ) + ? p.posts.flatMap((post, i) => { + const node = ; + return i === featuredAt - 1 + ? [node, ] + : [node]; + }) : null} {p.status === "ready" && p.loadMoreFailed ? ( -
+

The feed did not load.

) : null} {showLoad ? ( ) : null} diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx index 3ef5020..364de9a 100644 --- a/src/dev/FeedPreview.tsx +++ b/src/dev/FeedPreview.tsx @@ -125,6 +125,7 @@ export function FeedPreview() { status="ready" loadingMore={loadingMore || paging} hasMore={hasMore} + featuredExperts={FIXTURE_DISCOVER.featuredExperts} sentinelRef={sentinel} listRef={listRef} loadMoreFailed={false} diff --git a/src/styles/mobile.css b/src/styles/mobile.css index a9537ee..0584934 100644 --- a/src/styles/mobile.css +++ b/src/styles/mobile.css @@ -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; } } diff --git a/tests/mobile-feed.test.ts b/tests/mobile-feed.test.ts index e0f5179..f007e86 100644 --- a/tests/mobile-feed.test.ts +++ b/tests/mobile-feed.test.ts @@ -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"); + }); +});