import { useEffect, useMemo, useRef } 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 handledTargetRef = useRef(""); const targetScrollTimersRef = useRef([]); const clearTargetScrollTimers = () => { for (const timer of targetScrollTimersRef.current) { window.clearTimeout(timer); } targetScrollTimersRef.current = []; }; useEffect(() => { handledTargetRef.current = ""; clearTargetScrollTimers(); }, [targetPostId]); useEffect(() => clearTargetScrollTimers, []); useEffect(() => { if (!targetPostId || handledTargetRef.current === targetPostId) return; const el = document.getElementById(`post-${targetPostId}`); if (el) { handledTargetRef.current = targetPostId; clearTargetScrollTimers(); const scrollToTarget = (behavior: ScrollBehavior = "auto") => { const target = document.getElementById(`post-${targetPostId}`); if (!target) return; const filterBottom = filterBarRef.current?.getBoundingClientRect().bottom ?? 0; const targetTop = target.getBoundingClientRect().top + window.scrollY; window.scrollTo({ top: Math.max(0, targetTop - filterBottom - 12), left: 0, behavior, }); }; const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; // Show a deliberate "from top to target" transition when opening a card // from Home. The later auto re-alignments are intentionally delayed so // they don't interrupt the visible smooth scroll animation. window.scrollTo({ top: 0, left: 0, behavior: "auto" }); 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(); }, [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} )}
); }