import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { postJSON } from "../../api"; import { useI18n } from "../../i18n"; import type { 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"; 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 groups = useGroupedByDay(items, lang); const retryLabel = t("retry"); 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; // 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); 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 = ""; clearTargetScrollTimers(); setIsAligningQueryTarget(false); }, [queryTargetPostId, targetPostId]); 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. setIsAligningQueryTarget(true); window.requestAnimationFrame(() => scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), ); targetScrollTimersRef.current = [ window.setTimeout(() => scrollToTarget("auto"), 450), window.setTimeout(() => scrollToTarget("auto"), 750), window.setTimeout(() => { scrollToTarget("auto"); setIsAligningQueryTarget(false); }, 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, items, 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 && items.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)} />
{isInitialLoad ? ( <> {Array.from({ length: 10 }).map((_, i) => (
))} ) : ( <> {groups.map((group) => (
{group.items.map((post, index) => ( ))}
))} {!isLoading && !error && items.length === 0 ? (

{t("noResults")}

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