Files
Arkie-Library-Frontend/src/components/messageStream/MessageStream.tsx
2026-05-28 16:49:30 +08:00

116 lines
3.5 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { postJSON } from "../../api";
import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post";
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 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<HTMLDivElement>(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();
}
}
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [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 });
};
return (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-3">
{group.items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
))}
{!isLoading && !error && items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
) : null}
{error ? (
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<span className="break-all">{error}</span>
<button
type="button"
onClick={() => reset()}
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
>
{retryLabel}
</button>
</div>
) : null}
{isLoading ? (
<div className="py-4 text-center text-xs text-neutral-500"></div>
) : null}
<div ref={sentinelRef} aria-hidden className="h-1" />
</div>
</div>
);
}