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

Configure Feed

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

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