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 { SectionHeader } from "../SectionHeader"; import { MessageBubble } from "./MessageBubble"; import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { usePostStream } from "./hooks/usePostStream"; export type MessageStreamProps = { scope: PostScope; title?: string; }; export function MessageStream({ scope, title }: 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 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-` hash (e.g. from a recommended card), // 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 targetPostId = hash.startsWith("#post-") ? hash.slice("#post-".length) : ""; const handledTargetRef = useRef(""); useEffect(() => { handledTargetRef.current = ""; }, [targetPostId]); useEffect(() => { if (!targetPostId || handledTargetRef.current === targetPostId) return; const el = document.getElementById(`post-${targetPostId}`); if (el) { handledTargetRef.current = targetPostId; const frame = window.requestAnimationFrame(() => { el.scrollIntoView({ block: "start", behavior: "smooth" }); el.classList.add("ark-bubble-highlight"); window.setTimeout( () => el.classList.remove("ark-bubble-highlight"), 2000, ); }); return () => window.cancelAnimationFrame(frame); } // 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 (
{/* Title + filters stay pinned below the global header so users always see which page they're on and can switch filters while scrolling. */}
{title ? (
) : null} 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} )}
); }