Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

fixes

+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);