Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useState, useEffect, useMemo, useCallback } from "react";
2import { useSearchParams } from "react-router-dom";
3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard";
4import BookmarkCard from "../components/BookmarkCard";
5import CollectionItemCard from "../components/CollectionItemCard";
6import AnnotationSkeleton from "../components/AnnotationSkeleton";
7import IOSInstallBanner from "../components/IOSInstallBanner";
8import { getAnnotationFeed, deleteHighlight } from "../api/client";
9import { AlertIcon, InboxIcon } from "../components/Icons";
10import { useAuth } from "../context/AuthContext";
11import { X, ArrowUp } from "lucide-react";
12
13import AddToCollectionModal from "../components/AddToCollectionModal";
14
15export default function Feed() {
16 const [searchParams, setSearchParams] = useSearchParams();
17 const tagFilter = searchParams.get("tag");
18
19 const [filter, setFilter] = useState(() => {
20 return localStorage.getItem("feedFilter") || "all";
21 });
22
23 const [feedType, setFeedType] = useState(() => {
24 return localStorage.getItem("feedType") || "all";
25 });
26
27 const [annotations, setAnnotations] = useState([]);
28 const [loading, setLoading] = useState(true);
29 const [error, setError] = useState(null);
30 const [hasMore, setHasMore] = useState(true);
31 const [loadingMore, setLoadingMore] = useState(false);
32
33 useEffect(() => {
34 localStorage.setItem("feedFilter", filter);
35 }, [filter]);
36
37 useEffect(() => {
38 localStorage.setItem("feedType", feedType);
39 }, [feedType]);
40
41 const [collectionModalState, setCollectionModalState] = useState({
42 isOpen: false,
43 uri: null,
44 });
45
46 const { user } = useAuth();
47
48 const fetchFeed = useCallback(
49 async (isLoadMore = false) => {
50 try {
51 if (isLoadMore) {
52 setLoadingMore(true);
53 } else {
54 setLoading(true);
55 }
56
57 let creatorDid = "";
58
59 if (feedType === "my-feed") {
60 if (user?.did) {
61 creatorDid = user.did;
62 } else {
63 setAnnotations([]);
64 setLoading(false);
65 setLoadingMore(false);
66 return;
67 }
68 }
69
70 const motivationMap = {
71 commenting: "commenting",
72 highlighting: "highlighting",
73 bookmarking: "bookmarking",
74 };
75 const motivation = motivationMap[filter] || "";
76 const limit = 50;
77 const offset = isLoadMore ? annotations.length : 0;
78
79 const data = await getAnnotationFeed(
80 limit,
81 offset,
82 tagFilter || "",
83 creatorDid,
84 feedType,
85 motivation,
86 );
87
88 const newItems = data.items || [];
89 if (newItems.length < limit) {
90 setHasMore(false);
91 } else {
92 setHasMore(true);
93 }
94
95 if (isLoadMore) {
96 setAnnotations((prev) => [...prev, ...newItems]);
97 } else {
98 setAnnotations(newItems);
99 }
100 } catch (err) {
101 setError(err.message);
102 } finally {
103 setLoading(false);
104 setLoadingMore(false);
105 }
106 },
107 [tagFilter, feedType, filter, user, annotations.length],
108 );
109
110 useEffect(() => {
111 fetchFeed(false);
112 }, [fetchFeed]);
113
114 const deduplicatedAnnotations = useMemo(() => {
115 const inCollectionUris = new Set();
116 for (const item of annotations) {
117 if (item.type === "CollectionItem") {
118 const inner = item.annotation || item.highlight || item.bookmark;
119 if (inner) {
120 if (inner.uri) inCollectionUris.add(inner.uri.trim());
121 if (inner.id) inCollectionUris.add(inner.id.trim());
122 }
123 }
124 }
125
126 const result = [];
127
128 for (const item of annotations) {
129 if (item.type !== "CollectionItem") {
130 const itemUri = (item.uri || "").trim();
131 const itemId = (item.id || "").trim();
132 if (
133 (itemUri && inCollectionUris.has(itemUri)) ||
134 (itemId && inCollectionUris.has(itemId))
135 ) {
136 continue;
137 }
138 }
139
140 result.push(item);
141 }
142
143 return result;
144 }, [annotations]);
145
146 const filteredAnnotations =
147 feedType === "all" ||
148 feedType === "popular" ||
149 feedType === "semble" ||
150 feedType === "margin" ||
151 feedType === "my-feed"
152 ? filter === "all"
153 ? deduplicatedAnnotations
154 : deduplicatedAnnotations.filter((a) => {
155 if (a.type === "CollectionItem") {
156 if (filter === "commenting") return !!a.annotation;
157 if (filter === "highlighting") return !!a.highlight;
158 if (filter === "bookmarking") return !!a.bookmark;
159 }
160 if (filter === "commenting")
161 return a.motivation === "commenting" || a.type === "Annotation";
162 if (filter === "highlighting")
163 return a.motivation === "highlighting" || a.type === "Highlight";
164 if (filter === "bookmarking")
165 return a.motivation === "bookmarking" || a.type === "Bookmark";
166 return a.motivation === filter;
167 })
168 : deduplicatedAnnotations;
169
170 return (
171 <div className="feed-page">
172 <div className="page-header">
173 <h1 className="page-title">Feed</h1>
174 <p className="page-description">
175 See what people are annotating and bookmarking
176 </p>
177 </div>
178
179 {tagFilter && (
180 <div className="active-filter-banner">
181 <span>
182 Filtering by <strong>#{tagFilter}</strong>
183 </span>
184 <button
185 onClick={() =>
186 setSearchParams((prev) => {
187 const next = new URLSearchParams(prev);
188 next.delete("tag");
189 return next;
190 })
191 }
192 className="active-filter-clear"
193 aria-label="Clear filter"
194 >
195 <X size={14} />
196 </button>
197 </div>
198 )}
199
200 <div className="feed-controls">
201 <div className="feed-filters">
202 {[
203 { key: "all", label: "All" },
204 { key: "popular", label: "Popular" },
205 { key: "margin", label: "Margin" },
206 { key: "semble", label: "Semble" },
207 ...(user ? [{ key: "my-feed", label: "Mine" }] : []),
208 ].map(({ key, label }) => (
209 <button
210 key={key}
211 className={`filter-tab ${feedType === key ? "active" : ""}`}
212 onClick={() => setFeedType(key)}
213 >
214 {label}
215 </button>
216 ))}
217 </div>
218
219 <div className="feed-filters">
220 {[
221 { key: "all", label: "All" },
222 { key: "commenting", label: "Notes" },
223 { key: "highlighting", label: "Highlights" },
224 { key: "bookmarking", label: "Bookmarks" },
225 ].map(({ key, label }) => (
226 <button
227 key={key}
228 className={`filter-pill ${filter === key ? "active" : ""}`}
229 onClick={() => setFilter(key)}
230 >
231 {label}
232 </button>
233 ))}
234 </div>
235 </div>
236
237 <IOSInstallBanner />
238
239 {loading ? (
240 <div className="feed-container">
241 <div className="feed">
242 {[1, 2, 3, 4, 5].map((i) => (
243 <AnnotationSkeleton key={i} />
244 ))}
245 </div>
246 </div>
247 ) : (
248 <>
249 {error && (
250 <div className="empty-state">
251 <div className="empty-state-icon">
252 <AlertIcon size={24} />
253 </div>
254 <h3 className="empty-state-title">Something went wrong</h3>
255 <p className="empty-state-text">{error}</p>
256 </div>
257 )}
258
259 {!error && filteredAnnotations.length === 0 && (
260 <div className="empty-state">
261 <div className="empty-state-icon">
262 <InboxIcon size={24} />
263 </div>
264 <h3 className="empty-state-title">No items yet</h3>
265 <p className="empty-state-text">
266 {filter === "all"
267 ? "Be the first to annotate something!"
268 : `No ${filter} items found.`}
269 </p>
270 </div>
271 )}
272
273 {!error && filteredAnnotations.length > 0 && (
274 <div className="feed-container">
275 <div className="feed">
276 {filteredAnnotations.map((item) => {
277 if (item.type === "CollectionItem") {
278 return (
279 <CollectionItemCard
280 key={item.id}
281 item={item}
282 onAddToCollection={(uri) =>
283 setCollectionModalState({
284 isOpen: true,
285 uri: uri,
286 })
287 }
288 />
289 );
290 }
291 if (
292 item.type === "Highlight" ||
293 item.motivation === "highlighting"
294 ) {
295 return (
296 <HighlightCard
297 key={item.id}
298 highlight={item}
299 onDelete={async (uri) => {
300 const rkey = uri.split("/").pop();
301 await deleteHighlight(rkey);
302 setAnnotations((prev) =>
303 prev.filter((a) => a.id !== item.id),
304 );
305 }}
306 onAddToCollection={() =>
307 setCollectionModalState({
308 isOpen: true,
309 uri: item.uri || item.id,
310 })
311 }
312 />
313 );
314 }
315 if (
316 item.type === "Bookmark" ||
317 item.motivation === "bookmarking"
318 ) {
319 return (
320 <BookmarkCard
321 key={item.id}
322 bookmark={item}
323 onAddToCollection={() =>
324 setCollectionModalState({
325 isOpen: true,
326 uri: item.uri || item.id,
327 })
328 }
329 />
330 );
331 }
332 return (
333 <AnnotationCard
334 key={item.id}
335 annotation={item}
336 onAddToCollection={() =>
337 setCollectionModalState({
338 isOpen: true,
339 uri: item.uri || item.id,
340 })
341 }
342 />
343 );
344 })}
345 </div>
346
347 {hasMore && (
348 <div
349 style={{
350 display: "flex",
351 justifyContent: "center",
352 marginTop: "12px",
353 paddingBottom: "24px",
354 }}
355 >
356 <button
357 onClick={() => fetchFeed(true)}
358 disabled={loadingMore}
359 className="feed-load-more"
360 >
361 {loadingMore ? "Loading..." : "View More"}
362 </button>
363 </div>
364 )}
365 </div>
366 )}
367 </>
368 )}
369
370 {collectionModalState.isOpen && (
371 <AddToCollectionModal
372 isOpen={collectionModalState.isOpen}
373 onClose={() => setCollectionModalState({ isOpen: false, uri: null })}
374 annotationUri={collectionModalState.uri}
375 />
376 )}
377
378 <BackToTopButton />
379 </div>
380 );
381}
382
383function BackToTopButton() {
384 const [isVisible, setIsVisible] = useState(false);
385
386 useEffect(() => {
387 const toggleVisibility = () => {
388 if (window.scrollY > 300) {
389 setIsVisible(true);
390 } else {
391 setIsVisible(false);
392 }
393 };
394
395 window.addEventListener("scroll", toggleVisibility);
396 return () => window.removeEventListener("scroll", toggleVisibility);
397 }, []);
398
399 const scrollToTop = () => {
400 window.scrollTo({
401 top: 0,
402 behavior: "smooth",
403 });
404 };
405
406 return (
407 <button
408 className={`back-to-top-btn ${isVisible ? "visible" : ""}`}
409 onClick={scrollToTop}
410 aria-label="Back to top"
411 >
412 <ArrowUp size={20} />
413 </button>
414 );
415}