Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 1421 lines 36 kB view raw
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 try { 41 const bskyRes = await fetch( 42 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 43 ); 44 if (bskyRes.ok) { 45 const bskyData = await bskyRes.json(); 46 if (bskyData.avatar) baseProfile.avatar = bskyData.avatar; 47 if (bskyData.displayName) 48 baseProfile.displayName = bskyData.displayName; 49 } 50 } catch (e) { 51 console.warn("Failed to fetch Bsky profile for session", e); 52 } 53 54 try { 55 const res = await fetch(`/api/profile/${data.did}`); 56 if (res.ok) { 57 const marginProfile = await res.json(); 58 if (marginProfile) { 59 if (marginProfile.description) 60 baseProfile.description = marginProfile.description; 61 if (marginProfile.followersCount) 62 baseProfile.followersCount = marginProfile.followersCount; 63 if (marginProfile.followsCount) 64 baseProfile.followsCount = marginProfile.followsCount; 65 if (marginProfile.postsCount) 66 baseProfile.postsCount = marginProfile.postsCount; 67 if (marginProfile.website) 68 baseProfile.website = marginProfile.website; 69 if (marginProfile.links) baseProfile.links = marginProfile.links; 70 } 71 } 72 } catch (e) { 73 console.debug("Failed to fetch Margin profile:", e); 74 } 75 76 sessionAtom.set(baseProfile); 77 return baseProfile; 78 } 79 80 sessionAtom.set(null); 81 return null; 82 } catch (e) { 83 console.error("Session check failed:", e); 84 sessionAtom.set(null); 85 return null; 86 } 87} 88 89async function apiRequest( 90 path: string, 91 options: RequestInit & { skipAuthRedirect?: boolean } = {}, 92): Promise<Response> { 93 const { skipAuthRedirect, ...fetchOptions } = options; 94 const headers = { 95 "Content-Type": "application/json", 96 ...(fetchOptions.headers || {}), 97 }; 98 99 const apiPath = 100 path.startsWith("/api") || path.startsWith("/auth") ? path : `/api${path}`; 101 102 const response = await fetch(apiPath, { 103 ...fetchOptions, 104 headers, 105 }); 106 107 if (response.status === 401 && !skipAuthRedirect) { 108 sessionAtom.set(null); 109 try { 110 await fetch("/auth/logout", { method: "POST" }); 111 } catch { 112 // Ignore 113 } 114 if (window.location.pathname !== "/login") { 115 window.location.href = "/login"; 116 } 117 } 118 119 return response; 120} 121 122export interface GetFeedParams { 123 source?: string; 124 type?: string; 125 limit?: number; 126 offset?: number; 127 motivation?: string; 128 tag?: string; 129 creator?: string; 130} 131 132interface RawItem { 133 type?: string; 134 collectionUri?: string; 135 annotation?: RawItem; 136 highlight?: RawItem; 137 bookmark?: RawItem; 138 uri?: string; 139 id?: string; 140 cid?: string; 141 author?: UserProfile; 142 creator?: UserProfile; 143 collection?: { 144 uri: string; 145 name: string; 146 icon?: string; 147 }; 148 context?: { 149 uri: string; 150 name: string; 151 icon?: string; 152 }[]; 153 created?: string; 154 createdAt?: string; 155 target?: string | { source?: string; title?: string; selector?: Selector }; 156 url?: string; 157 targetUrl?: string; 158 title?: string; 159 selector?: Selector; 160 viewer?: { like?: string; [key: string]: unknown }; 161 viewerHasLiked?: boolean; 162 motivation?: string; 163 [key: string]: unknown; 164} 165 166function normalizeItem(raw: RawItem): AnnotationItem { 167 if (raw.type === "CollectionItem" || raw.collectionUri) { 168 const inner = raw.annotation || raw.highlight || raw.bookmark || {}; 169 const normalizedInner = normalizeItem(inner); 170 171 return { 172 ...normalizedInner, 173 uri: normalizedInner.uri || raw.uri || "", 174 cid: normalizedInner.cid || raw.cid || "", 175 author: (normalizedInner.author || 176 raw.author || 177 raw.creator) as UserProfile, 178 collection: raw.collection 179 ? { 180 uri: raw.collection.uri, 181 name: raw.collection.name, 182 icon: raw.collection.icon, 183 } 184 : undefined, 185 context: raw.context 186 ? raw.context.map((c) => ({ 187 uri: c.uri, 188 name: c.name, 189 icon: c.icon, 190 })) 191 : undefined, 192 addedBy: raw.creator || raw.author, 193 createdAt: 194 normalizedInner.createdAt || 195 raw.created || 196 raw.createdAt || 197 new Date().toISOString(), 198 collectionItemUri: raw.id || raw.uri, 199 }; 200 } 201 202 let target: Target | undefined; 203 204 if (raw.target) { 205 if (typeof raw.target === "string") { 206 target = { source: raw.target, title: raw.title, selector: raw.selector }; 207 } else { 208 target = { 209 source: raw.target.source || "", 210 title: raw.target.title || raw.title, 211 selector: raw.target.selector || raw.selector, 212 }; 213 } 214 } 215 216 if (!target || !target.source) { 217 const url = 218 raw.url || 219 raw.targetUrl || 220 (typeof raw.target === "string" ? raw.target : raw.target?.source); 221 if (url) { 222 target = { 223 source: url, 224 title: 225 raw.title || 226 (typeof raw.target !== "string" ? raw.target?.title : undefined), 227 selector: 228 raw.selector || 229 (typeof raw.target !== "string" ? raw.target?.selector : undefined), 230 }; 231 } 232 } 233 234 return { 235 ...raw, 236 uri: raw.id || raw.uri || "", 237 cid: raw.cid || "", 238 author: (raw.creator || raw.author) as UserProfile, 239 createdAt: raw.created || raw.createdAt || new Date().toISOString(), 240 target: target, 241 viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined }, 242 motivation: raw.motivation || "highlighting", 243 parentUri: (raw as Record<string, unknown>).inReplyTo as string | undefined, 244 }; 245} 246 247export async function searchItems( 248 query: string, 249 options: { creator?: string; limit?: number; offset?: number } = {}, 250): Promise<FeedResponse> { 251 const params = new URLSearchParams(); 252 params.append("q", query); 253 if (options.creator) params.append("creator", options.creator); 254 if (options.limit) params.append("limit", options.limit.toString()); 255 if (options.offset) params.append("offset", options.offset.toString()); 256 257 try { 258 const res = await apiRequest(`/api/search?${params.toString()}`, { 259 skipAuthRedirect: true, 260 }); 261 if (!res.ok) throw new Error("Search failed"); 262 const data = await res.json(); 263 const items: AnnotationItem[] = (data.items || []).map(normalizeItem); 264 return { 265 items, 266 hasMore: items.length >= (options.limit || 50), 267 fetchedCount: items.length, 268 }; 269 } catch (e) { 270 console.error("Search error:", e); 271 return { items: [], hasMore: false, fetchedCount: 0 }; 272 } 273} 274 275export async function getFeed({ 276 source, 277 type = "all", 278 limit = 50, 279 offset = 0, 280 motivation, 281 tag, 282 creator, 283}: GetFeedParams): Promise<FeedResponse> { 284 const params = new URLSearchParams(); 285 if (source) params.append("source", source); 286 if (type) params.append("type", type); 287 if (limit) params.append("limit", limit.toString()); 288 if (offset) params.append("offset", offset.toString()); 289 if (motivation) params.append("motivation", motivation); 290 if (tag) params.append("tag", tag); 291 if (creator) params.append("creator", creator); 292 293 const endpoint = source ? "/api/targets" : "/api/annotations/feed"; 294 295 try { 296 const res = await apiRequest(`${endpoint}?${params.toString()}`, { 297 skipAuthRedirect: true, 298 }); 299 if (!res.ok) throw new Error("Failed to fetch feed"); 300 const data = await res.json(); 301 const normalizedItems: AnnotationItem[] = (data.items || []).map( 302 normalizeItem, 303 ); 304 305 const groupedItems: AnnotationItem[] = []; 306 if (normalizedItems.length > 0) { 307 groupedItems.push(normalizedItems[0]); 308 309 for (let i = 1; i < normalizedItems.length; i++) { 310 const prev = groupedItems[groupedItems.length - 1]; 311 const curr = normalizedItems[i]; 312 313 if (prev.collection && curr.collection) { 314 if ( 315 prev.uri === curr.uri && 316 prev.addedBy?.did === curr.addedBy?.did 317 ) { 318 if (!prev.context) { 319 prev.context = [prev.collection]; 320 } 321 prev.context.push(curr.collection); 322 groupedItems[groupedItems.length - 1] = prev; 323 continue; 324 } 325 } 326 groupedItems.push(curr); 327 } 328 } 329 330 return { 331 items: groupedItems, 332 hasMore: normalizedItems.length >= limit, 333 fetchedCount: normalizedItems.length, 334 }; 335 } catch (e) { 336 console.error(e); 337 return { items: [], hasMore: false, fetchedCount: 0 }; 338 } 339} 340 341interface CreateAnnotationParams { 342 url: string; 343 text?: string; 344 title?: string; 345 selector?: { exact: string; prefix?: string; suffix?: string }; 346 tags?: string[]; 347 labels?: string[]; 348} 349 350export async function createAnnotation({ 351 url, 352 text, 353 title, 354 selector, 355 tags, 356 labels, 357}: CreateAnnotationParams) { 358 try { 359 const res = await apiRequest("/api/annotations", { 360 method: "POST", 361 body: JSON.stringify({ url, text, title, selector, tags, labels }), 362 }); 363 if (!res.ok) throw new Error(await res.text()); 364 const raw = await res.json(); 365 return normalizeItem(raw); 366 } catch (e) { 367 console.error(e); 368 return { error: e instanceof Error ? e.message : "Unknown error" }; 369 } 370} 371 372interface CreateHighlightParams { 373 url: string; 374 selector: { exact: string; prefix?: string; suffix?: string }; 375 color?: string; 376 tags?: string[]; 377 title?: string; 378 labels?: string[]; 379} 380 381export async function createHighlight({ 382 url, 383 selector, 384 color, 385 tags, 386 title, 387 labels, 388}: CreateHighlightParams) { 389 try { 390 const res = await apiRequest("/api/highlights", { 391 method: "POST", 392 body: JSON.stringify({ url, selector, color, tags, title, labels }), 393 }); 394 if (!res.ok) throw new Error(await res.text()); 395 const raw = await res.json(); 396 return normalizeItem(raw); 397 } catch (e) { 398 console.error(e); 399 return { error: e instanceof Error ? e.message : "Unknown error" }; 400 } 401} 402 403export async function createBookmark({ 404 url, 405 title, 406 description, 407 tags, 408}: { 409 url: string; 410 title?: string; 411 description?: string; 412 tags?: string[]; 413}) { 414 try { 415 const res = await apiRequest("/api/bookmarks", { 416 method: "POST", 417 body: JSON.stringify({ url, title, description, tags }), 418 }); 419 if (!res.ok) throw new Error(await res.text()); 420 const raw = await res.json(); 421 return normalizeItem(raw); 422 } catch (e) { 423 console.error(e); 424 return { error: e instanceof Error ? e.message : "Unknown error" }; 425 } 426} 427 428export async function uploadAvatar( 429 file: File, 430): Promise<{ blob: Blob | string }> { 431 const formData = new FormData(); 432 formData.append("file", file); 433 const res = await fetch("/api/upload/avatar", { 434 method: "POST", 435 headers: { 436 Authorization: `Bearer ${(await checkSession())?.did}`, 437 }, 438 body: formData, 439 }); 440 if (!res.ok) throw new Error("Failed to upload avatar"); 441 return res.json(); 442} 443 444export async function updateProfile(updates: { 445 displayName?: string; 446 description?: string; 447 avatar?: Blob | string | null; 448 website?: string; 449 links?: string[]; 450}): Promise<boolean> { 451 try { 452 const { description, ...rest } = updates; 453 const body = { ...rest, bio: description }; 454 const res = await apiRequest("/api/profile", { 455 method: "PUT", 456 body: JSON.stringify(body), 457 }); 458 return res.ok; 459 } catch (e) { 460 console.error(e); 461 return false; 462 } 463} 464 465export async function likeItem(uri: string, cid: string): Promise<boolean> { 466 try { 467 const res = await apiRequest("/api/annotations/like", { 468 method: "POST", 469 body: JSON.stringify({ subjectUri: uri, subjectCid: cid }), 470 }); 471 return res.ok; 472 } catch (e) { 473 console.error("Failed to like item:", e); 474 return false; 475 } 476} 477 478export async function unlikeItem(uri: string): Promise<boolean> { 479 try { 480 const res = await apiRequest( 481 `/api/annotations/like?uri=${encodeURIComponent(uri)}`, 482 { 483 method: "DELETE", 484 }, 485 ); 486 return res.ok; 487 } catch (e) { 488 console.error("Failed to unlike item:", e); 489 return false; 490 } 491} 492 493export async function deleteItem( 494 uri: string, 495 type: string = "annotation", 496): Promise<boolean> { 497 const rkey = (uri || "").split("/").pop(); 498 499 let endpoint = "/api/annotations"; 500 if (type === "highlight" || uri.includes("highlight")) { 501 endpoint = "/api/highlights"; 502 } else if (type === "bookmark" || uri.includes("bookmark")) { 503 endpoint = "/api/bookmarks"; 504 } 505 506 try { 507 const res = await apiRequest(`${endpoint}?rkey=${rkey}`, { 508 method: "DELETE", 509 }); 510 return res.ok; 511 } catch (e) { 512 console.error("Failed to delete item:", e); 513 return false; 514 } 515} 516 517export async function convertHighlightToAnnotation( 518 highlightUri: string, 519 url: string, 520 text: string, 521 selector?: { exact: string; prefix?: string; suffix?: string }, 522 title?: string, 523): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> { 524 try { 525 const createRes = await apiRequest("/api/annotations", { 526 method: "POST", 527 body: JSON.stringify({ url, text, title, selector }), 528 }); 529 if (!createRes.ok) { 530 const err = await createRes.text(); 531 return { success: false, error: err }; 532 } 533 const created = normalizeItem(await createRes.json()); 534 535 const rkey = (highlightUri || "").split("/").pop(); 536 if (rkey) { 537 await apiRequest(`/api/highlights?rkey=${rkey}`, { method: "DELETE" }); 538 } 539 540 return { success: true, item: created }; 541 } catch (e) { 542 console.error("Failed to convert highlight:", e); 543 return { 544 success: false, 545 error: e instanceof Error ? e.message : "Unknown error", 546 }; 547 } 548} 549 550export async function updateAnnotation( 551 uri: string, 552 text: string, 553 tags?: string[], 554 labels?: string[], 555): Promise<boolean> { 556 try { 557 const res = await apiRequest( 558 `/api/annotations?uri=${encodeURIComponent(uri)}`, 559 { 560 method: "PUT", 561 body: JSON.stringify({ text, tags, labels }), 562 }, 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/annotations/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/annotations/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/annotations/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( 1027 `/api/annotation?uri=${encodeURIComponent(uri)}`, 1028 ); 1029 if (!res.ok) return null; 1030 return normalizeItem(await res.json()); 1031 } catch { 1032 return null; 1033 } 1034} 1035 1036export async function getReplies( 1037 uri: string, 1038): Promise<{ items: AnnotationItem[] }> { 1039 try { 1040 const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`); 1041 if (!res.ok) return { items: [] }; 1042 const data = await res.json(); 1043 return { items: (data.items || []).map(normalizeItem) }; 1044 } catch { 1045 return { items: [] }; 1046 } 1047} 1048 1049export async function getByTarget( 1050 url: string, 1051 limit = 50, 1052 offset = 0, 1053): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { 1054 try { 1055 const res = await apiRequest( 1056 `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 1057 ); 1058 if (!res.ok) return { annotations: [], highlights: [] }; 1059 const data = await res.json(); 1060 return { 1061 annotations: (data.annotations || []).map(normalizeItem), 1062 highlights: (data.highlights || []).map(normalizeItem), 1063 }; 1064 } catch { 1065 return { annotations: [], highlights: [] }; 1066 } 1067} 1068 1069export async function getUserTargetItems( 1070 did: string, 1071 url: string, 1072 limit = 50, 1073 offset = 0, 1074): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { 1075 try { 1076 const res = await apiRequest( 1077 `/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 1078 ); 1079 if (!res.ok) return { annotations: [], highlights: [] }; 1080 const data = await res.json(); 1081 return { 1082 annotations: (data.annotations || []).map(normalizeItem), 1083 highlights: (data.highlights || []).map(normalizeItem), 1084 }; 1085 } catch { 1086 return { annotations: [], highlights: [] }; 1087 } 1088} 1089 1090import type { 1091 LabelerInfo, 1092 LabelerSubscription, 1093 LabelPreference, 1094} from "../types"; 1095 1096export interface PreferencesResponse { 1097 externalLinkSkippedHostnames?: string[]; 1098 subscribedLabelers?: LabelerSubscription[]; 1099 labelPreferences?: LabelPreference[]; 1100 disableExternalLinkWarning?: boolean; 1101} 1102 1103export async function getPreferences(): Promise<PreferencesResponse> { 1104 try { 1105 const res = await apiRequest("/api/preferences", { 1106 skipAuthRedirect: true, 1107 }); 1108 if (!res.ok) return {}; 1109 return await res.json(); 1110 } catch (e) { 1111 console.error(e); 1112 return {}; 1113 } 1114} 1115 1116export async function updatePreferences(prefs: { 1117 externalLinkSkippedHostnames?: string[]; 1118 subscribedLabelers?: LabelerSubscription[]; 1119 labelPreferences?: LabelPreference[]; 1120 disableExternalLinkWarning?: 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 DocumentItem { 1366 uri: string; 1367 authorDid: string; 1368 site: string; 1369 path?: string; 1370 title: string; 1371 description?: string; 1372 tags?: string[]; 1373 canonicalUrl: string; 1374 publishedAt: string; 1375} 1376 1377export interface DocumentsResponse { 1378 items: DocumentItem[]; 1379 totalItems: number; 1380} 1381 1382export async function getDocuments({ 1383 sort = "new", 1384 limit = 30, 1385 offset = 0, 1386}: { 1387 sort?: string; 1388 limit?: number; 1389 offset?: number; 1390}): Promise<DocumentsResponse> { 1391 try { 1392 const params = new URLSearchParams(); 1393 if (sort) params.append("sort", sort); 1394 params.append("limit", limit.toString()); 1395 params.append("offset", offset.toString()); 1396 1397 const res = await apiRequest(`/api/documents?${params.toString()}`, { 1398 skipAuthRedirect: true, 1399 }); 1400 if (!res.ok) throw new Error("Failed to fetch documents"); 1401 return await res.json(); 1402 } catch (e) { 1403 console.error("Failed to fetch documents:", e); 1404 return { items: [], totalItems: 0 }; 1405 } 1406} 1407 1408export async function getRecommendations( 1409 limit = 20, 1410): Promise<DocumentsResponse & { unavailable?: boolean }> { 1411 try { 1412 const res = await apiRequest(`/api/recommendations?limit=${limit}`); 1413 if (res.status === 503) 1414 return { items: [], totalItems: 0, unavailable: true }; 1415 if (!res.ok) throw new Error("Failed to fetch recommendations"); 1416 return await res.json(); 1417 } catch (e) { 1418 console.error("Failed to fetch recommendations:", e); 1419 return { items: [], totalItems: 0 }; 1420 } 1421}