Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1const API_BASE = "/api";
2const AUTH_BASE = "/auth";
3
4async function request(endpoint, options = {}) {
5 const response = await fetch(endpoint, {
6 credentials: "include",
7 headers: {
8 "Content-Type": "application/json",
9 ...options.headers,
10 },
11 ...options,
12 });
13
14 if (!response.ok) {
15 const error = await response.text();
16 throw new Error(error || `HTTP ${response.status}`);
17 }
18
19 return response.json();
20}
21
22export async function getURLMetadata(url) {
23 return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`);
24}
25
26export async function getAnnotationFeed(
27 limit = 50,
28 offset = 0,
29 tag = "",
30 creator = "",
31 feedType = "",
32 motivation = "",
33) {
34 let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`;
35 if (tag) url += `&tag=${encodeURIComponent(tag)}`;
36 if (creator) url += `&creator=${encodeURIComponent(creator)}`;
37 if (feedType) url += `&type=${encodeURIComponent(feedType)}`;
38 if (motivation) url += `&motivation=${encodeURIComponent(motivation)}`;
39 return request(url);
40}
41
42export async function getAnnotations({
43 source,
44 motivation,
45 limit = 50,
46 offset = 0,
47} = {}) {
48 let url = `${API_BASE}/annotations?limit=${limit}&offset=${offset}`;
49 if (source) url += `&source=${encodeURIComponent(source)}`;
50 if (motivation) url += `&motivation=${motivation}`;
51 return request(url);
52}
53
54export async function getByTarget(source, limit = 50, offset = 0) {
55 return request(
56 `${API_BASE}/targets?source=${encodeURIComponent(source)}&limit=${limit}&offset=${offset}`,
57 );
58}
59
60export async function getAnnotation(uri) {
61 return request(`${API_BASE}/annotation?uri=${encodeURIComponent(uri)}`);
62}
63
64export async function getProfile(did) {
65 return request(`${API_BASE}/profile/${encodeURIComponent(did)}`);
66}
67
68export async function getUserAnnotations(did, limit = 50, offset = 0) {
69 return request(
70 `${API_BASE}/users/${encodeURIComponent(did)}/annotations?limit=${limit}&offset=${offset}`,
71 );
72}
73
74export async function getUserHighlights(did, limit = 50, offset = 0) {
75 return request(
76 `${API_BASE}/users/${encodeURIComponent(did)}/highlights?limit=${limit}&offset=${offset}`,
77 );
78}
79
80export async function getUserBookmarks(did, limit = 50, offset = 0) {
81 return request(
82 `${API_BASE}/users/${encodeURIComponent(did)}/bookmarks?limit=${limit}&offset=${offset}`,
83 );
84}
85
86export async function getUserTargetItems(did, url, limit = 50, offset = 0) {
87 return request(
88 `${API_BASE}/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`,
89 );
90}
91
92export async function getHighlights(creatorDid, limit = 50, offset = 0) {
93 return request(
94 `${API_BASE}/highlights?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`,
95 );
96}
97
98export async function getBookmarks(creatorDid, limit = 50, offset = 0) {
99 return request(
100 `${API_BASE}/bookmarks?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`,
101 );
102}
103
104export async function getReplies(annotationUri) {
105 return request(
106 `${API_BASE}/replies?uri=${encodeURIComponent(annotationUri)}`,
107 );
108}
109
110export async function updateAnnotation(uri, text, tags) {
111 return request(`${API_BASE}/annotations?uri=${encodeURIComponent(uri)}`, {
112 method: "PUT",
113 body: JSON.stringify({ text, tags }),
114 });
115}
116
117export async function updateHighlight(uri, color, tags) {
118 return request(`${API_BASE}/highlights?uri=${encodeURIComponent(uri)}`, {
119 method: "PUT",
120 body: JSON.stringify({ color, tags }),
121 });
122}
123
124export async function createBookmark(url, title, description) {
125 return request(`${API_BASE}/bookmarks`, {
126 method: "POST",
127 body: JSON.stringify({ url, title, description }),
128 });
129}
130
131export async function updateBookmark(uri, title, description, tags) {
132 return request(`${API_BASE}/bookmarks?uri=${encodeURIComponent(uri)}`, {
133 method: "PUT",
134 body: JSON.stringify({ title, description, tags }),
135 });
136}
137
138export async function getCollections(did) {
139 let url = `${API_BASE}/collections`;
140 if (did) url += `?author=${encodeURIComponent(did)}`;
141 return request(url);
142}
143
144export async function getCollection(uri) {
145 return request(`${API_BASE}/collection?uri=${encodeURIComponent(uri)}`);
146}
147
148export async function getCollectionsContaining(annotationUri) {
149 return request(
150 `${API_BASE}/collections/containing?uri=${encodeURIComponent(annotationUri)}`,
151 );
152}
153
154export async function getEditHistory(uri) {
155 return request(
156 `${API_BASE}/annotations/history?uri=${encodeURIComponent(uri)}`,
157 );
158}
159
160export async function getNotifications(limit = 50, offset = 0) {
161 return request(`${API_BASE}/notifications?limit=${limit}&offset=${offset}`);
162}
163
164export async function getUnreadNotificationCount() {
165 return request(`${API_BASE}/notifications/count`);
166}
167
168export async function markNotificationsRead() {
169 return request(`${API_BASE}/notifications/read`, { method: "POST" });
170}
171
172export async function updateCollection(uri, name, description, icon) {
173 return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, {
174 method: "PUT",
175 body: JSON.stringify({ name, description, icon }),
176 });
177}
178
179export async function updateProfile({ bio, website, links }) {
180 return request(`${API_BASE}/profile`, {
181 method: "PUT",
182 body: JSON.stringify({ bio, website, links }),
183 });
184}
185
186export async function createCollection(name, description, icon) {
187 return request(`${API_BASE}/collections`, {
188 method: "POST",
189 body: JSON.stringify({ name, description, icon }),
190 });
191}
192
193export async function deleteCollection(uri) {
194 return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, {
195 method: "DELETE",
196 });
197}
198
199export async function getCollectionItems(collectionUri) {
200 return request(
201 `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`,
202 );
203}
204
205export async function addItemToCollection(
206 collectionUri,
207 annotationUri,
208 position = 0,
209) {
210 return request(
211 `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`,
212 {
213 method: "POST",
214 body: JSON.stringify({ annotationUri, position }),
215 },
216 );
217}
218
219export async function removeItemFromCollection(itemUri) {
220 return request(
221 `${API_BASE}/collections/items?uri=${encodeURIComponent(itemUri)}`,
222 {
223 method: "DELETE",
224 },
225 );
226}
227
228export async function getLikeCount(annotationUri) {
229 return request(`${API_BASE}/likes?uri=${encodeURIComponent(annotationUri)}`);
230}
231
232export async function deleteHighlight(rkey) {
233 return request(`${API_BASE}/highlights?rkey=${encodeURIComponent(rkey)}`, {
234 method: "DELETE",
235 });
236}
237
238export async function deleteBookmark(rkey) {
239 return request(`${API_BASE}/bookmarks?rkey=${encodeURIComponent(rkey)}`, {
240 method: "DELETE",
241 });
242}
243
244export async function createHighlight({ url, title, selector, color, tags }) {
245 return request(`${API_BASE}/highlights`, {
246 method: "POST",
247 body: JSON.stringify({ url, title, selector, color, tags }),
248 });
249}
250
251export async function createAnnotation({
252 url,
253 text,
254 quote,
255 title,
256 selector,
257 tags,
258}) {
259 return request(`${API_BASE}/annotations`, {
260 method: "POST",
261 body: JSON.stringify({ url, text, quote, title, selector, tags }),
262 });
263}
264
265export async function deleteAnnotation(rkey, type = "annotation") {
266 return request(
267 `${API_BASE}/annotations?rkey=${encodeURIComponent(rkey)}&type=${encodeURIComponent(type)}`,
268 {
269 method: "DELETE",
270 },
271 );
272}
273
274export async function likeAnnotation(subjectUri, subjectCid) {
275 return request(`${API_BASE}/annotations/like`, {
276 method: "POST",
277 headers: {
278 "Content-Type": "application/json",
279 },
280 body: JSON.stringify({
281 subjectUri,
282 subjectCid,
283 }),
284 });
285}
286
287export async function unlikeAnnotation(subjectUri) {
288 return request(
289 `${API_BASE}/annotations/like?uri=${encodeURIComponent(subjectUri)}`,
290 {
291 method: "DELETE",
292 },
293 );
294}
295
296export async function createReply({
297 parentUri,
298 parentCid,
299 rootUri,
300 rootCid,
301 text,
302}) {
303 return request(`${API_BASE}/annotations/reply`, {
304 method: "POST",
305 body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }),
306 });
307}
308
309export async function deleteReply(uri) {
310 return request(
311 `${API_BASE}/annotations/reply?uri=${encodeURIComponent(uri)}`,
312 {
313 method: "DELETE",
314 },
315 );
316}
317
318export async function getSession() {
319 return request(`${AUTH_BASE}/session`);
320}
321
322export async function logout() {
323 return request(`${AUTH_BASE}/logout`, { method: "POST" });
324}
325
326export function normalizeAnnotation(item) {
327 if (!item) return {};
328
329 if (item.type === "Annotation") {
330 return {
331 type: item.type,
332 uri: item.uri || item.id,
333 author: item.author || item.creator,
334 url: item.url || item.target?.source,
335 title: item.title || item.target?.title,
336 text: item.text || item.body?.value,
337 selector: item.selector || item.target?.selector,
338 motivation: item.motivation,
339 tags: item.tags || [],
340 createdAt: item.createdAt || item.created,
341 cid: item.cid || item.CID,
342 likeCount: item.likeCount || 0,
343 replyCount: item.replyCount || 0,
344 viewerHasLiked: item.viewerHasLiked || false,
345 };
346 }
347
348 if (item.type === "Bookmark") {
349 return {
350 type: item.type,
351 uri: item.uri || item.id,
352 author: item.author || item.creator,
353 url: item.url || item.source,
354 title: item.title,
355 description: item.description,
356 tags: item.tags || [],
357 createdAt: item.createdAt || item.created,
358 cid: item.cid || item.CID,
359 likeCount: item.likeCount || 0,
360 replyCount: item.replyCount || 0,
361 viewerHasLiked: item.viewerHasLiked || false,
362 };
363 }
364
365 if (item.type === "Highlight") {
366 return {
367 type: item.type,
368 uri: item.uri || item.id,
369 author: item.author || item.creator,
370 url: item.url || item.target?.source,
371 title: item.title || item.target?.title,
372 selector: item.selector || item.target?.selector,
373 color: item.color,
374 tags: item.tags || [],
375 createdAt: item.createdAt || item.created,
376 cid: item.cid || item.CID,
377 likeCount: item.likeCount || 0,
378 replyCount: item.replyCount || 0,
379 viewerHasLiked: item.viewerHasLiked || false,
380 };
381 }
382
383 return {
384 uri: item.uri || item.id,
385 author: item.author || item.creator,
386 url: item.url || item.source || item.target?.source,
387 title: item.title || item.target?.title,
388 text: item.text || item.body?.value,
389 description: item.description,
390 selector: item.selector || item.target?.selector,
391 color: item.color,
392 tags: item.tags || [],
393 createdAt: item.createdAt || item.created,
394 cid: item.cid || item.CID,
395 likeCount: item.likeCount || 0,
396 replyCount: item.replyCount || 0,
397 viewerHasLiked: item.viewerHasLiked || false,
398 };
399}
400
401export function normalizeHighlight(highlight) {
402 return {
403 uri: highlight.uri || highlight.id,
404 author: highlight.author || highlight.creator,
405 url: highlight.url || highlight.target?.source,
406 title: highlight.title || highlight.target?.title,
407 selector: highlight.selector || highlight.target?.selector,
408 color: highlight.color,
409 tags: highlight.tags || [],
410 createdAt: highlight.createdAt || highlight.created,
411 likeCount: highlight.likeCount || 0,
412 replyCount: highlight.replyCount || 0,
413 viewerHasLiked: highlight.viewerHasLiked || false,
414 };
415}
416
417export function normalizeBookmark(bookmark) {
418 return {
419 uri: bookmark.uri || bookmark.id,
420 author: bookmark.author || bookmark.creator,
421 url: bookmark.url || bookmark.source,
422 title: bookmark.title,
423 description: bookmark.description,
424 tags: bookmark.tags || [],
425 createdAt: bookmark.createdAt || bookmark.created,
426 likeCount: bookmark.likeCount || 0,
427 replyCount: bookmark.replyCount || 0,
428 viewerHasLiked: bookmark.viewerHasLiked || false,
429 };
430}
431
432export async function searchActors(query) {
433 const res = await fetch(
434 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`,
435 );
436 if (!res.ok) throw new Error("Search failed");
437 return res.json();
438}
439
440export async function resolveHandle(handle) {
441 const res = await fetch(
442 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
443 );
444 if (!res.ok) throw new Error("Failed to resolve handle");
445 const data = await res.json();
446 return data.did;
447}
448
449export async function startLogin(handle, inviteCode) {
450 return request(`${AUTH_BASE}/start`, {
451 method: "POST",
452 body: JSON.stringify({ handle, invite_code: inviteCode }),
453 });
454}
455
456export async function startSignup(pdsUrl) {
457 return request(`${AUTH_BASE}/signup`, {
458 method: "POST",
459 body: JSON.stringify({ pds_url: pdsUrl }),
460 });
461}
462export async function getTrendingTags(limit = 10) {
463 return request(`${API_BASE}/tags/trending?limit=${limit}`);
464}
465
466export async function getAPIKeys() {
467 return request(`${API_BASE}/keys`);
468}
469
470export async function createAPIKey(name) {
471 return request(`${API_BASE}/keys`, {
472 method: "POST",
473 body: JSON.stringify({ name }),
474 });
475}
476
477export async function deleteAPIKey(id) {
478 return request(`${API_BASE}/keys/${id}`, { method: "DELETE" });
479}
480
481export async function describeServer(service) {
482 const res = await fetch(`${service}/xrpc/com.atproto.server.describeServer`);
483 if (!res.ok) throw new Error("Failed to describe server");
484 return res.json();
485}
486
487export async function createAccount(
488 service,
489 { handle, email, password, inviteCode },
490) {
491 const res = await fetch(`${service}/xrpc/com.atproto.server.createAccount`, {
492 method: "POST",
493 headers: {
494 "Content-Type": "application/json",
495 },
496 body: JSON.stringify({
497 handle,
498 email,
499 password,
500 inviteCode,
501 }),
502 });
503
504 const data = await res.json();
505 if (!res.ok) {
506 throw new Error(data.message || data.error || "Failed to create account");
507 }
508 return data;
509}