rznb9xaih: Add double-tap to like and the featured experts slide to the phone feed
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user