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

Configure Feed

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

fixes

scanash00 ce26ad9c 36cd5abb

+191 -18
+2 -2
backend/internal/api/hydration.go
··· 617 617 618 618 missingDIDs := make([]string, 0) 619 619 for _, did := range dids { 620 - if author, ok := Cache.Get(did); ok { 620 + if author, ok := Cache.Get(did); ok && author.Handle != "" { 621 621 profiles[did] = author 622 622 } else { 623 623 missingDIDs = append(missingDIDs, did) ··· 646 646 647 647 stillMissing := make([]string, 0) 648 648 for _, did := range missingDIDs { 649 - if _, ok := profiles[did]; !ok { 649 + if p, ok := profiles[did]; !ok || p.Handle == "" { 650 650 stillMissing = append(stillMissing, did) 651 651 } 652 652 }
+41 -3
web/src/components/feed/FeedItems.tsx
··· 7 7 8 8 const LIMIT = 50; 9 9 10 + const feedCache = new Map< 11 + string, 12 + { 13 + items: AnnotationItem[]; 14 + hasMore: boolean; 15 + offset: number; 16 + timestamp: number; 17 + } 18 + >(); 19 + 10 20 export interface FeedItemsProps extends Omit< 11 21 GetFeedParams, 12 22 "limit" | "offset" ··· 32 42 33 43 useEffect(() => { 34 44 let cancelled = false; 45 + const cacheKey = JSON.stringify({ type, motivation, tag, creator, source }); 46 + const cached = feedCache.get(cacheKey); 35 47 48 + if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { 49 + setItems(cached.items); 50 + setHasMore(cached.hasMore); 51 + setOffset(cached.offset); 52 + setLoading(false); 53 + 54 + getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) 55 + .then((data) => { 56 + if (cancelled) return; 57 + const fetched = data.items; 58 + setItems(fetched); 59 + setHasMore(data.hasMore); 60 + setOffset(data.fetchedCount); 61 + feedCache.set(cacheKey, { items: fetched, hasMore: data.hasMore, offset: data.fetchedCount, timestamp: Date.now() }); 62 + }) 63 + .catch(console.error); 64 + 65 + return () => { cancelled = true; }; 66 + } 67 + 68 + setLoading(true); 36 69 getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 }) 37 70 .then((data) => { 38 71 if (cancelled) return; ··· 41 74 setHasMore(data.hasMore); 42 75 setOffset(data.fetchedCount); 43 76 setLoading(false); 77 + feedCache.set(cacheKey, { items: fetched, hasMore: data.hasMore, offset: data.fetchedCount, timestamp: Date.now() }); 44 78 }) 45 79 .catch((e) => { 46 80 if (cancelled) return; ··· 58 92 const loadMore = useCallback(async () => { 59 93 setLoadingMore(true); 60 94 try { 95 + const cacheKey = JSON.stringify({ type, motivation, tag, creator, source }); 61 96 const data = await getFeed({ 62 97 type, 63 98 motivation, ··· 68 103 offset, 69 104 }); 70 105 const fetched = data?.items || []; 71 - setItems((prev) => [...prev, ...fetched]); 106 + const newItems = [...items, ...fetched]; 107 + setItems(newItems); 72 108 setHasMore(data.hasMore); 73 - setOffset((prev) => prev + data.fetchedCount); 109 + const newOffset = offset + data.fetchedCount; 110 + setOffset(newOffset); 111 + feedCache.set(cacheKey, { items: newItems, hasMore: data.hasMore, offset: newOffset, timestamp: Date.now() }); 74 112 } catch (e) { 75 113 console.error(e); 76 114 } finally { 77 115 setLoadingMore(false); 78 116 } 79 - }, [type, motivation, tag, creator, source, offset]); 117 + }, [type, motivation, tag, creator, source, offset, items]); 80 118 81 119 const handleDelete = (uri: string) => { 82 120 setItems((prev) => prev.filter((i) => i.uri !== uri));
+10 -6
web/src/pages/og-image.ts
··· 50 50 const item = await res.json(); 51 51 const author = item.author || item.creator || {}; 52 52 const handle = author.handle || ""; 53 - const displayName = author.displayName || handle || "someone"; 53 + const did = author.did || ""; 54 + const authorName = handle ? `@${handle}` : did || "someone"; 55 + const displayName = author.displayName || handle || did || "someone"; 54 56 const avatarURL = author.avatar || ""; 55 57 const targetSource = item.target?.source || item.url || item.source || ""; 56 58 const domain = targetSource ··· 75 77 ) { 76 78 return { 77 79 type: "highlight", 78 - author: handle ? `@${handle}` : "someone", 80 + author: authorName, 79 81 displayName, 80 82 avatarURL, 81 83 text: targetTitle, ··· 91 93 if (uri.includes("/at.margin.bookmark/")) { 92 94 return { 93 95 type: "bookmark", 94 - author: handle ? `@${handle}` : "someone", 96 + author: authorName, 95 97 displayName, 96 98 avatarURL, 97 99 text: item.title || targetTitle || "Bookmark", ··· 106 108 107 109 return { 108 110 type: "annotation", 109 - author: handle ? `@${handle}` : "someone", 111 + author: authorName, 110 112 displayName, 111 113 avatarURL, 112 114 text: bodyText, ··· 130 132 const item = await res.json(); 131 133 const author = item.author || item.creator || {}; 132 134 const handle = author.handle || ""; 133 - const displayName = author.displayName || handle || "someone"; 135 + const did = author.did || ""; 136 + const authorName = handle ? `@${handle}` : did || "someone"; 137 + const displayName = author.displayName || handle || did || "someone"; 134 138 const avatarURL = author.avatar || ""; 135 139 136 140 return { 137 141 type: "collection", 138 - author: handle ? `@${handle}` : "someone", 142 + author: authorName, 139 143 displayName, 140 144 avatarURL, 141 145 text: "",
+6 -3
web/src/store/auth.ts
··· 6 6 export const $user = atom<UserProfile | null>(null); 7 7 export const $isLoading = atom<boolean>(true); 8 8 9 + $user.subscribe((user) => { 10 + if (user) { 11 + loadPreferences(); 12 + } 13 + }); 14 + 9 15 export async function initAuth() { 10 16 $isLoading.set(true); 11 17 const session = await checkSession(); 12 18 $user.set(session); 13 19 $isLoading.set(false); 14 - if (session) { 15 - loadPreferences(); 16 - } 17 20 } 18 21 19 22 export function logout() {
+29 -2
web/src/views/collections/Collections.tsx
··· 16 16 import { clsx } from "clsx"; 17 17 import { Button, Input, EmptyState, Skeleton } from "../../components/ui"; 18 18 19 + const collectionsCache = { 20 + data: null as Collection[] | null, 21 + timestamp: 0, 22 + }; 23 + 19 24 export default function Collections() { 20 25 const user = useStore($user); 21 26 const theme = useStore($theme); ··· 29 34 const [creating, setCreating] = useState(false); 30 35 31 36 const fetchCollections = async () => { 37 + if (collectionsCache.data && Date.now() - collectionsCache.timestamp < 5 * 60 * 1000) { 38 + setCollections(collectionsCache.data); 39 + setLoading(false); 40 + 41 + getCollections().then(data => { 42 + setCollections(data); 43 + collectionsCache.data = data; 44 + collectionsCache.timestamp = Date.now(); 45 + }).catch(console.error); 46 + return; 47 + } 48 + 32 49 try { 33 50 setLoading(true); 34 51 const data = await getCollections(); 35 52 setCollections(data); 53 + collectionsCache.data = data; 54 + collectionsCache.timestamp = Date.now(); 36 55 } catch (error) { 37 56 console.error("Failed to load collections:", error); 38 57 } finally { ··· 55 74 56 75 const res = await createCollection(newItemName, newItemDesc, finalIcon); 57 76 if (res) { 58 - setCollections([res, ...collections]); 77 + const newCollections = [res, ...collections]; 78 + setCollections(newCollections); 79 + collectionsCache.data = newCollections; 80 + collectionsCache.timestamp = Date.now(); 59 81 setShowCreateModal(false); 60 82 setNewItemName(""); 61 83 setNewItemDesc(""); ··· 71 93 if (window.confirm("Delete this collection?")) { 72 94 const success = await deleteCollection(id); 73 95 if (success) { 74 - setCollections((prev) => prev.filter((c) => c.id !== id)); 96 + setCollections((prev) => { 97 + const updated = prev.filter((c) => c.id !== id); 98 + collectionsCache.data = updated; 99 + collectionsCache.timestamp = Date.now(); 100 + return updated; 101 + }); 75 102 } 76 103 } 77 104 };
+21
web/src/views/core/Notifications.tsx
··· 16 16 import { clsx } from "clsx"; 17 17 import { Avatar, EmptyState, Skeleton } from "../../components/ui"; 18 18 19 + const notificationsCache = { 20 + data: null as NotificationItem[] | null, 21 + timestamp: 0, 22 + }; 23 + 19 24 function getContentType( 20 25 uri: string, 21 26 ): "annotation" | "highlight" | "bookmark" | "reply" | "unknown" { ··· 223 228 224 229 useEffect(() => { 225 230 const load = async () => { 231 + if (notificationsCache.data && Date.now() - notificationsCache.timestamp < 5 * 60 * 1000) { 232 + setNotifications(notificationsCache.data); 233 + setLoading(false); 234 + 235 + getNotifications().then(data => { 236 + setNotifications(data); 237 + notificationsCache.data = data; 238 + notificationsCache.timestamp = Date.now(); 239 + }).catch(console.error); 240 + 241 + markNotificationsRead(); 242 + return; 243 + } 244 + 226 245 setLoading(true); 227 246 const data = await getNotifications(); 228 247 setNotifications(data); 248 + notificationsCache.data = data; 249 + notificationsCache.timestamp = Date.now(); 229 250 setLoading(false); 230 251 markNotificationsRead(); 231 252 };
+44 -1
web/src/views/core/Search.tsx
··· 17 17 import { $user } from "../../store/auth"; 18 18 import { $feedLayout } from "../../store/feedLayout"; 19 19 20 + const searchCache = new Map< 21 + string, 22 + { 23 + results: AnnotationItem[]; 24 + hasMore: boolean; 25 + offset: number; 26 + timestamp: number; 27 + } 28 + >(); 29 + 20 30 interface SearchProps { 21 31 initialQuery?: string; 22 32 } ··· 56 66 setResults([]); 57 67 return; 58 68 } 69 + 70 + const cacheKey = JSON.stringify({ q: q.trim(), myItemsOnly: myItemsRef.current }); 71 + 72 + if (!append && newOffset === 0) { 73 + const cached = searchCache.get(cacheKey); 74 + if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { 75 + setResults(cached.results); 76 + setHasMore(cached.hasMore); 77 + setOffset(cached.offset); 78 + setLoading(false); 79 + 80 + const id = ++fetchIdRef.current; 81 + searchItems(q.trim(), { 82 + creator: myItemsRef.current && user ? user.did : undefined, 83 + limit: 30, 84 + offset: newOffset, 85 + }).then(data => { 86 + if (id !== fetchIdRef.current) return; 87 + setResults(data.items); 88 + setHasMore(data.hasMore); 89 + setOffset(newOffset + data.items.length); 90 + searchCache.set(cacheKey, { results: data.items, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() }); 91 + }).catch(console.error); 92 + 93 + return; 94 + } 95 + } 96 + 59 97 const id = ++fetchIdRef.current; 60 98 setLoading(true); 61 99 const data = await searchItems(q.trim(), { ··· 65 103 }); 66 104 if (id !== fetchIdRef.current) return; 67 105 if (append) { 68 - setResults((prev) => [...prev, ...data.items]); 106 + setResults((prev) => { 107 + const newResults = [...prev, ...data.items]; 108 + searchCache.set(cacheKey, { results: newResults, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() }); 109 + return newResults; 110 + }); 69 111 } else { 70 112 setResults(data.items); 113 + searchCache.set(cacheKey, { results: data.items, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() }); 71 114 } 72 115 setHasMore(data.hasMore); 73 116 setOffset(newOffset + data.items.length);
+38 -1
web/src/views/profile/Profile.tsx
··· 50 50 UserProfile, 51 51 } from "../../types"; 52 52 53 + const profileCache = new Map< 54 + string, 55 + { 56 + profile: UserProfile; 57 + labels: ContentLabel[]; 58 + relation: ModerationRelationship; 59 + timestamp: number; 60 + } 61 + >(); 62 + 63 + const profileCollectionsCache = new Map< 64 + string, 65 + { 66 + collections: Collection[]; 67 + timestamp: number; 68 + } 69 + >(); 70 + 53 71 interface ProfileProps { 54 72 did: string; 55 73 } ··· 120 138 setLoading(true); 121 139 122 140 const loadProfile = async () => { 141 + const cached = profileCache.get(did); 142 + if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { 143 + setProfile(cached.profile); 144 + setAccountLabels(cached.labels); 145 + setModRelation(cached.relation); 146 + setLoading(false); 147 + } else { 148 + setLoading(true); 149 + } 150 + 123 151 try { 124 152 const marginPromise = getProfile(did); 125 153 const bskyPromise = fetch( ··· 158 186 try { 159 187 const rel = await getModerationRelationship(did); 160 188 setModRelation(rel); 189 + profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: rel, timestamp: Date.now() }); 161 190 } catch { 162 - // ignore 191 + profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: modRelation, timestamp: Date.now() }); 163 192 } 193 + } else { 194 + profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: modRelation, timestamp: Date.now() }); 164 195 } 165 196 } catch (e) { 166 197 console.error("Profile load failed", e); ··· 195 226 setDataLoading(true); 196 227 try { 197 228 if (activeTab === "collections") { 229 + const cached = profileCollectionsCache.get(resolvedDid); 230 + if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { 231 + setCollections(cached.collections); 232 + setDataLoading(false); 233 + } 198 234 const res = await getCollections(resolvedDid); 199 235 setCollections(res); 236 + profileCollectionsCache.set(resolvedDid, { collections: res, timestamp: Date.now() }); 200 237 } 201 238 } catch (e) { 202 239 console.error(e);