import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2, Plus, Newspaper, FileText, } from "lucide-react"; import { api } from "@/client"; import { useChannelSocket } from "@/hooks/use-channel"; import { Button } from "@/components/ui/button"; import ArticleCard from "./article-card"; import ArticleDetail from "./article-detail"; import type { ArticleItem, ArticleDetail as ArticleDetailType } from "./article-types"; type Props = { roomId: string; roomName: string; currentUserId?: string; onCompose?: () => void; }; export default function ArticleFeed({ roomId, roomName, currentUserId, onCompose }: Props) { const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(true); const [detailId, setDetailId] = useState(null); const [detail, setDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); const loaderRef = useRef(null); const loadArticles = useCallback( async (before?: string) => { try { const params: Record = { limit: 20 }; if (before) params.before = before; const res = await api.get< Record >(`/api/v1/ws/channels/${roomId}/articles`, { params }); const data = res.data as Record; const list = (data.articles ?? []) as ArticleItem[]; const more = (data.has_more ?? false) as boolean; if (before) { setArticles((prev) => [...prev, ...list]); } else { setArticles(list); } setHasMore(more); } catch { // ignore } finally { setLoading(false); } }, [roomId], ); // Reset on room change and (re)load useEffect(() => { let cancelled = false; const doLoad = async () => { setLoading(true); setArticles([]); setHasMore(true); setDetailId(null); setDetail(null); try { const params: Record = { limit: 20 }; const res = await api.get>( `/api/v1/ws/channels/${roomId}/articles`, { params }, ); if (cancelled) return; const data = res.data as Record; setArticles((data.articles ?? []) as ArticleItem[]); setHasMore((data.has_more ?? false) as boolean); } catch { // ignore } finally { if (!cancelled) setLoading(false); } }; doLoad(); return () => { cancelled = true; }; }, [roomId]); // Load detail when detailId changes useEffect(() => { if (!detailId) { // eslint-disable-next-line react-hooks/set-state-in-effect -- clear on deselect setDetail(null); return; } let cancelled = false; setLoadingDetail(true); api .get>( `/api/v1/ws/channels/${roomId}/articles/${detailId}`, ) .then((res) => { if (cancelled) return; const d = res.data as unknown as ArticleDetailType; setDetail(d); setArticles((prev) => prev.map((a) => a.id === detailId ? { ...a, view_count: d.view_count } : a, ), ); }) .catch(() => setDetailId(null)) .finally(() => { if (!cancelled) setLoadingDetail(false); }); return () => { cancelled = true; }; }, [detailId, roomId]); // Intersection observer for infinite scroll useEffect(() => { const el = loaderRef.current; if (!el || !hasMore || loading) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasMore && !loading && articles.length > 0) { const last = articles[articles.length - 1]; loadArticles(last.id); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [hasMore, loading, articles, loadArticles]); // Listen to websocket events for article updates const { onEvent } = useChannelSocket(); useEffect(() => { return onEvent((event) => { if (!event.room || event.room.id !== roomId) return; if (event.type === "article.liked" || event.type === "article.unliked") { const d = event.data as { article_id: string; like_count: number }; setArticles((prev) => prev.map((a) => a.id === d.article_id ? { ...a, like_count: d.like_count } : a, ), ); if (detail?.id === d.article_id) { setDetail((prev) => prev ? { ...prev, like_count: d.like_count } : null, ); } } if (event.type === "article.comment.created") { const d = event.data as { comment: { article: string }; comment_count: number; }; setArticles((prev) => prev.map((a) => a.id === d.comment.article ? { ...a, comment_count: d.comment_count } : a, ), ); if (detail?.id === d.comment.article) { setDetail((prev) => prev ? { ...prev, comment_count: d.comment_count } : null, ); } } if (event.type === "article.comment.deleted") { const d = event.data as { article_id: string; comment_count: number }; setArticles((prev) => prev.map((a) => a.id === d.article_id ? { ...a, comment_count: d.comment_count } : a, ), ); if (detail?.id === d.article_id) { setDetail((prev) => prev ? { ...prev, comment_count: d.comment_count } : null, ); } } }); }, [roomId, onEvent, detail?.id]); return (
{/* Header */}

{roomName}

{articles.length} articles

{/* Article grid */}
{loading ? (
) : articles.length === 0 ? (

No articles yet

Be the first to share

) : ( <> {/* Masonry grid */}
{articles.map((article) => (
setDetailId(id)} />
))}
{/* Loader sentinel */}
{hasMore && ( )} {!hasMore && articles.length > 0 && ( — You have reached the end — )}
)}
{/* Article detail panel */} {detailId && ( <>
setDetailId(null)} />
{loadingDetail ? (
) : detail ? ( setDetailId(null)} onUpdated={(updated) => { setDetail(updated); setArticles((prev) => prev.map((a) => a.id === updated.id ? { ...a, title: updated.title, summary: updated.summary, cover_url: updated.cover_url, tags: updated.tags, like_count: updated.like_count, comment_count: updated.comment_count, view_count: updated.view_count, is_pinned: updated.is_pinned, updated_at: updated.updated_at, } : a, ), ); }} /> ) : null}
)}
); }