(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

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