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