rznb9xaih: Add double-tap to like and the featured experts slide to the phone feed

This commit is contained in:
2026-10-20 08:31:04 -06:00
parent b97dddfc4c
commit bbd84f38a0
9 changed files with 243 additions and 26 deletions
+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}