tangled
alpha
login
or
join now
margin.at
/
margin
90
fork
atom
Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
90
fork
atom
overview
issues
4
pulls
1
pipelines
fixes
scanash.com
4 days ago
ce26ad9c
36cd5abb
+191
-18
8 changed files
expand all
collapse all
unified
split
backend
internal
api
hydration.go
web
src
components
feed
FeedItems.tsx
pages
og-image.ts
store
auth.ts
views
collections
Collections.tsx
core
Notifications.tsx
Search.tsx
profile
Profile.tsx
+2
-2
backend/internal/api/hydration.go
···
617
617
618
618
missingDIDs := make([]string, 0)
619
619
for _, did := range dids {
620
620
-
if author, ok := Cache.Get(did); ok {
620
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
649
-
if _, ok := profiles[did]; !ok {
649
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
10
+
const feedCache = new Map<
11
11
+
string,
12
12
+
{
13
13
+
items: AnnotationItem[];
14
14
+
hasMore: boolean;
15
15
+
offset: number;
16
16
+
timestamp: number;
17
17
+
}
18
18
+
>();
19
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
45
+
const cacheKey = JSON.stringify({ type, motivation, tag, creator, source });
46
46
+
const cached = feedCache.get(cacheKey);
35
47
48
48
+
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
49
49
+
setItems(cached.items);
50
50
+
setHasMore(cached.hasMore);
51
51
+
setOffset(cached.offset);
52
52
+
setLoading(false);
53
53
+
54
54
+
getFeed({ type, motivation, tag, creator, source, limit: LIMIT, offset: 0 })
55
55
+
.then((data) => {
56
56
+
if (cancelled) return;
57
57
+
const fetched = data.items;
58
58
+
setItems(fetched);
59
59
+
setHasMore(data.hasMore);
60
60
+
setOffset(data.fetchedCount);
61
61
+
feedCache.set(cacheKey, { items: fetched, hasMore: data.hasMore, offset: data.fetchedCount, timestamp: Date.now() });
62
62
+
})
63
63
+
.catch(console.error);
64
64
+
65
65
+
return () => { cancelled = true; };
66
66
+
}
67
67
+
68
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
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
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
71
-
setItems((prev) => [...prev, ...fetched]);
106
106
+
const newItems = [...items, ...fetched];
107
107
+
setItems(newItems);
72
108
setHasMore(data.hasMore);
73
73
-
setOffset((prev) => prev + data.fetchedCount);
109
109
+
const newOffset = offset + data.fetchedCount;
110
110
+
setOffset(newOffset);
111
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
79
-
}, [type, motivation, tag, creator, source, offset]);
117
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
53
-
const displayName = author.displayName || handle || "someone";
53
53
+
const did = author.did || "";
54
54
+
const authorName = handle ? `@${handle}` : did || "someone";
55
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
78
-
author: handle ? `@${handle}` : "someone",
80
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
94
-
author: handle ? `@${handle}` : "someone",
96
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
109
-
author: handle ? `@${handle}` : "someone",
111
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
133
-
const displayName = author.displayName || handle || "someone";
135
135
+
const did = author.did || "";
136
136
+
const authorName = handle ? `@${handle}` : did || "someone";
137
137
+
const displayName = author.displayName || handle || did || "someone";
134
138
const avatarURL = author.avatar || "";
135
139
136
140
return {
137
141
type: "collection",
138
138
-
author: handle ? `@${handle}` : "someone",
142
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
9
+
$user.subscribe((user) => {
10
10
+
if (user) {
11
11
+
loadPreferences();
12
12
+
}
13
13
+
});
14
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
14
-
if (session) {
15
15
-
loadPreferences();
16
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
19
+
const collectionsCache = {
20
20
+
data: null as Collection[] | null,
21
21
+
timestamp: 0,
22
22
+
};
23
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
37
+
if (collectionsCache.data && Date.now() - collectionsCache.timestamp < 5 * 60 * 1000) {
38
38
+
setCollections(collectionsCache.data);
39
39
+
setLoading(false);
40
40
+
41
41
+
getCollections().then(data => {
42
42
+
setCollections(data);
43
43
+
collectionsCache.data = data;
44
44
+
collectionsCache.timestamp = Date.now();
45
45
+
}).catch(console.error);
46
46
+
return;
47
47
+
}
48
48
+
32
49
try {
33
50
setLoading(true);
34
51
const data = await getCollections();
35
52
setCollections(data);
53
53
+
collectionsCache.data = data;
54
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
58
-
setCollections([res, ...collections]);
77
77
+
const newCollections = [res, ...collections];
78
78
+
setCollections(newCollections);
79
79
+
collectionsCache.data = newCollections;
80
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
74
-
setCollections((prev) => prev.filter((c) => c.id !== id));
96
96
+
setCollections((prev) => {
97
97
+
const updated = prev.filter((c) => c.id !== id);
98
98
+
collectionsCache.data = updated;
99
99
+
collectionsCache.timestamp = Date.now();
100
100
+
return updated;
101
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
19
+
const notificationsCache = {
20
20
+
data: null as NotificationItem[] | null,
21
21
+
timestamp: 0,
22
22
+
};
23
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
231
+
if (notificationsCache.data && Date.now() - notificationsCache.timestamp < 5 * 60 * 1000) {
232
232
+
setNotifications(notificationsCache.data);
233
233
+
setLoading(false);
234
234
+
235
235
+
getNotifications().then(data => {
236
236
+
setNotifications(data);
237
237
+
notificationsCache.data = data;
238
238
+
notificationsCache.timestamp = Date.now();
239
239
+
}).catch(console.error);
240
240
+
241
241
+
markNotificationsRead();
242
242
+
return;
243
243
+
}
244
244
+
226
245
setLoading(true);
227
246
const data = await getNotifications();
228
247
setNotifications(data);
248
248
+
notificationsCache.data = data;
249
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
20
+
const searchCache = new Map<
21
21
+
string,
22
22
+
{
23
23
+
results: AnnotationItem[];
24
24
+
hasMore: boolean;
25
25
+
offset: number;
26
26
+
timestamp: number;
27
27
+
}
28
28
+
>();
29
29
+
20
30
interface SearchProps {
21
31
initialQuery?: string;
22
32
}
···
56
66
setResults([]);
57
67
return;
58
68
}
69
69
+
70
70
+
const cacheKey = JSON.stringify({ q: q.trim(), myItemsOnly: myItemsRef.current });
71
71
+
72
72
+
if (!append && newOffset === 0) {
73
73
+
const cached = searchCache.get(cacheKey);
74
74
+
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
75
75
+
setResults(cached.results);
76
76
+
setHasMore(cached.hasMore);
77
77
+
setOffset(cached.offset);
78
78
+
setLoading(false);
79
79
+
80
80
+
const id = ++fetchIdRef.current;
81
81
+
searchItems(q.trim(), {
82
82
+
creator: myItemsRef.current && user ? user.did : undefined,
83
83
+
limit: 30,
84
84
+
offset: newOffset,
85
85
+
}).then(data => {
86
86
+
if (id !== fetchIdRef.current) return;
87
87
+
setResults(data.items);
88
88
+
setHasMore(data.hasMore);
89
89
+
setOffset(newOffset + data.items.length);
90
90
+
searchCache.set(cacheKey, { results: data.items, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() });
91
91
+
}).catch(console.error);
92
92
+
93
93
+
return;
94
94
+
}
95
95
+
}
96
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
68
-
setResults((prev) => [...prev, ...data.items]);
106
106
+
setResults((prev) => {
107
107
+
const newResults = [...prev, ...data.items];
108
108
+
searchCache.set(cacheKey, { results: newResults, hasMore: data.hasMore, offset: newOffset + data.items.length, timestamp: Date.now() });
109
109
+
return newResults;
110
110
+
});
69
111
} else {
70
112
setResults(data.items);
113
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
53
+
const profileCache = new Map<
54
54
+
string,
55
55
+
{
56
56
+
profile: UserProfile;
57
57
+
labels: ContentLabel[];
58
58
+
relation: ModerationRelationship;
59
59
+
timestamp: number;
60
60
+
}
61
61
+
>();
62
62
+
63
63
+
const profileCollectionsCache = new Map<
64
64
+
string,
65
65
+
{
66
66
+
collections: Collection[];
67
67
+
timestamp: number;
68
68
+
}
69
69
+
>();
70
70
+
53
71
interface ProfileProps {
54
72
did: string;
55
73
}
···
120
138
setLoading(true);
121
139
122
140
const loadProfile = async () => {
141
141
+
const cached = profileCache.get(did);
142
142
+
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
143
143
+
setProfile(cached.profile);
144
144
+
setAccountLabels(cached.labels);
145
145
+
setModRelation(cached.relation);
146
146
+
setLoading(false);
147
147
+
} else {
148
148
+
setLoading(true);
149
149
+
}
150
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
189
+
profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: rel, timestamp: Date.now() });
161
190
} catch {
162
162
-
// ignore
191
191
+
profileCache.set(did, { profile: merged, labels: marginData?.labels || [], relation: modRelation, timestamp: Date.now() });
163
192
}
193
193
+
} else {
194
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
229
+
const cached = profileCollectionsCache.get(resolvedDid);
230
230
+
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
231
231
+
setCollections(cached.collections);
232
232
+
setDataLoading(false);
233
233
+
}
198
234
const res = await getCollections(resolvedDid);
199
235
setCollections(res);
236
236
+
profileCollectionsCache.set(resolvedDid, { collections: res, timestamp: Date.now() });
200
237
}
201
238
} catch (e) {
202
239
console.error(e);