"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(ref: Ref | 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(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 (
{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 setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])} /> ) : null}
{started ? null : ( <> {duration ? {duration} : null} )}
); } if (media.src) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} {media.kind
); } return (
Video is processing
); } /** 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(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(":scope > .slide-pane").forEach((pane, i) => { if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause()); }); }; return ( <>
{items.map((m, i) => (
))}
); } 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(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)} onEndedCapture={() => setPlaying(false)} > {media ? ( <> {items.length > 1 ? : }
); } /** 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 (
MINNECT
); } 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 (
); } /** * 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(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(); 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(":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(":scope > .slide").forEach((el) => { if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause()); }); }, [index]); return (
{p.newCount > 0 && p.status === "ready" ? (
) : null}
{ reel.current = el; setRef(p.listRef, el); }} > {p.status === "error" ? (

The feed did not load.

) : null} {p.status === "ready" && p.posts.length === 0 && !showLoad ? (

No posts yet.

) : null} {p.status === "ready" ? 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}
{p.status !== "error" ? ( { setSheet({ kind: "comments", postId }); p.onOpenComments?.(postId); }} onOpenComposer={() => { setSheet({ kind: "composer" }); p.onOpenComposer?.(); }} onOpenMore={() => { setSheet({ kind: "more" }); p.onOpenMore?.(); }} /> ) : null} {sheet?.kind === "comments" ? ( ) : null} {sheet?.kind === "composer" && p.composer ? ( ) : null} {sheet?.kind === "more" ? ( setSheet({ kind: "panel", panel })} onClose={closeSheet} /> ) : null} {sheet?.kind === "panel" ? ( ) : null}
); }