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 = lang === "zh-CN" ? "重试" : "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; const [isAligningQueryTarget, setIsAligningQueryTarget] = useState( Boolean(queryTargetPostId), ); 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(Boolean(queryTargetPostId)); }, [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 boundedSmoothScrollToTarget = () => { const firstTarget = targetScrollTop(); if (firstTarget === null) return; const start = window.scrollY; const startedAt = performance.now(); const duration = 520; const direction = firstTarget >= start ? 1 : -1; const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3); const tick = (now: number) => { const latestTarget = targetScrollTop(); if (latestTarget === null) return; const progress = Math.min(1, (now - startedAt) / duration); const ideal = start + (latestTarget - start) * easeOutCubic(progress); const next = direction >= 0 ? Math.min(ideal, latestTarget) : Math.max(ideal, latestTarget); window.scrollTo({ top: next, left: 0, behavior: "auto" }); if (progress < 1) { targetScrollFrameRef.current = window.requestAnimationFrame(tick); return; } scrollToTarget("auto"); setIsAligningQueryTarget(false); targetScrollFrameRef.current = null; }; targetScrollFrameRef.current = window.requestAnimationFrame(tick); }; const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; if (queryTargetPostId) { // Query deep-links (`?post=`) usually come from Home cards/list // rows. Keep the premium motion, but drive it ourselves so scrolling is // bounded to the current target position and cannot visibly pass the // target before snapping back. User-driven scroll is temporarily locked // by the effect above; programmatic scroll remains allowed. targetScrollTimersRef.current = [80].map((ms) => window.setTimeout(boundedSmoothScrollToTarget, ms), ); } 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 (hasMore && !isLoading) loadMore(); else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); }, [targetPostId, items, hasMore, isLoading, 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 ? (
{error}
) : null} {isLoading ? (
) : null} )}
); }