feat: add telegram-style resource stream
This commit is contained in:
115
src/components/messageStream/MessageStream.tsx
Normal file
115
src/components/messageStream/MessageStream.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
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 language = sp.get("language") || "";
|
||||
|
||||
const params = useMemo(
|
||||
() => ({ scope, type, language, lang }),
|
||||
[scope, type, language, lang],
|
||||
);
|
||||
|
||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||
usePostStream(params);
|
||||
const groups = useGroupedByDay(items, lang);
|
||||
const retryLabel =
|
||||
lang === "zh-TW" ? "重試" : 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(() => {
|
||||
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 px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||
<FilterChips
|
||||
type={type}
|
||||
language={language}
|
||||
onTypeChange={(v) => updateParam("type", v)}
|
||||
onLanguageChange={(v) => updateParam("language", v)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pb-10 pt-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.dayKey} className="flex flex-col gap-2">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user