import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { LoaderCircle } from "lucide-react"; import { getJSON, postJSON } from "../../api"; import { langQuery, useI18n } from "../../i18n"; import type { Post, PostScope } from "../../types/post"; import { Reveal } from "../../motion"; import { Skeleton } from "../Skeleton"; import { FilterChips } from "./FilterChips"; import { MessageBubble } from "./MessageBubble"; import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { usePostStream } from "./hooks/usePostStream"; import { useFavorites } from "../../favorites/FavoritesProvider"; export type MessageStreamProps = { scope: PostScope; }; export function MessageStream({ scope }: MessageStreamProps) { const { t, lang } = useI18n(); const [sp, setSp] = useSearchParams(); const { hash } = useLocation(); const type = sp.get("type") || "all"; const q = (sp.get("q") || "").trim(); const sort = sp.get("sort") || ""; const params = useMemo( () => ({ scope, type, q, sort, lang }), [scope, type, q, sort, lang], ); const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); const { ensureFavoriteIds } = useFavorites(); const retryLabel = t("retry"); useEffect(() => { void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined); }, [ensureFavoriteIds, items]); const sentinelRef = useRef(null); const filterBarRef = useRef(null); const hasMoreRef = useRef(hasMore); const isLoadingRef = useRef(isLoading); useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]); useEffect(() => { isLoadingRef.current = isLoading; }, [isLoading]); useEffect(() => { if (q) postJSON("/api/search-log", { query: q }).catch(() => {}); }, [q]); useEffect(() => { const el = sentinelRef.current; if (!el) return; const io = new IntersectionObserver( (entries) => { for (const entry of entries) { if ( entry.isIntersecting && hasMoreRef.current && !isLoadingRef.current ) { loadMore(); } } }, // Prefetch the next page well before the sentinel is visible so content // is already in place as the user scrolls — no blank gap at the bottom. { rootMargin: "1000px" }, ); io.observe(el); return () => io.disconnect(); }, [loadMore]); // When arriving with a `?post=` query (or legacy `#post-` hash), // scroll to that bubble — loading more pages until it shows up — then give // it a brief highlight so the user can see where they landed. const queryTargetPostId = sp.get("post") || ""; const hashTargetPostId = hash.startsWith("#post-") ? hash.slice("#post-".length) : ""; const targetPostId = queryTargetPostId || hashTargetPostId; const [resolvedTargetPost, setResolvedTargetPost] = useState( null, ); const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false); const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false); const targetAlreadyInBaseItems = useMemo( () => !!queryTargetPostId && items.some((post) => post.id === queryTargetPostId), [items, queryTargetPostId], ); const streamItems = useMemo(() => { if ( resolvedTargetPost && !items.some((post) => post.id === resolvedTargetPost.id) ) { return [resolvedTargetPost, ...items]; } return items; }, [items, resolvedTargetPost]); const groups = useGroupedByDay(streamItems, lang); // Lock only engages while we are actively running the smooth-scroll animation // — not during the wait/pagination phase — so the page never feels frozen // before the bubble exists. const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(false); const handledTargetRef = useRef(""); const targetScrollTimersRef = useRef([]); const targetScrollFrameRef = useRef(null); // Timestamp (perf clock) when the first batch of real items became visible // for this target. Used to delay the deep-link smooth scroll until the // initial Reveal in-view animations have had a moment to play, so the user // sees content before the page starts moving. const firstContentAtRef = useRef(null); const clearTargetScrollTimers = () => { for (const timer of targetScrollTimersRef.current) { window.clearTimeout(timer); } targetScrollTimersRef.current = []; if (targetScrollFrameRef.current !== null) { window.cancelAnimationFrame(targetScrollFrameRef.current); targetScrollFrameRef.current = null; } }; useEffect(() => { handledTargetRef.current = ""; firstContentAtRef.current = null; clearTargetScrollTimers(); setIsAligningQueryTarget(false); }, [queryTargetPostId, targetPostId]); // Mark when first real content becomes visible (skeletons gone, items in). // Captured per-target via the reset above so a later navigation re-measures. useEffect(() => { if ( streamItems.length > 0 && !isLoading && firstContentAtRef.current === null ) { firstContentAtRef.current = performance.now(); } }, [streamItems.length, isLoading]); // Banner / deep-link arrivals (`?post=`) should always begin the // smooth-scroll positioning from the top of the stream, so the user sees a // clear, satisfying journey to the target post instead of a tiny nudge when // they happen to revisit the page mid-scroll. Run before paint so the user // never briefly sees the previous scrollY before the jump. useLayoutEffect(() => { if (!queryTargetPostId) return; window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [queryTargetPostId]); // Search result clicks can target very old posts that are nowhere near the // first paginated /browse page. Do not make the user wait while the stream // loads page after page; fetch the target post directly and inject it at the // top so it can render and be highlighted immediately. The normal stream // still loads underneath for context / scrolling. useEffect(() => { if (!queryTargetPostId) { setResolvedTargetPost(null); setIsFetchingTargetPost(false); setTargetPostFetchFailed(false); return; } if (targetAlreadyInBaseItems) { setResolvedTargetPost(null); setIsFetchingTargetPost(false); setTargetPostFetchFailed(false); return; } let cancelled = false; setIsFetchingTargetPost(true); setTargetPostFetchFailed(false); getJSON( `/api/posts/${encodeURIComponent(queryTargetPostId)}?lang=${encodeURIComponent( langQuery(lang), )}`, ) .then((post) => { if (cancelled) return; setResolvedTargetPost(post); }) .catch(() => { if (cancelled) return; setResolvedTargetPost(null); setTargetPostFetchFailed(true); }) .finally(() => { if (!cancelled) setIsFetchingTargetPost(false); }); return () => { cancelled = true; }; }, [lang, queryTargetPostId, targetAlreadyInBaseItems]); useEffect(() => clearTargetScrollTimers, []); useEffect(() => { if (!isAligningQueryTarget) return; const preventScroll = (event: Event) => event.preventDefault(); const preventScrollKeys = (event: KeyboardEvent) => { if ( [ "ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " ", ].includes(event.key) ) { event.preventDefault(); } }; const html = document.documentElement; const previousOverscroll = html.style.overscrollBehavior; html.style.overscrollBehavior = "none"; window.addEventListener("wheel", preventScroll, { capture: true, passive: false, }); window.addEventListener("touchmove", preventScroll, { capture: true, passive: false, }); window.addEventListener("keydown", preventScrollKeys, { capture: true }); return () => { html.style.overscrollBehavior = previousOverscroll; window.removeEventListener("wheel", preventScroll, { capture: true }); window.removeEventListener("touchmove", preventScroll, { capture: true }); window.removeEventListener("keydown", preventScrollKeys, { capture: true, }); }; }, [isAligningQueryTarget]); useEffect(() => { if (!targetPostId || handledTargetRef.current === targetPostId) return; const el = document.getElementById(`post-${targetPostId}`); if (el) { handledTargetRef.current = targetPostId; clearTargetScrollTimers(); const targetScrollTop = () => { const target = document.getElementById(`post-${targetPostId}`); if (!target) return null; const filterBottom = filterBarRef.current?.getBoundingClientRect().bottom ?? 0; const targetTop = target.getBoundingClientRect().top + window.scrollY; return Math.max(0, targetTop - filterBottom - 12); }; const scrollToTarget = (behavior: ScrollBehavior = "auto") => { const top = targetScrollTop(); if (top === null) return; window.scrollTo({ top, left: 0, behavior }); }; const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; if (queryTargetPostId) { // Query deep-links (`?post=`) come from banner / Home card clicks. // Use the browser-native smooth scroll (runs on the compositor, much // smoother than a hand-rolled rAF) and lock user scroll only for the // duration of the animation so the page never feels frozen. Quiet // re-alignments run inside the lock window to absorb late image-shift // above the target; nothing nudges the user after the lock releases. // // Hold the animation until the page has had ~300ms after the first // content paint, so the in-view Reveal staggers on the top bubbles // have time to fade in. Otherwise the smooth scroll starts before the // user can see anything, and the journey reads as "blank flash". const REVEAL_SETTLE_MS = 300; const elapsed = firstContentAtRef.current !== null ? performance.now() - firstContentAtRef.current : 0; const settle = Math.max(0, REVEAL_SETTLE_MS - elapsed); setIsAligningQueryTarget(true); targetScrollTimersRef.current = [ window.setTimeout(() => { window.requestAnimationFrame(() => scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), ); }, settle), window.setTimeout(() => scrollToTarget("auto"), settle + 450), window.setTimeout(() => scrollToTarget("auto"), settle + 750), window.setTimeout(() => { scrollToTarget("auto"); setIsAligningQueryTarget(false); }, settle + 950), ]; } else { window.requestAnimationFrame(() => scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), ); // Media above the target can finish loading after the first scroll and // shift the target downward. Re-align after the smooth animation while // stream image/video heights settle, so the final resting point is exact. targetScrollTimersRef.current = [900, 1400, 2000].map((ms) => window.setTimeout(() => scrollToTarget("auto"), ms), ); } el.classList.add("ark-bubble-highlight"); window.setTimeout( () => el.classList.remove("ark-bubble-highlight"), 2000, ); return; } // Not loaded yet — keep paging until it appears or the stream is // exhausted. If the previous loadMore errored, stop the loop so the user // sees the inline retry button instead of an endless retry cycle, and // release the scroll lock so they can interact with the page. if (error) { setIsAligningQueryTarget(false); return; } if (hasMore && !isLoading) loadMore(); else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); }, [targetPostId, streamItems, hasMore, isLoading, error, loadMore]); const updateParam = (key: string, value: string) => { const n = new URLSearchParams(sp); if (!value || value === "all") n.delete(key); else n.set(key, value); setSp(n, { replace: true }); }; const isInitialLoad = isLoading && streamItems.length === 0; // When the user arrives via /browse?post= (typically from search or a // banner) and the target post lives deep in the stream, pagination has to // keep loading older pages until it surfaces. Show an explicit "finding // your post" indicator so the user knows we're actively searching for // their specific post, not just lazily loading the feed. const targetInLoadedItems = !!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId); const isSearchingDeepTarget = !!queryTargetPostId && !targetInLoadedItems && !error && (isFetchingTargetPost || hasMore || isLoading); const targetNotFoundInStream = !!queryTargetPostId && !targetInLoadedItems && !error && targetPostFetchFailed && !hasMore && !isLoading && streamItems.length > 0; return (
{/* Filters stay pinned below the global header (which shows the page name) so users can switch filters while scrolling. */}
updateParam("type", v)} />
{isSearchingDeepTarget ? (
{t("searchingForPost")}
) : null} {targetNotFoundInStream ? (
{t("postNotFound")}
) : null} {isInitialLoad ? ( <> {Array.from({ length: 10 }).map((_, i) => (
))} ) : ( <> {groups.map((group) => (
{group.items.map((post, index) => ( ))}
))} {!isLoading && !error && streamItems.length === 0 ? (

{t("noResults")}

) : null} {error ? (
{t("loadMoreFailed")}
) : null} {isLoading && !error ? (
) : null} )}
); }