Files
frontend-helper/src/components/MobileFeed.tsx
T

544 lines
24 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState, type MouseEvent, type Ref } from "react";
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
import { GrowButton, SweepButton } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { PlayIcon, PostMenu } from "./PostCard";
import { VideoPlayer } from "./VideoPlayer";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { Logo } from "./Logo";
import { useTheme } from "@/lib/use-theme";
import { MobileDock } from "./MobileDock";
import { BottomSheet } from "./BottomSheet";
import { CommentsSection } from "./CommentsSection";
import { Composer } from "./Composer";
import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet";
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;
/** 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;
}
/** Aspect ratio (width / height) at or below which a video fills the slide (4:5 or taller). */
export const COVER_MAX_RATIO = 0.8;
/** "cover" for portrait and near-portrait video, "contain" for wider, "cover" while the size is unknown. */
export function videoFit(width: number, height: number): "cover" | "contain" {
if (!(width > 0) || !(height > 0)) return "cover";
return width / height <= COVER_MAX_RATIO ? "cover" : "contain";
}
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
// Real video size once metadata loads; until then the thumbnail's size decides.
const [videoSize, setVideoSize] = useState<[number, number] | null>(null);
const [thumbSize, setThumbSize] = useState<[number, number] | null>(null);
const playable = isPlayableVideo(media);
useEffect(() => {
const video = box.current?.querySelector("video");
if (!video) return;
const read = () => {
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize([video.videoWidth, video.videoHeight]);
};
read();
video.addEventListener("loadedmetadata", read);
return () => video.removeEventListener("loadedmetadata", read);
}, [playable, media.mp4Url, media.hlsUrl]);
if (playable) {
const size = videoSize ?? thumbSize;
const fit = size ? videoFit(size[0], size[1]) : "cover";
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
};
return (
<div className="slide-media slide-video" data-fit={fit} ref={box}>
{media.src ? (
// The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback.
// eslint-disable-next-line @next/next/no-img-element
<img
className="slide-backdrop"
src={media.src}
alt=""
aria-hidden="true"
onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
/>
) : null}
<div className="slide-art">
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
</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>
);
}
/** Several media items in one slide: a sideways scroll-snap track with a dot per item. */
function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
const track = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const onScroll = () => {
const el = track.current;
if (!el) return;
const next = Math.min(items.length - 1, Math.max(0, Math.round(el.scrollLeft / Math.max(el.clientWidth, 1))));
if (next === active) return;
setActive(next);
// A video stops when it is swiped away.
el.querySelectorAll<HTMLElement>(":scope > .slide-pane").forEach((pane, i) => {
if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause());
});
};
return (
<>
<div className="slide-track" data-testid="slide-track" ref={track} onScroll={onScroll}>
{items.map((m, i) => (
<div className="slide-pane" key={i} aria-label={`Media ${i + 1} of ${items.length}`}>
<SlideMedia media={m} author={author} />
</div>
))}
</div>
<div className="slide-dots" data-testid="slide-dots" aria-hidden="true">
{items.map((_, i) => (
<span key={i} className={`slide-dot${i === active ? " slide-dot-on" : ""}`} />
))}
</div>
</>
);
}
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
const items = post.media.filter((m) => m.kind === "video" || m.src);
const media = items[0] ?? null;
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
// 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)}
onEndedCapture={() => setPlaying(false)}
>
{media ? (
<>
{items.length > 1 ? <MediaTrack items={items} author={post.authorName} /> : <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>
)}
{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}
<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>
);
}
/** Height of the progress rail, as a percent: the slide on screen out of the loaded slides. */
export function railPercent(index: number, count: number): number {
if (count <= 0) return 0;
return Math.round((Math.min(Math.max(index, 0), count - 1) + 1) / count * 100);
}
const topIcon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const;
/** Frosted pills over the feed (spec "Top bar"): logo and wordmark left, theme toggle right. */
function TopBar() {
const { theme, toggleTheme } = useTheme();
const next = theme === "dark" ? "light" : "dark";
return (
<div className="ph-top" data-testid="top-bar">
<div className="ph-brand ph-glass">
<Logo size={24} />
<span className="type-wordmark">MINNECT</span>
</div>
<button type="button" className="ph-theme ph-glass" data-testid="top-theme" aria-label={`Switch to ${next} theme`} onClick={toggleTheme}>
{theme === "dark" ? (
<svg {...topIcon}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
) : (
<svg {...topIcon}><path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" /></svg>
)}
</button>
</div>
);
}
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">
<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);
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
const closeSheet = useCallback(() => setSheet(null), []);
// A successful post closes the composer: publishing ends with no error and an emptied draft.
const wasBusy = useRef(false);
const composerBusy = !!p.composer?.busy;
const composerClear = !!p.composer && !p.composer.error && !p.composer.value && !p.composer.file;
useEffect(() => {
if (wasBusy.current && !composerBusy && composerClear) setSheet((s) => (s?.kind === "composer" ? null : s));
wasBusy.current = composerBusy;
}, [composerBusy, composerClear]);
const onCurrentChange = p.onCurrentChange;
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
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(() => {
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 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";
const onMedia = !!currentPost && currentPost.media.some((m) => m.kind === "video" || m.src);
useEffect(() => {
onCurrentChange?.({ index, kind, post: currentPost });
}, [index, kind, 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" data-on-media={onMedia ? "true" : "false"}>
{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}
<TopBar />
<div className="rail-v" data-testid="feed-rail" aria-hidden="true">
<i style={{ height: `${railPercent(index, slideCount)}%` }} />
</div>
<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>
<SweepButton label="Try again" onClick={p.onRetry} />
</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.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={slideCount} role="alert">
<p>The feed did not load.</p>
<SweepButton label="Try again" onClick={p.onRetry} />
</section>
) : null}
{showLoad ? (
<LoadSlide
index={slideCount - 1}
current={index === slideCount - 1}
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
/>
) : null}
</div>
{p.status !== "error" ? (
<MobileDock
slide={{ index, kind, post: currentPost }}
onToggleLike={p.onToggleLike}
likeBusy={currentPost ? p.likeBusy?.has(currentPost.key) : false}
onOpenComments={(postId) => {
setSheet({ kind: "comments", postId });
p.onOpenComments?.(postId);
}}
onOpenComposer={() => {
setSheet({ kind: "composer" });
p.onOpenComposer?.();
}}
onOpenMore={() => {
setSheet({ kind: "more" });
p.onOpenMore?.();
}}
/>
) : null}
{sheet?.kind === "comments" ? (
<BottomSheet title="Comments" onClose={closeSheet} className="sheet-comments">
<CommentsSection postId={sheet.postId} onSignedOut={p.onSignedOut} />
</BottomSheet>
) : null}
{sheet?.kind === "composer" && p.composer ? (
<BottomSheet title="New post" onClose={closeSheet} className="sheet-composer">
<Composer {...p.composer} />
</BottomSheet>
) : null}
{sheet?.kind === "more" ? (
<DiscoverSheet experts={experts} onOpen={(panel) => setSheet({ kind: "panel", panel })} onClose={closeSheet} />
) : null}
{sheet?.kind === "panel" ? (
<DiscoverPanelSheet panel={sheet.panel} experts={experts} trending={p.trendingMinnects ?? []} search={p.searchPanel} onClose={closeSheet} />
) : null}
</section>
);
}