Something went wrong. Try again.
(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension web atproto comments
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737import { atom } from "nanostores";import type { AnnotationItem, Collection, FeedResponse, HydratedLabel, NotificationItem, Selector, Target, TextQuoteSelector, UserProfile,} from "../types";
export type { Collection } from "../types";
export const sessionAtom = atom<UserProfile | null>(null);
export async function checkSession(): Promise<UserProfile | null | undefined> { try { const res = await fetch("/auth/session"); if (!res.ok) { return undefined; } const data = await res.json();
if (data.authenticated || data.did) { const baseProfile: UserProfile = { did: data.did, handle: data.handle, displayName: data.displayName, avatar: data.avatar, description: data.description, website: data.website, links: data.links, followersCount: data.followersCount, followsCount: data.followsCount, postsCount: data.postsCount, };
const [bskyResult, marginResult] = await Promise.allSettled([ fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, ), fetch(`/api/profile/${data.did}`), ]);
if (bskyResult.status === "fulfilled" && bskyResult.value.ok) { try { const bskyData = await bskyResult.value.json(); if (bskyData.avatar) baseProfile.avatar = bskyData.avatar; if (bskyData.displayName) baseProfile.displayName = bskyData.displayName; } catch { /* ignore */ } }
if (marginResult.status === "fulfilled" && marginResult.value.ok) { try { const marginProfile = await marginResult.value.json(); if (marginProfile) { if (marginProfile.description) baseProfile.description = marginProfile.description; if (marginProfile.followersCount) baseProfile.followersCount = marginProfile.followersCount; if (marginProfile.followsCount) baseProfile.followsCount = marginProfile.followsCount; if (marginProfile.postsCount) baseProfile.postsCount = marginProfile.postsCount; if (marginProfile.website) baseProfile.website = marginProfile.website; if (marginProfile.links) baseProfile.links = marginProfile.links; } } catch { /* ignore */ } }
sessionAtom.set(baseProfile); return baseProfile; }
sessionAtom.set(null); return null; } catch (e) { console.error("Session check failed:", e); return undefined; }}
async function apiRequest( path: string, options: RequestInit & { skipAuthRedirect?: boolean } = {},): Promise<Response> { const { skipAuthRedirect, ...fetchOptions } = options; const headers = { "Content-Type": "application/json", ...(fetchOptions.headers || {}), };
const apiPath = path.startsWith("/api") || path.startsWith("/auth") ? path : `/api${path}`;
const response = await fetch(apiPath, { ...fetchOptions, headers, });
if (response.status === 401 && !skipAuthRedirect) { const verified = await checkSession(); if (verified === null) { sessionAtom.set(null); if (window.location.pathname !== "/login") { window.location.href = "/login"; } } }
return response;}
export interface GetFeedParams { source?: string; type?: string; limit?: number; offset?: number; motivation?: string; tag?: string; creator?: string;}
interface RawItem { type?: string; collectionUri?: string; annotation?: RawItem; highlight?: RawItem; bookmark?: RawItem; uri?: string; id?: string; cid?: string; author?: UserProfile; creator?: UserProfile; collection?: { uri: string; name: string; icon?: string; }; context?: { uri: string; name: string; icon?: string; }[]; created?: string; createdAt?: string; target?: string | { source?: string; title?: string; selector?: Selector }; url?: string; targetUrl?: string; title?: string; selector?: Selector; viewer?: { like?: string; [key: string]: unknown }; viewerHasLiked?: boolean; motivation?: string; [key: string]: unknown;}
function normalizeItem(raw: RawItem): AnnotationItem { if (raw.type === "CollectionItem" || raw.collectionUri) { const inner = raw.annotation || raw.highlight || raw.bookmark || {}; const normalizedInner = normalizeItem(inner);
return { ...normalizedInner, uri: normalizedInner.uri || raw.uri || "", cid: normalizedInner.cid || raw.cid || "", author: (normalizedInner.author || raw.author || raw.creator) as UserProfile, collection: raw.collection ? { uri: raw.collection.uri, name: raw.collection.name, icon: raw.collection.icon, } : undefined, context: raw.context ? raw.context.map((c) => ({ uri: c.uri, name: c.name, icon: c.icon, })) : undefined, addedBy: raw.creator || raw.author, createdAt: normalizedInner.createdAt || raw.created || raw.createdAt || new Date().toISOString(), collectionItemUri: raw.id || raw.uri, }; }
let target: Target | undefined;
if (raw.target) { if (typeof raw.target === "string") { target = { source: raw.target, title: raw.title, selector: raw.selector }; } else { target = { source: raw.target.source || "", title: raw.target.title || raw.title, selector: raw.target.selector || raw.selector, }; } }
if (!target || !target.source) { const url = raw.url || raw.targetUrl || (typeof raw.target === "string" ? raw.target : raw.target?.source); if (url) { target = { source: url, title: raw.title || (typeof raw.target !== "string" ? raw.target?.title : undefined), selector: raw.selector || (typeof raw.target !== "string" ? raw.target?.selector : undefined), }; } }
return { ...raw, uri: raw.id || raw.uri || "", cid: raw.cid || "", author: (raw.creator || raw.author) as UserProfile, createdAt: raw.created || raw.createdAt || new Date().toISOString(), target: target, viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined }, motivation: raw.motivation || "highlighting", parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined, };}
export async function searchItems( query: string, options: { creator?: string; limit?: number; offset?: number } = {},): Promise<FeedResponse> { const params = new URLSearchParams(); params.append("q", query); if (options.creator) params.append("creator", options.creator); if (options.limit) params.append("limit", options.limit.toString()); if (options.offset) params.append("offset", options.offset.toString());
try { const res = await apiRequest(`/api/search?${params.toString()}`, { skipAuthRedirect: true, }); if (!res.ok) throw new Error("Search failed"); const data = await res.json(); const items: AnnotationItem[] = (data.items || []).map(normalizeItem); return { items, hasMore: items.length >= (options.limit || 50), fetchedCount: items.length, }; } catch (e) { console.error("Search error:", e); return { items: [], hasMore: false, fetchedCount: 0 }; }}
export async function getFeed({ source, type = "all", limit = 50, offset = 0, motivation, tag, creator,}: GetFeedParams): Promise<FeedResponse> { const params = new URLSearchParams(); if (source) params.append("source", source); if (type) params.append("type", type); if (limit) params.append("limit", limit.toString()); if (offset) params.append("offset", offset.toString()); if (motivation) params.append("motivation", motivation); if (tag) params.append("tag", tag); if (creator) params.append("creator", creator);
const endpoint = source ? "/api/targets" : "/api/notes/feed";
try { const res = await apiRequest(`${endpoint}?${params.toString()}`, { skipAuthRedirect: true, }); if (!res.ok) throw new Error("Failed to fetch feed"); const data = await res.json(); const normalizedItems: AnnotationItem[] = (data.items || []).map( normalizeItem, );
const groupedItems: AnnotationItem[] = []; if (normalizedItems.length > 0) { groupedItems.push(normalizedItems[0]);
for (let i = 1; i < normalizedItems.length; i++) { const prev = groupedItems[groupedItems.length - 1]; const curr = normalizedItems[i];
if (prev.collection && curr.collection) { if ( prev.uri === curr.uri && prev.addedBy?.did === curr.addedBy?.did ) { if (!prev.context) { prev.context = [prev.collection]; } prev.context.push(curr.collection); groupedItems[groupedItems.length - 1] = prev; continue; } } groupedItems.push(curr); } }
return { items: groupedItems, hasMore: normalizedItems.length >= limit, fetchedCount: normalizedItems.length, }; } catch (e) { console.error(e); return { items: [], hasMore: false, fetchedCount: 0 }; }}
interface CreateAnnotationParams { url: string; text?: string; title?: string; selector?: TextQuoteSelector; tags?: string[]; labels?: string[];}
export async function createAnnotation({ url, text, title, selector, tags, labels,}: CreateAnnotationParams) { try { const res = await apiRequest("/api/notes", { method: "POST", body: JSON.stringify({ url, text, title, selector, tags, labels }), }); if (!res.ok) throw new Error(await res.text()); const raw = await res.json(); return normalizeItem(raw); } catch (e) { console.error(e); return { error: e instanceof Error ? e.message : "Unknown error" }; }}
interface CreateHighlightParams { url: string; selector: TextQuoteSelector; color?: string; tags?: string[]; title?: string; labels?: string[];}
export async function createHighlight({ url, selector, color, tags, title, labels,}: CreateHighlightParams) { try { const res = await apiRequest("/api/highlights", { method: "POST", body: JSON.stringify({ url, selector, color, tags, title, labels }), }); if (!res.ok) throw new Error(await res.text()); const raw = await res.json(); return normalizeItem(raw); } catch (e) { console.error(e); return { error: e instanceof Error ? e.message : "Unknown error" }; }}
export async function createBookmark({ url, title, description, tags,}: { url: string; title?: string; description?: string; tags?: string[];}) { try { const res = await apiRequest("/api/bookmarks", { method: "POST", body: JSON.stringify({ url, title, description, tags }), }); if (!res.ok) throw new Error(await res.text()); const raw = await res.json(); return normalizeItem(raw); } catch (e) { console.error(e); return { error: e instanceof Error ? e.message : "Unknown error" }; }}
export async function uploadAvatar( file: File,): Promise<{ blob: Blob | string }> { const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/upload/avatar", { method: "POST", headers: { Authorization: `Bearer ${(await checkSession())?.did}`, }, body: formData, }); if (!res.ok) throw new Error("Failed to upload avatar"); return res.json();}
export async function updateProfile(updates: { displayName?: string; description?: string; avatar?: Blob | string | null; website?: string; links?: string[];}): Promise<boolean> { try { const { description, ...rest } = updates; const body = { ...rest, bio: description }; const res = await apiRequest("/api/profile", { method: "PUT", body: JSON.stringify(body), }); return res.ok; } catch (e) { console.error(e); return false; }}
export async function likeItem(uri: string, cid: string): Promise<boolean> { try { const res = await apiRequest("/api/notes/like", { method: "POST", body: JSON.stringify({ subjectUri: uri, subjectCid: cid }), }); return res.ok; } catch (e) { console.error("Failed to like item:", e); return false; }}
export async function unlikeItem(uri: string): Promise<boolean> { try { const res = await apiRequest( `/api/notes/like?uri=${encodeURIComponent(uri)}`, { method: "DELETE", }, ); return res.ok; } catch (e) { console.error("Failed to unlike item:", e); return false; }}
export async function deleteItem( uri: string, type: string = "annotation",): Promise<boolean> { const rkey = (uri || "").split("/").pop();
let endpoint = "/api/notes"; if (type === "highlight" || uri.includes("highlight")) { endpoint = "/api/highlights"; } else if (type === "bookmark" || uri.includes("bookmark")) { endpoint = "/api/bookmarks"; }
try { const res = await apiRequest(`${endpoint}?rkey=${rkey}`, { method: "DELETE", }); return res.ok; } catch (e) { console.error("Failed to delete item:", e); return false; }}
export async function convertHighlightToAnnotation( highlightUri: string, url: string, text: string, selector?: TextQuoteSelector, title?: string,): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> { try { const createRes = await apiRequest("/api/notes", { method: "POST", body: JSON.stringify({ url, text, title, selector }), }); if (!createRes.ok) { const err = await createRes.text(); return { success: false, error: err }; } const created = normalizeItem(await createRes.json());
const rkey = (highlightUri || "").split("/").pop(); if (rkey) { await apiRequest(`/api/highlights?rkey=${rkey}`, { method: "DELETE" }); }
return { success: true, item: created }; } catch (e) { console.error("Failed to convert highlight:", e); return { success: false, error: e instanceof Error ? e.message : "Unknown error", }; }}
export async function updateAnnotation( uri: string, text: string, tags?: string[], labels?: string[],): Promise<boolean> { try { const res = await apiRequest(`/api/notes?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ text, tags, labels }), }); return res.ok; } catch (e) { console.error("Failed to update annotation:", e); return false; }}
export async function updateHighlight( uri: string, color: string, tags?: string[], labels?: string[],): Promise<boolean> { try { const res = await apiRequest( `/api/highlights?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ color, tags, labels }), }, ); return res.ok; } catch (e) { console.error("Failed to update highlight:", e); return false; }}
export async function updateBookmark( uri: string, title?: string, description?: string, tags?: string[], labels?: string[],): Promise<boolean> { try { const res = await apiRequest( `/api/bookmarks?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ title, description, tags, labels }), }, ); return res.ok; } catch (e) { console.error("Failed to save bookmark:", e); return false; }}
export async function getCollectionsContaining( annotationUri: string,): Promise<string[]> { try { const res = await apiRequest( `/api/collections/containing?uri=${encodeURIComponent(annotationUri)}`, ); if (!res.ok) return []; return await res.json(); } catch (e) { console.error("Failed to fetch containing collections:", e); return []; }}
import type { EditHistoryItem } from "../types";
export async function getEditHistory(uri: string): Promise<EditHistoryItem[]> { try { const res = await apiRequest( `/api/notes/history?uri=${encodeURIComponent(uri)}`, ); if (!res.ok) return []; return await res.json(); } catch (e) { console.error("Failed to fetch edit history:", e); return []; }}
export async function getProfile(did: string): Promise<UserProfile | null> { try { const res = await apiRequest(`/api/profile/${did}`); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch profile:", e); return null; }}
export interface ActorSearchItem { did: string; handle: string; displayName?: string; avatar?: string;}
export function getAvatarUrl( did?: string, avatar?: string,): string | undefined { if (!avatar && !did) return undefined; if (avatar && !avatar.includes("cdn.bsky.app")) return avatar; if (!did) return avatar;
return `/api/avatar/${encodeURIComponent(did)}`;}
export async function searchActors( query: string,): Promise<{ actors: ActorSearchItem[] }> { try { const res = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`, ); if (!res.ok) throw new Error("Search failed"); return await res.json(); } catch (e) { console.error("Failed to search actors:", e); return { actors: [] }; }}
export async function resolveHandle(handle: string): Promise<string | null> { if (handle.startsWith("did:")) return handle; try { const res = await fetch( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, ); if (!res.ok) throw new Error("Failed to resolve handle"); const data = await res.json(); return data.did; } catch (e) { console.error("Failed to resolve handle:", e); return null; }}
export async function startLogin( handle: string,): Promise<{ authorizationUrl?: string }> { const res = await apiRequest("/auth/start", { method: "POST", body: JSON.stringify({ handle }), }); if (!res.ok) throw new Error("Failed to start login"); return await res.json();}
export async function startSignup( pdsUrl: string,): Promise<{ authorizationUrl?: string }> { const res = await apiRequest("/auth/signup", { method: "POST", body: JSON.stringify({ pds_url: pdsUrl }), }); if (!res.ok) throw new Error("Failed to start signup"); return await res.json();}
export async function getNotifications( limit = 50, offset = 0,): Promise<NotificationItem[]> { try { const res = await apiRequest( `/api/notifications?limit=${limit}&offset=${offset}`, ); if (!res.ok) throw new Error("Failed to fetch notifications"); const data = await res.json(); return (data.items || []).map((n: NotificationItem) => ({ ...n, subject: n.subject ? normalizeItem(n.subject as RawItem) : undefined, })); } catch (e) { console.error("Failed to fetch notifications:", e); return []; }}
export async function getUnreadNotificationCount(): Promise<number> { try { const res = await apiRequest("/api/notifications/count", { skipAuthRedirect: true, }); if (!res.ok) return 0; const data = await res.json(); return data.count || 0; } catch (e) { console.error("Failed to fetch unread notification count:", e); return 0; }}
export async function markNotificationsRead(): Promise<boolean> { try { const res = await apiRequest("/api/notifications/read", { method: "POST" }); return res.ok; } catch (e) { console.error("Failed to mark notifications as read:", e); return false; }}
export interface APIKey { id: string; name: string; key?: string; createdAt: string;}
export async function getAPIKeys(): Promise<APIKey[]> { try { const res = await apiRequest("/api/keys"); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : data.keys || []; } catch (e) { console.error("Failed to fetch API keys:", e); return []; }}
export async function createAPIKey(name: string): Promise<APIKey | null> { try { const res = await apiRequest("/api/keys", { method: "POST", body: JSON.stringify({ name }), }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to create API key:", e); return null; }}
export async function deleteAPIKey(id: string): Promise<boolean> { try { const res = await apiRequest(`/api/keys/${id}`, { method: "DELETE" }); return res.ok; } catch (e) { console.error("Failed to delete API key:", e); return false; }}
export interface Tag { tag: string; count: number;}
export async function getTrendingTags(limit = 50): Promise<Tag[]> { try { const res = await apiRequest(`/api/trending-tags?limit=${limit}`, { skipAuthRedirect: true, }); if (!res.ok) return []; const data = await res.json(); return Array.isArray(data) ? data : data.tags || []; } catch (e) { console.error("Failed to fetch trending tags:", e); return []; }}
export async function getUserTags(did: string, limit = 50): Promise<string[]> { try { const res = await apiRequest(`/api/users/${did}/tags?limit=${limit}`, { skipAuthRedirect: true, }); if (!res.ok) return []; const data = await res.json(); return (data || []).map((t: Tag) => t.tag); } catch (e) { console.error("Failed to fetch user tags:", e); return []; }}
export async function getCollections(creator?: string): Promise<Collection[]> { try { const query = creator ? `?author=${encodeURIComponent(creator)}` : ""; const res = await apiRequest(`/api/collections${query}`); if (!res.ok) throw new Error("Failed to fetch collections"); const data = await res.json(); let items = Array.isArray(data) ? data : data.items || data.collections || [];
items = items.map((item: Record<string, unknown>) => { if (!item.id && item.uri) { item.id = (item.uri as string).split("/").pop(); } return item; });
return items; } catch (e) { console.error(e); return []; }}
export async function getCollection(uri: string): Promise<Collection | null> { try { const res = await apiRequest( `/api/collection?uri=${encodeURIComponent(uri)}`, ); if (!res.ok) throw new Error("Failed to fetch collection"); return await res.json(); } catch (e) { console.error(e); return null; }}
export async function createCollection( name: string, description?: string, icon?: string,): Promise<Collection | null> { try { const res = await apiRequest("/api/collections", { method: "POST", body: JSON.stringify({ name, description, icon }), }); if (!res.ok) throw new Error("Failed to create collection"); return await res.json(); } catch (e) { console.error(e); return null; }}
export async function deleteCollection(id: string): Promise<boolean> { try { const res = await apiRequest( `/api/collections?uri=${encodeURIComponent(id)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function getCollectionItems( uri: string,): Promise<AnnotationItem[]> { try { const res = await apiRequest( `/api/collections/${encodeURIComponent(uri)}/items`, ); if (!res.ok) throw new Error("Failed to fetch collection items"); const data = await res.json(); return (data || []).map(normalizeItem); } catch (e) { console.error(e); return []; }}
export async function updateCollection( uri: string, name: string, description?: string, icon?: string,): Promise<Collection | null> { try { const res = await apiRequest( `/api/collections?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ name, description, icon }), }, ); if (!res.ok) throw new Error("Failed to update collection"); return await res.json(); } catch (e) { console.error(e); return null; }}
export async function addCollectionItem( collectionUri: string, annotationUri: string, position: number = 0,): Promise<boolean> { try { const res = await apiRequest( `/api/collections/${encodeURIComponent(collectionUri)}/items`, { method: "POST", body: JSON.stringify({ annotationUri, position }), }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function removeCollectionItem(itemUri: string): Promise<boolean> { try { const res = await apiRequest( `/api/collections/items?uri=${encodeURIComponent(itemUri)}`, { method: "DELETE", }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function createReply( parentUri: string, parentCid: string, rootUri: string, rootCid: string, text: string,): Promise<string | null> { try { const res = await apiRequest("/api/notes/reply", { method: "POST", body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), }); if (!res.ok) throw new Error("Failed to create reply"); const data = await res.json(); return data.uri; } catch (e) { console.error(e); return null; }}
export async function deleteReply(uri: string): Promise<boolean> { try { const res = await apiRequest( `/api/notes/reply?uri=${encodeURIComponent(uri)}`, { method: "DELETE", }, ); return res.ok; } catch (e) { console.error(e); return false; }}
export async function getAnnotation( uri: string,): Promise<AnnotationItem | null> { try { const res = await apiRequest(`/api/note?uri=${encodeURIComponent(uri)}`); if (!res.ok) return null; return normalizeItem(await res.json()); } catch { return null; }}
export async function getReplies( uri: string,): Promise<{ items: AnnotationItem[] }> { try { const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`); if (!res.ok) return { items: [] }; const data = await res.json(); return { items: (data.items || []).map(normalizeItem) }; } catch { return { items: [] }; }}
export async function getByTarget( url: string, limit = 50, offset = 0,): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { try { const res = await apiRequest( `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, ); if (!res.ok) return { annotations: [], highlights: [] }; const data = await res.json(); return { annotations: (data.annotations || []).map(normalizeItem), highlights: (data.highlights || []).map(normalizeItem), }; } catch { return { annotations: [], highlights: [] }; }}
export async function getUserTargetItems( did: string, url: string, limit = 50, offset = 0,): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { try { const res = await apiRequest( `/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, ); if (!res.ok) return { annotations: [], highlights: [] }; const data = await res.json(); return { annotations: (data.annotations || []).map(normalizeItem), highlights: (data.highlights || []).map(normalizeItem), }; } catch { return { annotations: [], highlights: [] }; }}
import type { LabelerInfo, LabelerSubscription, LabelPreference,} from "../types";
export interface PreferencesResponse { externalLinkSkippedHostnames?: string[]; subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; disableExternalLinkWarning?: boolean; enableCommunityBookmarks?: boolean;}
export async function getPreferences(): Promise<PreferencesResponse> { try { const res = await apiRequest("/api/preferences", { skipAuthRedirect: true, }); if (!res.ok) return {}; return await res.json(); } catch (e) { console.error(e); return {}; }}
export async function updatePreferences(prefs: { externalLinkSkippedHostnames?: string[]; subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; disableExternalLinkWarning?: boolean; enableCommunityBookmarks?: boolean;}): Promise<boolean> { try { const res = await apiRequest("/api/preferences", { method: "PUT", body: JSON.stringify(prefs), }); return res.ok; } catch (e) { console.error(e); return false; }}
export async function getLabelerInfo(): Promise<LabelerInfo | null> { try { const res = await apiRequest("/moderation/labeler", { skipAuthRedirect: true, }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch labeler info:", e); return null; }}
import type { BlockedUser, ModerationRelationship, ModerationReport, MutedUser, ReportReasonType,} from "../types";
export async function blockUser(did: string): Promise<boolean> { try { const res = await apiRequest("/api/moderation/block", { method: "POST", body: JSON.stringify({ did }), }); return res.ok; } catch (e) { console.error("Failed to block user:", e); return false; }}
export async function unblockUser(did: string): Promise<boolean> { try { const res = await apiRequest( `/api/moderation/block?did=${encodeURIComponent(did)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error("Failed to unblock user:", e); return false; }}
export async function getBlocks(): Promise<BlockedUser[]> { try { const res = await apiRequest("/api/moderation/blocks"); if (!res.ok) return []; const data = await res.json(); return data.items || []; } catch (e) { console.error("Failed to fetch blocks:", e); return []; }}
export async function muteUser(did: string): Promise<boolean> { try { const res = await apiRequest("/api/moderation/mute", { method: "POST", body: JSON.stringify({ did }), }); return res.ok; } catch (e) { console.error("Failed to mute user:", e); return false; }}
export async function unmuteUser(did: string): Promise<boolean> { try { const res = await apiRequest( `/api/moderation/mute?did=${encodeURIComponent(did)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error("Failed to unmute user:", e); return false; }}
export async function getMutes(): Promise<MutedUser[]> { try { const res = await apiRequest("/api/moderation/mutes"); if (!res.ok) return []; const data = await res.json(); return data.items || []; } catch (e) { console.error("Failed to fetch mutes:", e); return []; }}
export async function getModerationRelationship( did: string,): Promise<ModerationRelationship> { try { const res = await apiRequest( `/api/moderation/relationship?did=${encodeURIComponent(did)}`, { skipAuthRedirect: true }, ); if (!res.ok) return { blocking: false, muting: false, blockedBy: false }; return await res.json(); } catch (e) { console.error("Failed to get moderation relationship:", e); return { blocking: false, muting: false, blockedBy: false }; }}
export async function reportUser(params: { subjectDid: string; subjectUri?: string; reasonType: ReportReasonType; reasonText?: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/report", { method: "POST", body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to submit report:", e); return false; }}
export async function checkAdminAccess(): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/check", { skipAuthRedirect: true, }); if (!res.ok) return false; const data = await res.json(); return data.isAdmin || false; } catch { return false; }}
export async function getAdminReports( status?: string, limit = 50, offset = 0,): Promise<{ items: ModerationReport[]; totalItems: number; pendingCount: number;}> { try { const params = new URLSearchParams(); if (status) params.append("status", status); params.append("limit", limit.toString()); params.append("offset", offset.toString()); const res = await apiRequest( `/api/moderation/admin/reports?${params.toString()}`, ); if (!res.ok) return { items: [], totalItems: 0, pendingCount: 0 }; return await res.json(); } catch (e) { console.error("Failed to fetch admin reports:", e); return { items: [], totalItems: 0, pendingCount: 0 }; }}
export async function adminTakeAction(params: { reportId: number; action: string; comment?: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/action", { method: "POST", body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to take moderation action:", e); return false; }}
export async function adminCreateLabel(params: { src: string; uri?: string; val: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/label", { method: "POST", body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to create label:", e); return false; }}
export async function adminDeleteLabel(id: number): Promise<boolean> { try { const res = await apiRequest(`/api/moderation/admin/label?id=${id}`, { method: "DELETE", }); return res.ok; } catch (e) { console.error("Failed to delete label:", e); return false; }}
export async function adminGetLabels( limit = 50, offset = 0,): Promise<{ items: HydratedLabel[] }> { try { const res = await apiRequest( `/api/moderation/admin/labels?limit=${limit}&offset=${offset}`, ); if (!res.ok) return { items: [] }; return await res.json(); } catch (e) { console.error("Failed to fetch labels:", e); return { items: [] }; }}
export interface BannedAccount { did: string; reason?: string; bannedBy: string; bannedAt: string; profile?: { did: string; handle: string; displayName?: string; avatar?: string; };}
export async function adminBanAccount(params: { did: string; reason?: string;}): Promise<boolean> { try { const res = await apiRequest("/api/moderation/admin/ban", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }); return res.ok; } catch (e) { console.error("Failed to ban account:", e); return false; }}
export async function adminUnbanAccount(did: string): Promise<boolean> { try { const res = await apiRequest( `/api/moderation/admin/ban?did=${encodeURIComponent(did)}`, { method: "DELETE" }, ); return res.ok; } catch (e) { console.error("Failed to unban account:", e); return false; }}
export async function adminGetBannedAccounts(): Promise<{ items: BannedAccount[]; total: number;}> { try { const res = await apiRequest("/api/moderation/admin/bans"); if (!res.ok) return { items: [], total: 0 }; return await res.json(); } catch (e) { console.error("Failed to fetch banned accounts:", e); return { items: [], total: 0 }; }}
export interface DocumentItem { uri: string; authorDid: string; site: string; path?: string; title: string; description?: string; tags?: string[]; canonicalUrl: string; publishedAt: string;}
export interface DocumentsResponse { items: DocumentItem[]; totalItems: number;}
export async function getDocuments({ sort = "new", limit = 30, offset = 0,}: { sort?: string; limit?: number; offset?: number;}): Promise<DocumentsResponse> { try { const params = new URLSearchParams(); if (sort) params.append("sort", sort); params.append("limit", limit.toString()); params.append("offset", offset.toString());
const res = await apiRequest(`/api/documents?${params.toString()}`, { skipAuthRedirect: true, }); if (!res.ok) throw new Error("Failed to fetch documents"); return await res.json(); } catch (e) { console.error("Failed to fetch documents:", e); return { items: [], totalItems: 0 }; }}
export async function getRecommendations( limit = 20,): Promise<DocumentsResponse & { unavailable?: boolean }> { try { const res = await apiRequest(`/api/recommendations?limit=${limit}`); if (res.status === 503) return { items: [], totalItems: 0, unavailable: true }; if (!res.ok) throw new Error("Failed to fetch recommendations"); return await res.json(); } catch (e) { console.error("Failed to fetch recommendations:", e); return { items: [], totalItems: 0, unavailable: true }; }}
export interface ReadingRoomTheme { backgroundColor: string; accentColor: string; fontFamily: string; layout: string;}
export interface ReadingRoomConfig { enabled: boolean; title: string; subtitle: string; description: string; theme: ReadingRoomTheme; featuredUris: string[]; hasSubscription: boolean; showExternalBookmarks: boolean;}
export interface ReadingRoomPublic { handle: string; did: string; title: string; subtitle: string; description: string; theme: ReadingRoomTheme; displayName: string; avatar: string; bio: string; featured: AnnotationItem[]; recent: AnnotationItem[]; totalCount: number; typeCounts?: { highlight: number; note: number; bookmark: number };}
export interface BillingStatus { status: string; plan: string; currentPeriodEnd?: string; hasSubscription: boolean;}
export interface CustomDomainInfo { domain: string; status: string; verificationRecords: Array<{ type: string; name?: string; value?: string; httpUrl?: string; httpBody?: string; }>;}
export async function getPublicReadingRoom( handle: string,): Promise<ReadingRoomPublic | null> { try { const res = await apiRequest(`/api/reading-room/${handle}`, { skipAuthRedirect: true, }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch reading room:", e); return null; }}
export interface ReadingRoomNotePublic { handle: string; did: string; roomTitle: string; displayName: string; avatar: string; theme: ReadingRoomTheme; note: AnnotationItem;}
export async function getReadingRoomNote( handle: string, uri: string,): Promise<ReadingRoomNotePublic | null> { try { const res = await apiRequest( `/api/reading-room/${handle}/note?uri=${encodeURIComponent(uri)}`, { skipAuthRedirect: true }, ); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch reading room note:", e); return null; }}
export interface ReadingRoomNotesPage { notes: AnnotationItem[]; totalCount: number;}
export async function getReadingRoomNotes( handle: string, offset: number, limit = 20,): Promise<ReadingRoomNotesPage | null> { try { const res = await apiRequest( `/api/reading-room/${handle}/notes?offset=${offset}&limit=${limit}`, { skipAuthRedirect: true }, ); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch reading room notes:", e); return null; }}
export async function getReadingRoomConfig(): Promise<ReadingRoomConfig | null> { try { const res = await apiRequest("/api/reading-room/config"); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch reading room config:", e); return null; }}
export async function updateReadingRoomConfig( config: Partial<ReadingRoomConfig>,): Promise<boolean> { try { const res = await apiRequest("/api/reading-room/config", { method: "PUT", body: JSON.stringify(config), }); return res.ok; } catch (e) { console.error("Failed to update reading room config:", e); return false; }}
export async function updateFeaturedItems(uris: string[]): Promise<boolean> { try { const res = await apiRequest("/api/reading-room/featured", { method: "PUT", body: JSON.stringify({ uris }), }); return res.ok; } catch (e) { console.error("Failed to update featured items:", e); return false; }}
export async function getCustomDomainStatus(): Promise<CustomDomainInfo | null> { try { const res = await apiRequest("/api/reading-room/domain"); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to fetch custom domain status:", e); return null; }}
export async function addCustomDomain( domain: string,): Promise<CustomDomainInfo | null> { try { const res = await apiRequest("/api/reading-room/domain", { method: "POST", body: JSON.stringify({ domain }), }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to add custom domain:", e); return null; }}
export async function pollCustomDomain(): Promise<CustomDomainInfo | null> { try { const res = await apiRequest("/api/reading-room/domain/poll", { method: "POST", }); if (!res.ok) return null; return await res.json(); } catch (e) { console.error("Failed to poll custom domain:", e); return null; }}
export async function removeCustomDomain(): Promise<boolean> { try { const res = await apiRequest("/api/reading-room/domain", { method: "DELETE", }); return res.ok; } catch (e) { console.error("Failed to remove custom domain:", e); return false; }}
export async function createCheckout(plan: string): Promise<string | null> { try { const res = await apiRequest("/api/billing/checkout", { method: "POST", body: JSON.stringify({ plan }), }); if (!res.ok) return null; const data = await res.json(); return data.url; } catch (e) { console.error("Failed to create checkout:", e); return null; }}
export async function createPortal(): Promise<string | null> { try { const res = await apiRequest("/api/billing/portal", { method: "POST", }); if (!res.ok) return null; const data = await res.json(); return data.url; } catch (e) { console.error("Failed to create portal session:", e); return null; }}
export async function getBillingStatus(): Promise<BillingStatus | null> { const MAX_ATTEMPTS = 3; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { try { const res = await apiRequest("/api/billing/status"); if (res.status >= 500 && attempt < MAX_ATTEMPTS - 1) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); continue; } if (!res.ok) return null; return await res.json(); } catch (e) { if (attempt < MAX_ATTEMPTS - 1) { await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); continue; } console.error("Failed to fetch billing status:", e); return null; } } return null;}