(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 239 lines 6.2 kB view raw
1import { Clock, Loader2 } from "lucide-react"; 2import { useCallback, useEffect, useRef, useState } from "react"; 3import { useTranslation } from "react-i18next"; 4import { type GetFeedParams, getFeed } from "../../api/client"; 5import Card from "../../components/common/Card"; 6import { EmptyState } from "../../components/ui"; 7import type { AnnotationItem } from "../../types"; 8 9const LIMIT = 50; 10 11const feedCache = new Map< 12 string, 13 { 14 items: AnnotationItem[]; 15 hasMore: boolean; 16 offset: number; 17 timestamp: number; 18 } 19>(); 20 21export interface FeedItemsProps extends Omit< 22 GetFeedParams, 23 "limit" | "offset" 24> { 25 layout: "list" | "mosaic"; 26 emptyMessage: string; 27 initialItems?: AnnotationItem[]; 28 initialHasMore?: boolean; 29} 30 31export default function FeedItems({ 32 creator, 33 source, 34 tag, 35 type, 36 motivation, 37 emptyMessage, 38 layout, 39 initialItems, 40 initialHasMore, 41}: FeedItemsProps) { 42 const { t } = useTranslation(); 43 const [items, setItems] = useState<AnnotationItem[]>(initialItems || []); 44 const [loading, setLoading] = useState(!initialItems); 45 const [loadingMore, setLoadingMore] = useState(false); 46 const [hasMore, setHasMore] = useState(initialHasMore ?? false); 47 const [offset, setOffset] = useState(initialItems?.length ?? 0); 48 const skipInitialFetch = useRef(!!initialItems); 49 50 useEffect(() => { 51 if (skipInitialFetch.current) { 52 skipInitialFetch.current = false; 53 return; 54 } 55 56 let cancelled = false; 57 const cacheKey = JSON.stringify({ type, motivation, tag, creator, source }); 58 const cached = feedCache.get(cacheKey); 59 60 if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { 61 setItems(cached.items); 62 setHasMore(cached.hasMore); 63 setOffset(cached.offset); 64 setLoading(false); 65 66 getFeed({ 67 type, 68 motivation, 69 tag, 70 creator, 71 source, 72 limit: LIMIT, 73 offset: 0, 74 }) 75 .then((data) => { 76 if (cancelled) return; 77 const fetched = data.items; 78 setItems(fetched); 79 setHasMore(data.hasMore); 80 setOffset(data.fetchedCount); 81 feedCache.set(cacheKey, { 82 items: fetched, 83 hasMore: data.hasMore, 84 offset: data.fetchedCount, 85 timestamp: Date.now(), 86 }); 87 }) 88 .catch(console.error); 89 90 return () => { 91 cancelled = true; 92 }; 93 } 94 95 setLoading(true); 96 getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) 97 .then((data) => { 98 if (cancelled) return; 99 const fetched = data.items; 100 setItems(fetched); 101 setHasMore(data.hasMore); 102 setOffset(data.fetchedCount); 103 setLoading(false); 104 feedCache.set(cacheKey, { 105 items: fetched, 106 hasMore: data.hasMore, 107 offset: data.fetchedCount, 108 timestamp: Date.now(), 109 }); 110 }) 111 .catch((e) => { 112 if (cancelled) return; 113 console.error(e); 114 setItems([]); 115 setHasMore(false); 116 setLoading(false); 117 }); 118 119 return () => { 120 cancelled = true; 121 }; 122 }, [type, motivation, tag, creator, source]); 123 124 const loadMore = useCallback(async () => { 125 setLoadingMore(true); 126 try { 127 const cacheKey = JSON.stringify({ 128 type, 129 motivation, 130 tag, 131 creator, 132 source, 133 }); 134 const data = await getFeed({ 135 type, 136 motivation, 137 tag, 138 creator, 139 source, 140 limit: LIMIT, 141 offset, 142 }); 143 const fetched = data?.items || []; 144 const newItems = [...items, ...fetched]; 145 setItems(newItems); 146 setHasMore(data.hasMore); 147 const newOffset = offset + data.fetchedCount; 148 setOffset(newOffset); 149 feedCache.set(cacheKey, { 150 items: newItems, 151 hasMore: data.hasMore, 152 offset: newOffset, 153 timestamp: Date.now(), 154 }); 155 } catch (e) { 156 console.error(e); 157 } finally { 158 setLoadingMore(false); 159 } 160 }, [type, motivation, tag, creator, source, offset, items]); 161 162 const handleDelete = (uri: string) => { 163 setItems((prev) => prev.filter((i) => i.uri !== uri)); 164 }; 165 166 if (loading) { 167 return ( 168 <div className="flex flex-col items-center justify-center py-20 gap-3"> 169 <Loader2 170 className="animate-spin text-primary-600 dark:text-primary-400" 171 size={32} 172 /> 173 <p className="text-sm text-surface-400 dark:text-surface-500"> 174 {t("feed.loading")} 175 </p> 176 </div> 177 ); 178 } 179 180 if (items.length === 0) { 181 return ( 182 <EmptyState 183 icon={<Clock size={48} />} 184 title={t("feed.nothingHereYet")} 185 message={emptyMessage} 186 /> 187 ); 188 } 189 190 const loadMoreButton = hasMore && ( 191 <div className="flex justify-center py-6"> 192 <button 193 type="button" 194 onClick={loadMore} 195 disabled={loadingMore} 196 className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 197 > 198 {loadingMore ? ( 199 <> 200 <Loader2 size={16} className="animate-spin" /> 201 {t("common.loading")} 202 </> 203 ) : ( 204 t("common.loadMore") 205 )} 206 </button> 207 </div> 208 ); 209 210 if (layout === "mosaic") { 211 return ( 212 <> 213 <div className="columns-1 sm:columns-2 xl:columns-3 2xl:columns-4 gap-4 animate-fade-in"> 214 {items.map((item) => ( 215 <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 216 <Card item={item} onDelete={handleDelete} layout="mosaic" /> 217 </div> 218 ))} 219 </div> 220 {loadMoreButton} 221 </> 222 ); 223 } 224 225 return ( 226 <> 227 <div className="space-y-3 animate-fade-in"> 228 {items.map((item) => ( 229 <Card 230 key={item.uri || item.cid} 231 item={item} 232 onDelete={handleDelete} 233 /> 234 ))} 235 </div> 236 {loadMoreButton} 237 </> 238 ); 239}