Social Annotations in the Atmosphere
15
fork

Configure Feed

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

Merge branch 'rebuild-sidepanel'

+383 -178
+56 -40
entrypoints/background.ts
··· 1 + import { loadSession } from '@/lib/oauth'; 2 + import { listAllAnnotations, listComments } from '@/lib/pds'; 3 + 1 4 export default defineBackground(() => { 2 - // Create context menu on install 3 - browser.runtime.onInstalled.addListener(() => { 4 - browser.contextMenus.create({ 5 - id: 'annotate-selection', 6 - title: 'Annotate', 7 - contexts: ['selection'], 8 - }); 5 + let syncing = false; 6 + 7 + // Sync on startup 8 + browser.runtime.onStartup.addListener(async () => { 9 + console.log('[background] Extension startup, syncing from PDS...'); 10 + await syncFromPDS(); 9 11 }); 10 12 11 - // Handle context menu click 12 - browser.contextMenus.onClicked.addListener(async (info, tab) => { 13 - if (info.menuItemId === 'annotate-selection' && tab?.id) { 14 - // Tell content script to save annotation immediately 15 - browser.tabs.sendMessage(tab.id, { 16 - type: 'SAVE_ANNOTATION', 17 - }); 18 - } 13 + // Sync on install (first run) 14 + browser.runtime.onInstalled.addListener(async () => { 15 + console.log('[background] Extension installed, syncing from PDS...'); 16 + await syncFromPDS(); 19 17 }); 20 18 21 19 // Open sidepanel when extension icon is clicked ··· 28 26 } 29 27 }); 30 28 31 - // Handle auth state changes 29 + // Handle messages 32 30 browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 33 - if (message.type === 'AUTH_STATE_CHANGED') { 34 - // Broadcast auth state change to all extension contexts 35 - browser.runtime.sendMessage({ 36 - type: 'AUTH_STATE_CHANGED', 37 - isAuthenticated: message.isAuthenticated, 38 - }).catch(() => { 39 - // Ignore if no listeners 40 - }); 31 + if (message.type === 'SYNC_CACHE') { 32 + console.log('[background] SYNC_CACHE requested'); 33 + syncFromPDS(); // fire-and-forget 41 34 sendResponse({ success: true }); 42 - return true; 43 35 } 36 + }); 44 37 45 - if (message.type === 'LOGOUT') { 46 - handleLogout().then(() => sendResponse({ success: true })); 47 - return true; 38 + async function syncFromPDS() { 39 + if (syncing) { 40 + console.log('[background] Sync already in progress, skipping'); 41 + return; 48 42 } 49 - }); 50 43 51 - async function handleLogout() { 52 - // Clear OAuth session from storage 53 - await browser.storage.local.remove('synthesis-oauth:session'); 54 - 55 - // Notify all contexts 56 - browser.runtime.sendMessage({ 57 - type: 'AUTH_STATE_CHANGED', 58 - isAuthenticated: false, 59 - }).catch(() => { 60 - // Ignore if no listeners 61 - }); 44 + syncing = true; 45 + 46 + try { 47 + console.log('[background] Syncing from slices.network...'); 48 + 49 + // Fetch all annotations from slices.network (network-wide) 50 + const annotations = await listAllAnnotations(); 51 + 52 + // Fetch user's comments from PDS (if logged in) 53 + const session = await loadSession(); 54 + const comments = session ? await listComments() : []; 55 + 56 + await browser.storage.local.set({ 57 + annotations, 58 + comments, 59 + lastSync: Date.now(), 60 + syncError: null, 61 + lastSyncAttempt: Date.now() 62 + }); 63 + 64 + console.log('[background] Sync complete:', { 65 + annotations: annotations.length, 66 + comments: comments.length, 67 + source: 'slices.network + PDS' 68 + }); 69 + } catch (error) { 70 + console.error('[background] Sync error:', error); 71 + await browser.storage.local.set({ 72 + syncError: String(error?.message || error), 73 + lastSyncAttempt: Date.now() 74 + }); 75 + } finally { 76 + syncing = false; 77 + } 62 78 } 63 79 });
+102 -44
entrypoints/content.ts
··· 5 5 export default defineContentScript({ 6 6 matches: ['<all_urls>'], 7 7 main() { 8 - console.log('[synthesis] content script loaded'); 8 + console.log('[content] content script loaded'); 9 9 10 10 let currentSelection: { 11 11 text: string; 12 12 selectors: Selector[]; 13 13 } | null = null; 14 14 15 - let currentAnnotations: Annotation[] = []; 15 + let currentUrl: string = normalizeUrl(window.location.href); 16 + 17 + function normalizeUrl(url: string): string { 18 + try { 19 + const parsed = new URL(url); 20 + // Remove fragment 21 + parsed.hash = ''; 22 + // Remove trailing slash 23 + let path = parsed.pathname; 24 + if (path.endsWith('/') && path !== '/') { 25 + path = path.slice(0, -1); 26 + } 27 + parsed.pathname = path; 28 + return parsed.toString(); 29 + } catch { 30 + return url; 31 + } 32 + } 33 + 34 + // Load and render highlights on page load 35 + (async function init() { 36 + currentUrl = normalizeUrl(window.location.href); 37 + await loadAndRenderHighlights(); 38 + })(); 39 + 40 + async function loadAndRenderHighlights() { 41 + const { annotations } = await browser.storage.local.get('annotations'); 42 + 43 + // Always clear highlights first 44 + clearHighlights(); 45 + 46 + if (!annotations || annotations.length === 0) { 47 + console.log('[content] No annotations in cache'); 48 + return; 49 + } 50 + 51 + const url = normalizeUrl(currentUrl); 52 + 53 + // Filter by current URL 54 + const pageAnnotations = annotations.filter( 55 + (ann: Annotation) => normalizeUrl(ann.target[0]?.source) === url 56 + ); 57 + 58 + console.log(`[content] Found ${pageAnnotations.length} annotations for ${url}`); 59 + 60 + if (pageAnnotations.length > 0) { 61 + applyHighlights(pageAnnotations); 62 + } 63 + } 64 + 65 + // Watch for storage changes 66 + browser.storage.onChanged.addListener((changes, area) => { 67 + if (area === 'local' && changes.annotations) { 68 + console.log('[content] Annotations cache updated, re-rendering'); 69 + loadAndRenderHighlights(); 70 + } 71 + }); 16 72 17 73 // Track text selection 18 74 document.addEventListener('mouseup', () => { ··· 24 80 25 81 currentSelection = { text, selectors }; 26 82 27 - console.log('[synthesis] text selected:', text); 28 - console.log('[synthesis] selectors:', selectors); 83 + console.log('[content] text selected:', text); 29 84 30 - // Notify sidebar that selection changed 85 + // Notify sidepanel of selection change (ephemeral state) 31 86 browser.runtime.sendMessage({ 32 87 type: 'SELECTION_CHANGED', 33 - data: { text, selectors } 88 + selection: currentSelection 34 89 }).catch(() => { 35 - // Sidebar might not be open 90 + // Sidepanel might not be open 36 91 }); 37 92 } else { 38 93 currentSelection = null; 94 + 95 + // Notify sidepanel selection was cleared 96 + browser.runtime.sendMessage({ 97 + type: 'SELECTION_CHANGED', 98 + selection: null 99 + }).catch(() => {}); 39 100 } 40 101 }); 41 102 42 - // Listen for messages from sidebar 103 + // Detect URL changes (SPA navigation) 104 + let lastUrl = window.location.href; 105 + 106 + // Native navigation events 107 + window.addEventListener('popstate', handleUrlChange); 108 + 109 + // Intercept pushState/replaceState 110 + const originalPushState = history.pushState; 111 + history.pushState = function(...args) { 112 + originalPushState.apply(this, args); 113 + handleUrlChange(); 114 + }; 115 + 116 + const originalReplaceState = history.replaceState; 117 + history.replaceState = function(...args) { 118 + originalReplaceState.apply(this, args); 119 + handleUrlChange(); 120 + }; 121 + 122 + function handleUrlChange() { 123 + const newUrl = normalizeUrl(window.location.href); 124 + if (newUrl !== lastUrl) { 125 + console.log('[content] URL changed:', lastUrl, '→', newUrl); 126 + lastUrl = newUrl; 127 + currentUrl = newUrl; 128 + currentSelection = null; 129 + loadAndRenderHighlights(); 130 + } 131 + } 132 + 133 + // Respond to GET_STATE requests 43 134 browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 44 - console.log('[synthesis] Received message:', message.type); 45 - if (message.type === 'GET_SELECTION') { 135 + if (message.type === 'GET_STATE') { 46 136 sendResponse({ 47 - selection: currentSelection, 48 - url: window.location.href, 49 - title: document.title 137 + url: currentUrl, 138 + selection: currentSelection 50 139 }); 51 - return true; 52 140 } 53 - 54 - if (message.type === 'UPDATE_HIGHLIGHTS') { 55 - currentAnnotations = message.annotations || []; 56 - console.log('[synthesis] Received UPDATE_HIGHLIGHTS:', currentAnnotations.length); 57 - console.log('[synthesis] Annotations data:', currentAnnotations); 58 - console.log('[synthesis] Document ready state:', document.readyState); 59 - 60 - // Apply highlights immediately 61 - applyHighlights(currentAnnotations); 62 - 63 - sendResponse({ success: true }); 64 - return true; 65 - } 66 - 67 - if (message.type === 'CLEAR_HIGHLIGHTS') { 68 - clearHighlights(); 69 - currentAnnotations = []; 70 - sendResponse({ success: true }); 71 - return true; 72 - } 73 - 74 - if (message.type === 'SAVE_ANNOTATION') { 75 - console.log('[synthesis] SAVE_ANNOTATION message received - handled by sidepanel'); 76 - sendResponse({ success: false, error: 'Use sidepanel to save annotations' }); 77 - return true; 78 - } 79 - 80 - return false; 81 141 }); 82 - 83 - // Don't use MutationObserver - it causes conflicts with page JavaScript 84 142 }, 85 143 });
+161 -94
entrypoints/sidepanel/main.ts
··· 2 2 import type { Annotation } from '@/lib/types/annotation'; 3 3 import type { Comment } from '@/lib/types/comment'; 4 4 import { initializeOAuth, startLoginProcess, loadSession, clearSession, getProfile } from '@/lib/oauth'; 5 - import { createAnnotation, listAnnotations, createComment, listComments } from '@/lib/pds'; 5 + import { createAnnotation, createComment } from '@/lib/pds'; 6 6 7 - console.log('[synthesis] sidepanel script loading...'); 7 + console.log('[sidepanel] sidepanel script loading...'); 8 8 9 9 // Initialize OAuth 10 10 initializeOAuth(); ··· 14 14 const app = document.getElementById('app'); 15 15 if (!app) return; 16 16 17 + let currentTabId: number | null = null; 17 18 let currentUrl = ''; 18 19 let currentSelection: { text: string; selectors: any[] } | null = null; 20 + let pageAnnotations: Annotation[] = []; 19 21 let allComments: Comment[] = []; 20 22 const collapsedThreads = new Set<string>(); 21 23 const activeReplyForms = new Set<string>(); 22 24 25 + function normalizeUrl(url: string): string { 26 + try { 27 + const parsed = new URL(url); 28 + // Remove fragment 29 + parsed.hash = ''; 30 + // Remove trailing slash 31 + let path = parsed.pathname; 32 + if (path.endsWith('/') && path !== '/') { 33 + path = path.slice(0, -1); 34 + } 35 + parsed.pathname = path; 36 + return parsed.toString(); 37 + } catch { 38 + return url; 39 + } 40 + } 41 + 23 42 app.innerHTML = ` 24 43 <div class="sidebar"> 25 44 <div class="auth-section" id="auth-section"> ··· 83 102 } 84 103 if (authSection) authSection.style.display = 'none'; 85 104 if (contentSection) contentSection.style.display = 'block'; 105 + 106 + // Trigger sync on sidepanel open if already logged in 107 + browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 86 108 } catch (error) { 87 109 console.error('Failed to fetch profile:', error); 88 110 if (authSection) authSection.style.display = 'none'; ··· 120 142 } 121 143 if (authSection) authSection.style.display = 'none'; 122 144 if (contentSection) contentSection.style.display = 'block'; 145 + 146 + // Trigger initial sync after login 147 + browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 123 148 } catch (error) { 124 149 console.error('Failed to fetch profile:', error); 125 150 if (authSection) authSection.style.display = 'none'; ··· 161 186 // Logout handler 162 187 logoutBtn?.addEventListener('click', async () => { 163 188 await clearSession(); 189 + await browser.storage.local.remove(['annotations', 'comments', 'lastSync', 'syncError', 'lastSyncAttempt']); 164 190 if (profileAvatar) profileAvatar.style.display = 'none'; 165 191 if (profileDropdown) profileDropdown.style.display = 'none'; 166 192 if (authSection) authSection.style.display = 'flex'; ··· 176 202 if (annotationForm) annotationForm.style.display = 'none'; 177 203 }); 178 204 179 - // Listen for selection changes 180 - browser.runtime.onMessage.addListener((message) => { 181 - if (message.type === 'SELECTION_CHANGED') { 182 - currentSelection = message.data; 183 - if (currentSelection && currentSelection.text) { 184 - if (selectedTextEl) { 185 - selectedTextEl.innerHTML = `<blockquote>${currentSelection.text}</blockquote>`; 186 - } 187 - if (annotationForm) annotationForm.style.display = 'block'; 188 - } else { 189 - if (annotationForm) annotationForm.style.display = 'none'; 190 - currentSelection = null; 191 - } 192 - console.log('Selection updated:', currentSelection); 205 + // Initialize on open 206 + (async function init() { 207 + await refreshActiveTab(); 208 + })(); 209 + 210 + async function refreshActiveTab() { 211 + // Get active tab 212 + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 213 + const activeTab = tabs[0]; 214 + 215 + if (!activeTab?.id) { 216 + console.error('[sidepanel] No active tab'); 217 + return; 218 + } 219 + 220 + currentTabId = activeTab.id; 221 + 222 + // Get state from content script 223 + try { 224 + const response = await browser.tabs.sendMessage(currentTabId, { 225 + type: 'GET_STATE' 226 + }); 227 + 228 + currentUrl = response.url; 229 + currentSelection = response.selection; 230 + } catch (error) { 231 + console.warn('[sidepanel] Content script not available; falling back to tab URL'); 232 + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 233 + currentUrl = tabs[0]?.url || ''; 234 + currentSelection = null; 235 + } 236 + 237 + // Update UI 238 + updateSelectionUI(); 239 + 240 + // Load annotations for this URL 241 + await loadAnnotationsForUrl(currentUrl); 242 + } 243 + 244 + function updateSelectionUI() { 245 + if (currentSelection && currentSelection.text && selectedTextEl) { 246 + selectedTextEl.innerHTML = `<blockquote>${currentSelection.text}</blockquote>`; 247 + if (annotationForm) annotationForm.style.display = 'block'; 248 + } else { 249 + if (annotationForm) annotationForm.style.display = 'none'; 250 + } 251 + } 252 + 253 + // Watch for tab changes 254 + browser.tabs.onActivated.addListener(({ tabId }) => { 255 + if (tabId !== currentTabId) { 256 + console.log('[sidepanel] Active tab changed:', tabId); 257 + refreshActiveTab(); 193 258 } 194 259 }); 195 260 196 - // Get current page info 197 - browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 198 - if (tabs[0]?.id) { 199 - browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }).then(response => { 200 - currentUrl = response.url; 201 - currentSelection = response.selection; 261 + // Watch for URL changes in active tab 262 + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 263 + if (tabId === currentTabId && changeInfo.url) { 264 + console.log('[sidepanel] Active tab URL changed:', changeInfo.url); 265 + currentUrl = changeInfo.url; 266 + currentSelection = null; 267 + updateSelectionUI(); 268 + loadAnnotationsForUrl(currentUrl); 269 + } 270 + }); 202 271 203 - if (currentSelection && currentSelection.text && selectedTextEl) { 204 - selectedTextEl.innerHTML = ` 205 - <blockquote>${currentSelection.text}</blockquote> 206 - `; 207 - if (annotationForm) annotationForm.style.display = 'block'; 208 - } else { 209 - if (annotationForm) annotationForm.style.display = 'none'; 210 - } 272 + // Watch for storage changes 273 + browser.storage.onChanged.addListener((changes, area) => { 274 + if (area === 'local' && (changes.annotations || changes.comments)) { 275 + console.log('[sidepanel] Cache updated, reloading for current URL'); 276 + loadAnnotationsForUrl(currentUrl); 277 + } 278 + }); 211 279 212 - loadAnnotations(currentUrl); 213 - }).catch(err => { 214 - console.error('Failed to get selection:', err); 215 - }); 280 + // Listen for selection changes from content script 281 + browser.runtime.onMessage.addListener((message) => { 282 + if (message.type === 'SELECTION_CHANGED') { 283 + console.log('[sidepanel] Selection changed'); 284 + currentSelection = message.selection; 285 + updateSelectionUI(); 216 286 } 217 287 }); 218 288 219 - // Send highlights to content script when annotations change 220 - async function updateHighlights(annotations: Annotation[]) { 221 - console.log('[synthesis] Sending highlights to content script:', annotations.length); 222 - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 223 - console.log('[synthesis] Active tab:', tabs[0]?.id, tabs[0]?.url); 224 289 225 - if (tabs[0]?.id) { 226 - browser.tabs.sendMessage(tabs[0].id, { 227 - type: 'UPDATE_HIGHLIGHTS', 228 - annotations 229 - }).then(response => { 230 - console.log('[synthesis] Highlights updated successfully:', response); 231 - }).catch(err => { 232 - console.error('[synthesis] Failed to update highlights:', err); 233 - }); 234 - } else { 235 - console.error('[synthesis] No active tab found'); 236 - } 237 - } 238 290 239 291 // Save annotation 240 292 saveBtn?.addEventListener('click', async () => { ··· 246 298 const body = annotationTextarea.value.trim(); 247 299 248 300 const annotation: Annotation = { 249 - $type: 'community.lexicon.annotation.annotation', 250 - target: [{ 301 + $type: 'community.lexicon.annotation.annotation', 302 + target: [{ 251 303 source: currentUrl, 252 304 selector: currentSelection.selectors 253 305 }], ··· 255 307 createdAt: new Date().toISOString() 256 308 }; 257 309 258 - // Store locally for now (will replace with atproto) 259 - await saveAnnotationLocal(annotation); 260 - 261 - // Clear form 262 - annotationTextarea.value = ''; 263 - if (selectedTextEl) selectedTextEl.innerHTML = ''; 264 - currentSelection = null; 265 - if (annotationForm) annotationForm.style.display = 'none'; 266 - 267 - // Reload annotations 268 - await loadAnnotations(currentUrl); 310 + await createAndSyncAnnotation(annotation); 269 311 }); 270 312 271 - async function saveAnnotationLocal(annotation: Annotation) { 313 + async function createAndSyncAnnotation(annotation: Annotation) { 272 314 try { 273 - const savedAnnotation = await createAnnotation(annotation); 274 - console.log('Annotation saved to PDS:', savedAnnotation); 275 - return savedAnnotation; 315 + // Save to PDS 316 + const saved = await createAnnotation(annotation); 317 + console.log('[sidepanel] Saved to PDS:', saved.uri); 318 + 319 + // Optimistically add to cache immediately 320 + const { annotations } = await browser.storage.local.get('annotations'); 321 + const updatedAnnotations = [...(annotations || []), saved]; 322 + await browser.storage.local.set({ annotations: updatedAnnotations }); 323 + 324 + // Request background sync (will reconcile with slices.network later) 325 + // Use a delay to give slices.network time to index 326 + setTimeout(() => { 327 + browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 328 + }, 2000); 329 + 330 + // Clear form 331 + annotationTextarea.value = ''; 332 + if (selectedTextEl) selectedTextEl.innerHTML = ''; 333 + currentSelection = null; 334 + if (annotationForm) annotationForm.style.display = 'none'; 335 + 336 + // UI will update automatically via storage.onChanged 276 337 } catch (error) { 277 - console.error('Failed to save annotation to PDS:', error); 278 - throw error; 338 + console.error('[sidepanel] Failed to create annotation:', error); 339 + alert('Failed to save annotation'); 279 340 } 280 341 } 281 342 ··· 392 453 `; 393 454 } 394 455 395 - let pageAnnotations: Annotation[] = []; 396 - 397 456 function renderAnnotations() { 398 457 if (!annotationsContainer) return; 399 458 ··· 406 465 attachCommentEventListeners(); 407 466 } 408 467 409 - async function loadAnnotations(url: string) { 410 - try { 411 - const allAnnotations = await listAnnotations(); 412 - allComments = await listComments(); 413 - 414 - // Filter by current URL 415 - pageAnnotations = allAnnotations.filter(ann => 416 - ann.target[0]?.source === url 417 - ); 418 - 419 - renderAnnotations(); 420 - 421 - // Update highlights on page 422 - await updateHighlights(pageAnnotations); 423 - } catch (error) { 424 - console.error('Failed to load annotations from PDS:', error); 425 - if (annotationsContainer) { 426 - annotationsContainer.innerHTML = '<p class="error">Failed to load annotations. Please check your connection.</p>'; 427 - } 468 + async function loadAnnotationsForUrl(url: string) { 469 + const { annotations, comments, syncError, lastSync } = await browser.storage.local.get([ 470 + 'annotations', 471 + 'comments', 472 + 'syncError', 473 + 'lastSync' 474 + ]); 475 + 476 + const norm = normalizeUrl(url); 477 + 478 + // Filter by URL 479 + pageAnnotations = (annotations || []).filter( 480 + (ann: Annotation) => normalizeUrl(ann.target[0]?.source) === norm 481 + ); 482 + 483 + allComments = comments || []; 484 + 485 + // Show sync error if present 486 + if (syncError) { 487 + const lastSyncDate = lastSync ? new Date(lastSync).toLocaleString() : 'never'; 488 + console.warn(`[sidepanel] Sync failed: ${syncError}. Last successful sync: ${lastSyncDate}`); 428 489 } 490 + 491 + renderAnnotations(); 429 492 } 430 493 431 494 function attachCommentEventListeners() { ··· 488 551 createdAt: new Date().toISOString(), 489 552 }); 490 553 activeReplyForms.delete(subject); 491 - await loadAnnotations(currentUrl); 554 + 555 + // Request sync after comment creation 556 + browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 492 557 } catch (error) { 493 558 console.error('Failed to create comment:', error); 494 559 alert('Failed to post comment'); ··· 519 584 reply: { parent }, 520 585 }); 521 586 activeReplyForms.delete(parent); 522 - await loadAnnotations(currentUrl); 587 + 588 + // Request sync after reply creation 589 + browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 523 590 } catch (error) { 524 591 console.error('Failed to create reply:', error); 525 592 alert('Failed to post reply');
+64
lib/pds.ts
··· 6 6 const ANNOTATION_COLLECTION = "community.lexicon.annotation.annotation"; 7 7 const COMMENT_COLLECTION = "pub.leaflet.comment"; 8 8 9 + const SLICE_ID = 'at://did:plc:dy6ekftqerqu5bcz76kgy6ux/network.slices.slice/3m3ugigrrz52k'; 10 + const GRAPHQL_ENDPOINT = `https://api.slices.network/graphql?slice=${SLICE_ID}`; 11 + 9 12 export async function createAnnotation(annotation: Annotation): Promise<Annotation> { 10 13 const session = await loadSession(); 11 14 if (!session) { ··· 203 206 throw new Error(`Failed to delete comment: ${response.status} - ${JSON.stringify(error)}`); 204 207 } 205 208 } 209 + 210 + // Fetch all annotations from slices.network (network-wide) 211 + export async function listAllAnnotations(): Promise<Annotation[]> { 212 + const QUERY = ` 213 + query AllAnnotations { 214 + communityLexiconAnnotationAnnotations( 215 + first: 1000 216 + ) { 217 + edges { 218 + node { 219 + uri 220 + cid 221 + did 222 + actorHandle 223 + target { 224 + source 225 + selector 226 + } 227 + body 228 + tags 229 + createdAt 230 + } 231 + } 232 + } 233 + } 234 + `; 235 + 236 + try { 237 + const response = await fetch(GRAPHQL_ENDPOINT, { 238 + method: 'POST', 239 + headers: { 240 + 'Content-Type': 'application/json', 241 + }, 242 + body: JSON.stringify({ query: QUERY }) 243 + }); 244 + 245 + if (!response.ok) { 246 + throw new Error(`HTTP error! status: ${response.status}`); 247 + } 248 + 249 + const data = await response.json(); 250 + 251 + if (data.errors) { 252 + console.error('[pds] GraphQL errors:', data.errors); 253 + throw new Error(data.errors[0]?.message || 'GraphQL query failed'); 254 + } 255 + 256 + return data.data.communityLexiconAnnotationAnnotations.edges.map((edge: any) => ({ 257 + $type: ANNOTATION_COLLECTION, 258 + uri: edge.node.uri, 259 + cid: edge.node.cid, 260 + target: edge.node.target, 261 + body: edge.node.body, 262 + tags: edge.node.tags, 263 + createdAt: edge.node.createdAt, 264 + })); 265 + } catch (error) { 266 + console.error('[pds] Failed to fetch from slices.network:', error); 267 + throw error; 268 + } 269 + }