(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { atom } from "nanostores";
2import type {
3 AnnotationItem,
4 Collection,
5 FeedResponse,
6 HydratedLabel,
7 NotificationItem,
8 Selector,
9 Target,
10 UserProfile,
11} from "../types";
12
13export type { Collection } from "../types";
14
15export const sessionAtom = atom<UserProfile | null>(null);
16
17export async function checkSession(): Promise<UserProfile | null> {
18 try {
19 const res = await fetch("/auth/session");
20 if (!res.ok) {
21 sessionAtom.set(null);
22 return null;
23 }
24 const data = await res.json();
25
26 if (data.authenticated || data.did) {
27 const baseProfile: UserProfile = {
28 did: data.did,
29 handle: data.handle,
30 displayName: data.displayName,
31 avatar: data.avatar,
32 description: data.description,
33 website: data.website,
34 links: data.links,
35 followersCount: data.followersCount,
36 followsCount: data.followsCount,
37 postsCount: data.postsCount,
38 };
39
40 const [bskyResult, marginResult] = await Promise.allSettled([
41 fetch(
42 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`,
43 ),
44 fetch(`/api/profile/${data.did}`),
45 ]);
46
47 if (bskyResult.status === "fulfilled" && bskyResult.value.ok) {
48 try {
49 const bskyData = await bskyResult.value.json();
50 if (bskyData.avatar) baseProfile.avatar = bskyData.avatar;
51 if (bskyData.displayName)
52 baseProfile.displayName = bskyData.displayName;
53 } catch {
54 /* ignore */
55 }
56 }
57
58 if (marginResult.status === "fulfilled" && marginResult.value.ok) {
59 try {
60 const marginProfile = await marginResult.value.json();
61 if (marginProfile) {
62 if (marginProfile.description)
63 baseProfile.description = marginProfile.description;
64 if (marginProfile.followersCount)
65 baseProfile.followersCount = marginProfile.followersCount;
66 if (marginProfile.followsCount)
67 baseProfile.followsCount = marginProfile.followsCount;
68 if (marginProfile.postsCount)
69 baseProfile.postsCount = marginProfile.postsCount;
70 if (marginProfile.website)
71 baseProfile.website = marginProfile.website;
72 if (marginProfile.links) baseProfile.links = marginProfile.links;
73 }
74 } catch {
75 /* ignore */
76 }
77 }
78
79 sessionAtom.set(baseProfile);
80 return baseProfile;
81 }
82
83 sessionAtom.set(null);
84 return null;
85 } catch (e) {
86 console.error("Session check failed:", e);
87 sessionAtom.set(null);
88 return null;
89 }
90}
91
92async function apiRequest(
93 path: string,
94 options: RequestInit & { skipAuthRedirect?: boolean } = {},
95): Promise<Response> {
96 const { skipAuthRedirect, ...fetchOptions } = options;
97 const headers = {
98 "Content-Type": "application/json",
99 ...(fetchOptions.headers || {}),
100 };
101
102 const apiPath =
103 path.startsWith("/api") || path.startsWith("/auth") ? path : `/api${path}`;
104
105 const response = await fetch(apiPath, {
106 ...fetchOptions,
107 headers,
108 });
109
110 if (response.status === 401 && !skipAuthRedirect) {
111 sessionAtom.set(null);
112 try {
113 await fetch("/auth/logout", { method: "POST" });
114 } catch {
115 // Ignore
116 }
117 if (window.location.pathname !== "/login") {
118 window.location.href = "/login";
119 }
120 }
121
122 return response;
123}
124
125export interface GetFeedParams {
126 source?: string;
127 type?: string;
128 limit?: number;
129 offset?: number;
130 motivation?: string;
131 tag?: string;
132 creator?: string;
133}
134
135interface RawItem {
136 type?: string;
137 collectionUri?: string;
138 annotation?: RawItem;
139 highlight?: RawItem;
140 bookmark?: RawItem;
141 uri?: string;
142 id?: string;
143 cid?: string;
144 author?: UserProfile;
145 creator?: UserProfile;
146 collection?: {
147 uri: string;
148 name: string;
149 icon?: string;
150 };
151 context?: {
152 uri: string;
153 name: string;
154 icon?: string;
155 }[];
156 created?: string;
157 createdAt?: string;
158 target?: string | { source?: string; title?: string; selector?: Selector };
159 url?: string;
160 targetUrl?: string;
161 title?: string;
162 selector?: Selector;
163 viewer?: { like?: string; [key: string]: unknown };
164 viewerHasLiked?: boolean;
165 motivation?: string;
166 [key: string]: unknown;
167}
168
169function normalizeItem(raw: RawItem): AnnotationItem {
170 if (raw.type === "CollectionItem" || raw.collectionUri) {
171 const inner = raw.annotation || raw.highlight || raw.bookmark || {};
172 const normalizedInner = normalizeItem(inner);
173
174 return {
175 ...normalizedInner,
176 uri: normalizedInner.uri || raw.uri || "",
177 cid: normalizedInner.cid || raw.cid || "",
178 author: (normalizedInner.author ||
179 raw.author ||
180 raw.creator) as UserProfile,
181 collection: raw.collection
182 ? {
183 uri: raw.collection.uri,
184 name: raw.collection.name,
185 icon: raw.collection.icon,
186 }
187 : undefined,
188 context: raw.context
189 ? raw.context.map((c) => ({
190 uri: c.uri,
191 name: c.name,
192 icon: c.icon,
193 }))
194 : undefined,
195 addedBy: raw.creator || raw.author,
196 createdAt:
197 normalizedInner.createdAt ||
198 raw.created ||
199 raw.createdAt ||
200 new Date().toISOString(),
201 collectionItemUri: raw.id || raw.uri,
202 };
203 }
204
205 let target: Target | undefined;
206
207 if (raw.target) {
208 if (typeof raw.target === "string") {
209 target = { source: raw.target, title: raw.title, selector: raw.selector };
210 } else {
211 target = {
212 source: raw.target.source || "",
213 title: raw.target.title || raw.title,
214 selector: raw.target.selector || raw.selector,
215 };
216 }
217 }
218
219 if (!target || !target.source) {
220 const url =
221 raw.url ||
222 raw.targetUrl ||
223 (typeof raw.target === "string" ? raw.target : raw.target?.source);
224 if (url) {
225 target = {
226 source: url,
227 title:
228 raw.title ||
229 (typeof raw.target !== "string" ? raw.target?.title : undefined),
230 selector:
231 raw.selector ||
232 (typeof raw.target !== "string" ? raw.target?.selector : undefined),
233 };
234 }
235 }
236
237 return {
238 ...raw,
239 uri: raw.id || raw.uri || "",
240 cid: raw.cid || "",
241 author: (raw.creator || raw.author) as UserProfile,
242 createdAt: raw.created || raw.createdAt || new Date().toISOString(),
243 target: target,
244 viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined },
245 motivation: raw.motivation || "highlighting",
246 parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined,
247 };
248}
249
250export async function searchItems(
251 query: string,
252 options: { creator?: string; limit?: number; offset?: number } = {},
253): Promise<FeedResponse> {
254 const params = new URLSearchParams();
255 params.append("q", query);
256 if (options.creator) params.append("creator", options.creator);
257 if (options.limit) params.append("limit", options.limit.toString());
258 if (options.offset) params.append("offset", options.offset.toString());
259
260 try {
261 const res = await apiRequest(`/api/search?${params.toString()}`, {
262 skipAuthRedirect: true,
263 });
264 if (!res.ok) throw new Error("Search failed");
265 const data = await res.json();
266 const items: AnnotationItem[] = (data.items || []).map(normalizeItem);
267 return {
268 items,
269 hasMore: items.length >= (options.limit || 50),
270 fetchedCount: items.length,
271 };
272 } catch (e) {
273 console.error("Search error:", e);
274 return { items: [], hasMore: false, fetchedCount: 0 };
275 }
276}
277
278export async function getFeed({
279 source,
280 type = "all",
281 limit = 50,
282 offset = 0,
283 motivation,
284 tag,
285 creator,
286}: GetFeedParams): Promise<FeedResponse> {
287 const params = new URLSearchParams();
288 if (source) params.append("source", source);
289 if (type) params.append("type", type);
290 if (limit) params.append("limit", limit.toString());
291 if (offset) params.append("offset", offset.toString());
292 if (motivation) params.append("motivation", motivation);
293 if (tag) params.append("tag", tag);
294 if (creator) params.append("creator", creator);
295
296 const endpoint = source ? "/api/targets" : "/api/notes/feed";
297
298 try {
299 const res = await apiRequest(`${endpoint}?${params.toString()}`, {
300 skipAuthRedirect: true,
301 });
302 if (!res.ok) throw new Error("Failed to fetch feed");
303 const data = await res.json();
304 const normalizedItems: AnnotationItem[] = (data.items || []).map(
305 normalizeItem,
306 );
307
308 const groupedItems: AnnotationItem[] = [];
309 if (normalizedItems.length > 0) {
310 groupedItems.push(normalizedItems[0]);
311
312 for (let i = 1; i < normalizedItems.length; i++) {
313 const prev = groupedItems[groupedItems.length - 1];
314 const curr = normalizedItems[i];
315
316 if (prev.collection && curr.collection) {
317 if (
318 prev.uri === curr.uri &&
319 prev.addedBy?.did === curr.addedBy?.did
320 ) {
321 if (!prev.context) {
322 prev.context = [prev.collection];
323 }
324 prev.context.push(curr.collection);
325 groupedItems[groupedItems.length - 1] = prev;
326 continue;
327 }
328 }
329 groupedItems.push(curr);
330 }
331 }
332
333 return {
334 items: groupedItems,
335 hasMore: normalizedItems.length >= limit,
336 fetchedCount: normalizedItems.length,
337 };
338 } catch (e) {
339 console.error(e);
340 return { items: [], hasMore: false, fetchedCount: 0 };
341 }
342}
343
344interface CreateAnnotationParams {
345 url: string;
346 text?: string;
347 title?: string;
348 selector?: { exact: string; prefix?: string; suffix?: string };
349 tags?: string[];
350 labels?: string[];
351}
352
353export async function createAnnotation({
354 url,
355 text,
356 title,
357 selector,
358 tags,
359 labels,
360}: CreateAnnotationParams) {
361 try {
362 const res = await apiRequest("/api/notes", {
363 method: "POST",
364 body: JSON.stringify({ url, text, title, selector, tags, labels }),
365 });
366 if (!res.ok) throw new Error(await res.text());
367 const raw = await res.json();
368 return normalizeItem(raw);
369 } catch (e) {
370 console.error(e);
371 return { error: e instanceof Error ? e.message : "Unknown error" };
372 }
373}
374
375interface CreateHighlightParams {
376 url: string;
377 selector: { exact: string; prefix?: string; suffix?: string };
378 color?: string;
379 tags?: string[];
380 title?: string;
381 labels?: string[];
382}
383
384export async function createHighlight({
385 url,
386 selector,
387 color,
388 tags,
389 title,
390 labels,
391}: CreateHighlightParams) {
392 try {
393 const res = await apiRequest("/api/highlights", {
394 method: "POST",
395 body: JSON.stringify({ url, selector, color, tags, title, labels }),
396 });
397 if (!res.ok) throw new Error(await res.text());
398 const raw = await res.json();
399 return normalizeItem(raw);
400 } catch (e) {
401 console.error(e);
402 return { error: e instanceof Error ? e.message : "Unknown error" };
403 }
404}
405
406export async function createBookmark({
407 url,
408 title,
409 description,
410 tags,
411}: {
412 url: string;
413 title?: string;
414 description?: string;
415 tags?: string[];
416}) {
417 try {
418 const res = await apiRequest("/api/bookmarks", {
419 method: "POST",
420 body: JSON.stringify({ url, title, description, tags }),
421 });
422 if (!res.ok) throw new Error(await res.text());
423 const raw = await res.json();
424 return normalizeItem(raw);
425 } catch (e) {
426 console.error(e);
427 return { error: e instanceof Error ? e.message : "Unknown error" };
428 }
429}
430
431export async function uploadAvatar(
432 file: File,
433): Promise<{ blob: Blob | string }> {
434 const formData = new FormData();
435 formData.append("file", file);
436 const res = await fetch("/api/upload/avatar", {
437 method: "POST",
438 headers: {
439 Authorization: `Bearer ${(await checkSession())?.did}`,
440 },
441 body: formData,
442 });
443 if (!res.ok) throw new Error("Failed to upload avatar");
444 return res.json();
445}
446
447export async function updateProfile(updates: {
448 displayName?: string;
449 description?: string;
450 avatar?: Blob | string | null;
451 website?: string;
452 links?: string[];
453}): Promise<boolean> {
454 try {
455 const { description, ...rest } = updates;
456 const body = { ...rest, bio: description };
457 const res = await apiRequest("/api/profile", {
458 method: "PUT",
459 body: JSON.stringify(body),
460 });
461 return res.ok;
462 } catch (e) {
463 console.error(e);
464 return false;
465 }
466}
467
468export async function likeItem(uri: string, cid: string): Promise<boolean> {
469 try {
470 const res = await apiRequest("/api/notes/like", {
471 method: "POST",
472 body: JSON.stringify({ subjectUri: uri, subjectCid: cid }),
473 });
474 return res.ok;
475 } catch (e) {
476 console.error("Failed to like item:", e);
477 return false;
478 }
479}
480
481export async function unlikeItem(uri: string): Promise<boolean> {
482 try {
483 const res = await apiRequest(
484 `/api/notes/like?uri=${encodeURIComponent(uri)}`,
485 {
486 method: "DELETE",
487 },
488 );
489 return res.ok;
490 } catch (e) {
491 console.error("Failed to unlike item:", e);
492 return false;
493 }
494}
495
496export async function deleteItem(
497 uri: string,
498 type: string = "annotation",
499): Promise<boolean> {
500 const rkey = (uri || "").split("/").pop();
501
502 let endpoint = "/api/notes";
503 if (type === "highlight" || uri.includes("highlight")) {
504 endpoint = "/api/highlights";
505 } else if (type === "bookmark" || uri.includes("bookmark")) {
506 endpoint = "/api/bookmarks";
507 }
508
509 try {
510 const res = await apiRequest(`${endpoint}?rkey=${rkey}`, {
511 method: "DELETE",
512 });
513 return res.ok;
514 } catch (e) {
515 console.error("Failed to delete item:", e);
516 return false;
517 }
518}
519
520export async function convertHighlightToAnnotation(
521 highlightUri: string,
522 url: string,
523 text: string,
524 selector?: { exact: string; prefix?: string; suffix?: string },
525 title?: string,
526): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> {
527 try {
528 const createRes = await apiRequest("/api/notes", {
529 method: "POST",
530 body: JSON.stringify({ url, text, title, selector }),
531 });
532 if (!createRes.ok) {
533 const err = await createRes.text();
534 return { success: false, error: err };
535 }
536 const created = normalizeItem(await createRes.json());
537
538 const rkey = (highlightUri || "").split("/").pop();
539 if (rkey) {
540 await apiRequest(`/api/highlights?rkey=${rkey}`, { method: "DELETE" });
541 }
542
543 return { success: true, item: created };
544 } catch (e) {
545 console.error("Failed to convert highlight:", e);
546 return {
547 success: false,
548 error: e instanceof Error ? e.message : "Unknown error",
549 };
550 }
551}
552
553export async function updateAnnotation(
554 uri: string,
555 text: string,
556 tags?: string[],
557 labels?: string[],
558): Promise<boolean> {
559 try {
560 const res = await apiRequest(`/api/notes?uri=${encodeURIComponent(uri)}`, {
561 method: "PUT",
562 body: JSON.stringify({ text, tags, labels }),
563 });
564 return res.ok;
565 } catch (e) {
566 console.error("Failed to update annotation:", e);
567 return false;
568 }
569}
570
571export async function updateHighlight(
572 uri: string,
573 color: string,
574 tags?: string[],
575 labels?: string[],
576): Promise<boolean> {
577 try {
578 const res = await apiRequest(
579 `/api/highlights?uri=${encodeURIComponent(uri)}`,
580 {
581 method: "PUT",
582 body: JSON.stringify({ color, tags, labels }),
583 },
584 );
585 return res.ok;
586 } catch (e) {
587 console.error("Failed to update highlight:", e);
588 return false;
589 }
590}
591
592export async function updateBookmark(
593 uri: string,
594 title?: string,
595 description?: string,
596 tags?: string[],
597 labels?: string[],
598): Promise<boolean> {
599 try {
600 const res = await apiRequest(
601 `/api/bookmarks?uri=${encodeURIComponent(uri)}`,
602 {
603 method: "PUT",
604 body: JSON.stringify({ title, description, tags, labels }),
605 },
606 );
607 return res.ok;
608 } catch (e) {
609 console.error("Failed to save bookmark:", e);
610 return false;
611 }
612}
613
614export async function getCollectionsContaining(
615 annotationUri: string,
616): Promise<string[]> {
617 try {
618 const res = await apiRequest(
619 `/api/collections/containing?uri=${encodeURIComponent(annotationUri)}`,
620 );
621 if (!res.ok) return [];
622 return await res.json();
623 } catch (e) {
624 console.error("Failed to fetch containing collections:", e);
625 return [];
626 }
627}
628
629import type { EditHistoryItem } from "../types";
630
631export async function getEditHistory(uri: string): Promise<EditHistoryItem[]> {
632 try {
633 const res = await apiRequest(
634 `/api/notes/history?uri=${encodeURIComponent(uri)}`,
635 );
636 if (!res.ok) return [];
637 return await res.json();
638 } catch (e) {
639 console.error("Failed to fetch edit history:", e);
640 return [];
641 }
642}
643
644export async function getProfile(did: string): Promise<UserProfile | null> {
645 try {
646 const res = await apiRequest(`/api/profile/${did}`);
647 if (!res.ok) return null;
648 return await res.json();
649 } catch (e) {
650 console.error("Failed to fetch profile:", e);
651 return null;
652 }
653}
654
655export interface ActorSearchItem {
656 did: string;
657 handle: string;
658 displayName?: string;
659 avatar?: string;
660}
661
662export function getAvatarUrl(
663 did?: string,
664 avatar?: string,
665): string | undefined {
666 if (!avatar && !did) return undefined;
667 if (avatar && !avatar.includes("cdn.bsky.app")) return avatar;
668 if (!did) return avatar;
669
670 return `/api/avatar/${encodeURIComponent(did)}`;
671}
672
673export async function searchActors(
674 query: string,
675): Promise<{ actors: ActorSearchItem[] }> {
676 try {
677 const res = await fetch(
678 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`,
679 );
680 if (!res.ok) throw new Error("Search failed");
681 return await res.json();
682 } catch (e) {
683 console.error("Failed to search actors:", e);
684 return { actors: [] };
685 }
686}
687
688export async function resolveHandle(handle: string): Promise<string | null> {
689 if (handle.startsWith("did:")) return handle;
690 try {
691 const res = await fetch(
692 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
693 );
694 if (!res.ok) throw new Error("Failed to resolve handle");
695 const data = await res.json();
696 return data.did;
697 } catch (e) {
698 console.error("Failed to resolve handle:", e);
699 return null;
700 }
701}
702
703export async function startLogin(
704 handle: string,
705): Promise<{ authorizationUrl?: string }> {
706 const res = await apiRequest("/auth/start", {
707 method: "POST",
708 body: JSON.stringify({ handle }),
709 });
710 if (!res.ok) throw new Error("Failed to start login");
711 return await res.json();
712}
713
714export async function startSignup(
715 pdsUrl: string,
716): Promise<{ authorizationUrl?: string }> {
717 const res = await apiRequest("/auth/signup", {
718 method: "POST",
719 body: JSON.stringify({ pds_url: pdsUrl }),
720 });
721 if (!res.ok) throw new Error("Failed to start signup");
722 return await res.json();
723}
724
725export async function getNotifications(
726 limit = 50,
727 offset = 0,
728): Promise<NotificationItem[]> {
729 try {
730 const res = await apiRequest(
731 `/api/notifications?limit=${limit}&offset=${offset}`,
732 );
733 if (!res.ok) throw new Error("Failed to fetch notifications");
734 const data = await res.json();
735 return (data.items || []).map((n: NotificationItem) => ({
736 ...n,
737 subject: n.subject ? normalizeItem(n.subject as RawItem) : undefined,
738 }));
739 } catch (e) {
740 console.error("Failed to fetch notifications:", e);
741 return [];
742 }
743}
744
745export async function getUnreadNotificationCount(): Promise<number> {
746 try {
747 const res = await apiRequest("/api/notifications/count", {
748 skipAuthRedirect: true,
749 });
750 if (!res.ok) return 0;
751 const data = await res.json();
752 return data.count || 0;
753 } catch (e) {
754 console.error("Failed to fetch unread notification count:", e);
755 return 0;
756 }
757}
758
759export async function markNotificationsRead(): Promise<boolean> {
760 try {
761 const res = await apiRequest("/api/notifications/read", { method: "POST" });
762 return res.ok;
763 } catch (e) {
764 console.error("Failed to mark notifications as read:", e);
765 return false;
766 }
767}
768
769export interface APIKey {
770 id: string;
771 name: string;
772 key?: string;
773 createdAt: string;
774}
775
776export async function getAPIKeys(): Promise<APIKey[]> {
777 try {
778 const res = await apiRequest("/api/keys");
779 if (!res.ok) return [];
780 const data = await res.json();
781 return Array.isArray(data) ? data : data.keys || [];
782 } catch (e) {
783 console.error("Failed to fetch API keys:", e);
784 return [];
785 }
786}
787
788export async function createAPIKey(name: string): Promise<APIKey | null> {
789 try {
790 const res = await apiRequest("/api/keys", {
791 method: "POST",
792 body: JSON.stringify({ name }),
793 });
794 if (!res.ok) return null;
795 return await res.json();
796 } catch (e) {
797 console.error("Failed to create API key:", e);
798 return null;
799 }
800}
801
802export async function deleteAPIKey(id: string): Promise<boolean> {
803 try {
804 const res = await apiRequest(`/api/keys/${id}`, { method: "DELETE" });
805 return res.ok;
806 } catch (e) {
807 console.error("Failed to delete API key:", e);
808 return false;
809 }
810}
811
812export interface Tag {
813 tag: string;
814 count: number;
815}
816
817export async function getTrendingTags(limit = 50): Promise<Tag[]> {
818 try {
819 const res = await apiRequest(`/api/trending-tags?limit=${limit}`, {
820 skipAuthRedirect: true,
821 });
822 if (!res.ok) return [];
823 const data = await res.json();
824 return Array.isArray(data) ? data : data.tags || [];
825 } catch (e) {
826 console.error("Failed to fetch trending tags:", e);
827 return [];
828 }
829}
830
831export async function getUserTags(did: string, limit = 50): Promise<string[]> {
832 try {
833 const res = await apiRequest(`/api/users/${did}/tags?limit=${limit}`, {
834 skipAuthRedirect: true,
835 });
836 if (!res.ok) return [];
837 const data = await res.json();
838 return (data || []).map((t: Tag) => t.tag);
839 } catch (e) {
840 console.error("Failed to fetch user tags:", e);
841 return [];
842 }
843}
844
845export async function getCollections(creator?: string): Promise<Collection[]> {
846 try {
847 const query = creator ? `?author=${encodeURIComponent(creator)}` : "";
848 const res = await apiRequest(`/api/collections${query}`);
849 if (!res.ok) throw new Error("Failed to fetch collections");
850 const data = await res.json();
851 let items = Array.isArray(data)
852 ? data
853 : data.items || data.collections || [];
854
855 items = items.map((item: Record<string, unknown>) => {
856 if (!item.id && item.uri) {
857 item.id = (item.uri as string).split("/").pop();
858 }
859 return item;
860 });
861
862 return items;
863 } catch (e) {
864 console.error(e);
865 return [];
866 }
867}
868
869export async function getCollection(uri: string): Promise<Collection | null> {
870 try {
871 const res = await apiRequest(
872 `/api/collection?uri=${encodeURIComponent(uri)}`,
873 );
874 if (!res.ok) throw new Error("Failed to fetch collection");
875 return await res.json();
876 } catch (e) {
877 console.error(e);
878 return null;
879 }
880}
881
882export async function createCollection(
883 name: string,
884 description?: string,
885 icon?: string,
886): Promise<Collection | null> {
887 try {
888 const res = await apiRequest("/api/collections", {
889 method: "POST",
890 body: JSON.stringify({ name, description, icon }),
891 });
892 if (!res.ok) throw new Error("Failed to create collection");
893 return await res.json();
894 } catch (e) {
895 console.error(e);
896 return null;
897 }
898}
899
900export async function deleteCollection(id: string): Promise<boolean> {
901 try {
902 const res = await apiRequest(
903 `/api/collections?uri=${encodeURIComponent(id)}`,
904 { method: "DELETE" },
905 );
906 return res.ok;
907 } catch (e) {
908 console.error(e);
909 return false;
910 }
911}
912
913export async function getCollectionItems(
914 uri: string,
915): Promise<AnnotationItem[]> {
916 try {
917 const res = await apiRequest(
918 `/api/collections/${encodeURIComponent(uri)}/items`,
919 );
920 if (!res.ok) throw new Error("Failed to fetch collection items");
921 const data = await res.json();
922 return (data || []).map(normalizeItem);
923 } catch (e) {
924 console.error(e);
925 return [];
926 }
927}
928
929export async function updateCollection(
930 uri: string,
931 name: string,
932 description?: string,
933 icon?: string,
934): Promise<Collection | null> {
935 try {
936 const res = await apiRequest(
937 `/api/collections?uri=${encodeURIComponent(uri)}`,
938 {
939 method: "PUT",
940 body: JSON.stringify({ name, description, icon }),
941 },
942 );
943 if (!res.ok) throw new Error("Failed to update collection");
944 return await res.json();
945 } catch (e) {
946 console.error(e);
947 return null;
948 }
949}
950
951export async function addCollectionItem(
952 collectionUri: string,
953 annotationUri: string,
954 position: number = 0,
955): Promise<boolean> {
956 try {
957 const res = await apiRequest(
958 `/api/collections/${encodeURIComponent(collectionUri)}/items`,
959 {
960 method: "POST",
961 body: JSON.stringify({ annotationUri, position }),
962 },
963 );
964 return res.ok;
965 } catch (e) {
966 console.error(e);
967 return false;
968 }
969}
970
971export async function removeCollectionItem(itemUri: string): Promise<boolean> {
972 try {
973 const res = await apiRequest(
974 `/api/collections/items?uri=${encodeURIComponent(itemUri)}`,
975 {
976 method: "DELETE",
977 },
978 );
979 return res.ok;
980 } catch (e) {
981 console.error(e);
982 return false;
983 }
984}
985
986export async function createReply(
987 parentUri: string,
988 parentCid: string,
989 rootUri: string,
990 rootCid: string,
991 text: string,
992): Promise<string | null> {
993 try {
994 const res = await apiRequest("/api/notes/reply", {
995 method: "POST",
996 body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }),
997 });
998 if (!res.ok) throw new Error("Failed to create reply");
999 const data = await res.json();
1000 return data.uri;
1001 } catch (e) {
1002 console.error(e);
1003 return null;
1004 }
1005}
1006
1007export async function deleteReply(uri: string): Promise<boolean> {
1008 try {
1009 const res = await apiRequest(
1010 `/api/notes/reply?uri=${encodeURIComponent(uri)}`,
1011 {
1012 method: "DELETE",
1013 },
1014 );
1015 return res.ok;
1016 } catch (e) {
1017 console.error(e);
1018 return false;
1019 }
1020}
1021
1022export async function getAnnotation(
1023 uri: string,
1024): Promise<AnnotationItem | null> {
1025 try {
1026 const res = await apiRequest(`/api/note?uri=${encodeURIComponent(uri)}`);
1027 if (!res.ok) return null;
1028 return normalizeItem(await res.json());
1029 } catch {
1030 return null;
1031 }
1032}
1033
1034export async function getReplies(
1035 uri: string,
1036): Promise<{ items: AnnotationItem[] }> {
1037 try {
1038 const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`);
1039 if (!res.ok) return { items: [] };
1040 const data = await res.json();
1041 return { items: (data.items || []).map(normalizeItem) };
1042 } catch {
1043 return { items: [] };
1044 }
1045}
1046
1047export async function getByTarget(
1048 url: string,
1049 limit = 50,
1050 offset = 0,
1051): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> {
1052 try {
1053 const res = await apiRequest(
1054 `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`,
1055 );
1056 if (!res.ok) return { annotations: [], highlights: [] };
1057 const data = await res.json();
1058 return {
1059 annotations: (data.annotations || []).map(normalizeItem),
1060 highlights: (data.highlights || []).map(normalizeItem),
1061 };
1062 } catch {
1063 return { annotations: [], highlights: [] };
1064 }
1065}
1066
1067export async function getUserTargetItems(
1068 did: string,
1069 url: string,
1070 limit = 50,
1071 offset = 0,
1072): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> {
1073 try {
1074 const res = await apiRequest(
1075 `/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`,
1076 );
1077 if (!res.ok) return { annotations: [], highlights: [] };
1078 const data = await res.json();
1079 return {
1080 annotations: (data.annotations || []).map(normalizeItem),
1081 highlights: (data.highlights || []).map(normalizeItem),
1082 };
1083 } catch {
1084 return { annotations: [], highlights: [] };
1085 }
1086}
1087
1088import type {
1089 LabelerInfo,
1090 LabelerSubscription,
1091 LabelPreference,
1092} from "../types";
1093
1094export interface PreferencesResponse {
1095 externalLinkSkippedHostnames?: string[];
1096 subscribedLabelers?: LabelerSubscription[];
1097 labelPreferences?: LabelPreference[];
1098 disableExternalLinkWarning?: boolean;
1099 enableCommunityBookmarks?: boolean;
1100}
1101
1102export async function getPreferences(): Promise<PreferencesResponse> {
1103 try {
1104 const res = await apiRequest("/api/preferences", {
1105 skipAuthRedirect: true,
1106 });
1107 if (!res.ok) return {};
1108 return await res.json();
1109 } catch (e) {
1110 console.error(e);
1111 return {};
1112 }
1113}
1114
1115export async function updatePreferences(prefs: {
1116 externalLinkSkippedHostnames?: string[];
1117 subscribedLabelers?: LabelerSubscription[];
1118 labelPreferences?: LabelPreference[];
1119 disableExternalLinkWarning?: boolean;
1120 enableCommunityBookmarks?: boolean;
1121}): Promise<boolean> {
1122 try {
1123 const res = await apiRequest("/api/preferences", {
1124 method: "PUT",
1125 body: JSON.stringify(prefs),
1126 });
1127 return res.ok;
1128 } catch (e) {
1129 console.error(e);
1130 return false;
1131 }
1132}
1133
1134export async function getLabelerInfo(): Promise<LabelerInfo | null> {
1135 try {
1136 const res = await apiRequest("/moderation/labeler", {
1137 skipAuthRedirect: true,
1138 });
1139 if (!res.ok) return null;
1140 return await res.json();
1141 } catch (e) {
1142 console.error("Failed to fetch labeler info:", e);
1143 return null;
1144 }
1145}
1146
1147import type {
1148 BlockedUser,
1149 ModerationRelationship,
1150 ModerationReport,
1151 MutedUser,
1152 ReportReasonType,
1153} from "../types";
1154
1155export async function blockUser(did: string): Promise<boolean> {
1156 try {
1157 const res = await apiRequest("/api/moderation/block", {
1158 method: "POST",
1159 body: JSON.stringify({ did }),
1160 });
1161 return res.ok;
1162 } catch (e) {
1163 console.error("Failed to block user:", e);
1164 return false;
1165 }
1166}
1167
1168export async function unblockUser(did: string): Promise<boolean> {
1169 try {
1170 const res = await apiRequest(
1171 `/api/moderation/block?did=${encodeURIComponent(did)}`,
1172 { method: "DELETE" },
1173 );
1174 return res.ok;
1175 } catch (e) {
1176 console.error("Failed to unblock user:", e);
1177 return false;
1178 }
1179}
1180
1181export async function getBlocks(): Promise<BlockedUser[]> {
1182 try {
1183 const res = await apiRequest("/api/moderation/blocks");
1184 if (!res.ok) return [];
1185 const data = await res.json();
1186 return data.items || [];
1187 } catch (e) {
1188 console.error("Failed to fetch blocks:", e);
1189 return [];
1190 }
1191}
1192
1193export async function muteUser(did: string): Promise<boolean> {
1194 try {
1195 const res = await apiRequest("/api/moderation/mute", {
1196 method: "POST",
1197 body: JSON.stringify({ did }),
1198 });
1199 return res.ok;
1200 } catch (e) {
1201 console.error("Failed to mute user:", e);
1202 return false;
1203 }
1204}
1205
1206export async function unmuteUser(did: string): Promise<boolean> {
1207 try {
1208 const res = await apiRequest(
1209 `/api/moderation/mute?did=${encodeURIComponent(did)}`,
1210 { method: "DELETE" },
1211 );
1212 return res.ok;
1213 } catch (e) {
1214 console.error("Failed to unmute user:", e);
1215 return false;
1216 }
1217}
1218
1219export async function getMutes(): Promise<MutedUser[]> {
1220 try {
1221 const res = await apiRequest("/api/moderation/mutes");
1222 if (!res.ok) return [];
1223 const data = await res.json();
1224 return data.items || [];
1225 } catch (e) {
1226 console.error("Failed to fetch mutes:", e);
1227 return [];
1228 }
1229}
1230
1231export async function getModerationRelationship(
1232 did: string,
1233): Promise<ModerationRelationship> {
1234 try {
1235 const res = await apiRequest(
1236 `/api/moderation/relationship?did=${encodeURIComponent(did)}`,
1237 { skipAuthRedirect: true },
1238 );
1239 if (!res.ok) return { blocking: false, muting: false, blockedBy: false };
1240 return await res.json();
1241 } catch (e) {
1242 console.error("Failed to get moderation relationship:", e);
1243 return { blocking: false, muting: false, blockedBy: false };
1244 }
1245}
1246
1247export async function reportUser(params: {
1248 subjectDid: string;
1249 subjectUri?: string;
1250 reasonType: ReportReasonType;
1251 reasonText?: string;
1252}): Promise<boolean> {
1253 try {
1254 const res = await apiRequest("/api/moderation/report", {
1255 method: "POST",
1256 body: JSON.stringify(params),
1257 });
1258 return res.ok;
1259 } catch (e) {
1260 console.error("Failed to submit report:", e);
1261 return false;
1262 }
1263}
1264
1265export async function checkAdminAccess(): Promise<boolean> {
1266 try {
1267 const res = await apiRequest("/api/moderation/admin/check", {
1268 skipAuthRedirect: true,
1269 });
1270 if (!res.ok) return false;
1271 const data = await res.json();
1272 return data.isAdmin || false;
1273 } catch {
1274 return false;
1275 }
1276}
1277
1278export async function getAdminReports(
1279 status?: string,
1280 limit = 50,
1281 offset = 0,
1282): Promise<{
1283 items: ModerationReport[];
1284 totalItems: number;
1285 pendingCount: number;
1286}> {
1287 try {
1288 const params = new URLSearchParams();
1289 if (status) params.append("status", status);
1290 params.append("limit", limit.toString());
1291 params.append("offset", offset.toString());
1292 const res = await apiRequest(
1293 `/api/moderation/admin/reports?${params.toString()}`,
1294 );
1295 if (!res.ok) return { items: [], totalItems: 0, pendingCount: 0 };
1296 return await res.json();
1297 } catch (e) {
1298 console.error("Failed to fetch admin reports:", e);
1299 return { items: [], totalItems: 0, pendingCount: 0 };
1300 }
1301}
1302
1303export async function adminTakeAction(params: {
1304 reportId: number;
1305 action: string;
1306 comment?: string;
1307}): Promise<boolean> {
1308 try {
1309 const res = await apiRequest("/api/moderation/admin/action", {
1310 method: "POST",
1311 body: JSON.stringify(params),
1312 });
1313 return res.ok;
1314 } catch (e) {
1315 console.error("Failed to take moderation action:", e);
1316 return false;
1317 }
1318}
1319
1320export async function adminCreateLabel(params: {
1321 src: string;
1322 uri?: string;
1323 val: string;
1324}): Promise<boolean> {
1325 try {
1326 const res = await apiRequest("/api/moderation/admin/label", {
1327 method: "POST",
1328 body: JSON.stringify(params),
1329 });
1330 return res.ok;
1331 } catch (e) {
1332 console.error("Failed to create label:", e);
1333 return false;
1334 }
1335}
1336
1337export async function adminDeleteLabel(id: number): Promise<boolean> {
1338 try {
1339 const res = await apiRequest(`/api/moderation/admin/label?id=${id}`, {
1340 method: "DELETE",
1341 });
1342 return res.ok;
1343 } catch (e) {
1344 console.error("Failed to delete label:", e);
1345 return false;
1346 }
1347}
1348
1349export async function adminGetLabels(
1350 limit = 50,
1351 offset = 0,
1352): Promise<{ items: HydratedLabel[] }> {
1353 try {
1354 const res = await apiRequest(
1355 `/api/moderation/admin/labels?limit=${limit}&offset=${offset}`,
1356 );
1357 if (!res.ok) return { items: [] };
1358 return await res.json();
1359 } catch (e) {
1360 console.error("Failed to fetch labels:", e);
1361 return { items: [] };
1362 }
1363}
1364
1365export interface BannedAccount {
1366 did: string;
1367 reason?: string;
1368 bannedBy: string;
1369 bannedAt: string;
1370 profile?: {
1371 did: string;
1372 handle: string;
1373 displayName?: string;
1374 avatar?: string;
1375 };
1376}
1377
1378export async function adminBanAccount(params: {
1379 did: string;
1380 reason?: string;
1381}): Promise<boolean> {
1382 try {
1383 const res = await apiRequest("/api/moderation/admin/ban", {
1384 method: "POST",
1385 headers: { "Content-Type": "application/json" },
1386 body: JSON.stringify(params),
1387 });
1388 return res.ok;
1389 } catch (e) {
1390 console.error("Failed to ban account:", e);
1391 return false;
1392 }
1393}
1394
1395export async function adminUnbanAccount(did: string): Promise<boolean> {
1396 try {
1397 const res = await apiRequest(
1398 `/api/moderation/admin/ban?did=${encodeURIComponent(did)}`,
1399 { method: "DELETE" },
1400 );
1401 return res.ok;
1402 } catch (e) {
1403 console.error("Failed to unban account:", e);
1404 return false;
1405 }
1406}
1407
1408export async function adminGetBannedAccounts(): Promise<{
1409 items: BannedAccount[];
1410 total: number;
1411}> {
1412 try {
1413 const res = await apiRequest("/api/moderation/admin/bans");
1414 if (!res.ok) return { items: [], total: 0 };
1415 return await res.json();
1416 } catch (e) {
1417 console.error("Failed to fetch banned accounts:", e);
1418 return { items: [], total: 0 };
1419 }
1420}
1421
1422export interface DocumentItem {
1423 uri: string;
1424 authorDid: string;
1425 site: string;
1426 path?: string;
1427 title: string;
1428 description?: string;
1429 tags?: string[];
1430 canonicalUrl: string;
1431 publishedAt: string;
1432}
1433
1434export interface DocumentsResponse {
1435 items: DocumentItem[];
1436 totalItems: number;
1437}
1438
1439export async function getDocuments({
1440 sort = "new",
1441 limit = 30,
1442 offset = 0,
1443}: {
1444 sort?: string;
1445 limit?: number;
1446 offset?: number;
1447}): Promise<DocumentsResponse> {
1448 try {
1449 const params = new URLSearchParams();
1450 if (sort) params.append("sort", sort);
1451 params.append("limit", limit.toString());
1452 params.append("offset", offset.toString());
1453
1454 const res = await apiRequest(`/api/documents?${params.toString()}`, {
1455 skipAuthRedirect: true,
1456 });
1457 if (!res.ok) throw new Error("Failed to fetch documents");
1458 return await res.json();
1459 } catch (e) {
1460 console.error("Failed to fetch documents:", e);
1461 return { items: [], totalItems: 0 };
1462 }
1463}
1464
1465export async function getRecommendations(
1466 limit = 20,
1467): Promise<DocumentsResponse & { unavailable?: boolean }> {
1468 try {
1469 const res = await apiRequest(`/api/recommendations?limit=${limit}`);
1470 if (res.status === 503)
1471 return { items: [], totalItems: 0, unavailable: true };
1472 if (!res.ok) throw new Error("Failed to fetch recommendations");
1473 return await res.json();
1474 } catch (e) {
1475 console.error("Failed to fetch recommendations:", e);
1476 return { items: [], totalItems: 0 };
1477 }
1478}