Social Annotations in the Atmosphere
15
fork

Configure Feed

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

annotation lexicon and styles

+1208 -280
+31
AGENTS.md
··· 1 + # AGENTS.md - synthes.is 2 + 3 + ## Project Overview 4 + **Hypothesis clone built on AT Protocol** - Web annotation system similar to Hypothesis but using ATProto/Bluesky infrastructure. 5 + 6 + ## Future Architecture Plans 7 + - **via.seams.so proxy**: Need to build a proxy service (like Hypothesis's via.hypothes.is) for annotation injection 8 + - **slices.network integration**: Backend for loading all annotations from a page - not yet configured 9 + - Current implementation is extension-only; backend integration pending 10 + 11 + ## Commands 12 + - **Dev**: `pnpm dev` (or `wxt`) - Start development server for browser extension 13 + - **Build**: `pnpm build` (or `wxt build`) - Build extension for production 14 + - **Package**: `pnpm zip` (or `wxt zip`) - Create extension zip file 15 + - **No test suite configured** - Extension is manually tested in browser 16 + 17 + ## Architecture 18 + Browser extension built with **WXT** (web extension framework) for AT Protocol web annotations: 19 + - **entrypoints/**: Background script (context menus, auth), content script (page interaction), sidepanel UI 20 + - **lib/**: Core logic - `oauth.ts` (AT Protocol auth), `pds.ts` (PDS integration), `highlights/` and `selectors/` (annotation logic), `types/` (TypeScript interfaces) 21 + - Uses `@atcute/oauth-browser-client` for AT Protocol OAuth flow and `@atproto/api` for Bluesky integration 22 + - Browser extension APIs: `browser.identity` (OAuth), `browser.contextMenus`, `browser.sidePanel` (Chrome only), `browser.storage` 23 + 24 + ## Code Style 25 + - **TypeScript**: Strict mode enabled, ES2020 target, ESNext modules 26 + - **Imports**: ES6 imports, prefer named imports from `@atcute/oauth-browser-client` and `@atproto/api` 27 + - **Types**: Define interfaces in `lib/types/`, use TypeScript strict type checking 28 + - **Naming**: camelCase for functions/variables, PascalCase for types, SCREAMING_SNAKE_CASE for constants (e.g., `OAUTH_SESSION_KEY`) 29 + - **Error handling**: Use try/catch with console.error for logging, propagate errors upward 30 + - **Browser APIs**: Use `browser.*` namespace (not `chrome.*`) for cross-browser compatibility 31 + - **Environment**: Nix-based development, uses `pnpm` for package management
+493 -246
entrypoints/sidepanel/main.ts
··· 1 1 import './style.css'; 2 2 import type { Annotation } from '@/lib/types/annotation'; 3 + import type { Comment } from '@/lib/types/comment'; 3 4 import { initializeOAuth, startLoginProcess, loadSession, clearSession, getProfile } from '@/lib/oauth'; 4 - import { createAnnotation, listAnnotations } from '@/lib/pds'; 5 + import { createAnnotation, listAnnotations, createComment, listComments } from '@/lib/pds'; 5 6 6 7 console.log('[synthesis] sidepanel script loading...'); 7 8 ··· 10 11 11 12 // Run immediately, not wrapped in defineUnlistedScript 12 13 (function() { 13 - const app = document.getElementById('app'); 14 - if (!app) return; 15 - 16 - let currentUrl = ''; 17 - let currentSelection: { text: string; selectors: any[] } | null = null; 18 - 19 - app.innerHTML = ` 14 + const app = document.getElementById('app'); 15 + if (!app) return; 16 + 17 + let currentUrl = ''; 18 + let currentSelection: { text: string; selectors: any[] } | null = null; 19 + let allComments: Comment[] = []; 20 + const collapsedThreads = new Set<string>(); 21 + const activeReplyForms = new Set<string>(); 22 + 23 + app.innerHTML = ` 20 24 <div class="sidebar"> 21 25 <div class="auth-section" id="auth-section"> 22 26 <div class="login-container"> 23 27 <h2>Login to Synthesis</h2> 24 - <input type="text" id="handle-input" class="handle-input" placeholder="your-handle.bsky.social" /> 28 + <div class="input-wrapper"> 29 + <span class="at-symbol">@</span> 30 + <input type="text" id="handle-input" class="handle-input" placeholder="you.bsky.social" /> 31 + </div> 25 32 <button id="login-btn">Login with ATProto</button> 26 33 <div id="auth-status"></div> 27 34 </div> 28 35 </div> 29 36 <div class="content-section" id="content-section" style="display: none;"> 30 - <div class="annotation-form"> 31 - <h2>Create Annotation</h2> 37 + <div class="annotation-form" id="annotation-form" style="display: none;"> 38 + <div class="form-header"> 39 + <h2>Create Annotation</h2> 40 + <button id="clear-selection-btn" class="clear-btn">×</button> 41 + </div> 32 42 <div id="selected-text" class="selected-text"></div> 33 43 <textarea id="annotation-text" placeholder="Add your note..."></textarea> 34 44 <button id="save-btn">Save Annotation</button> ··· 46 56 </div> 47 57 </div> 48 58 `; 49 - 50 - const selectedTextEl = document.getElementById('selected-text'); 51 - const annotationTextarea = document.getElementById('annotation-text') as HTMLTextAreaElement; 52 - const saveBtn = document.getElementById('save-btn'); 53 - const annotationsContainer = document.getElementById('annotations'); 54 - const handleInput = document.getElementById('handle-input') as HTMLInputElement; 55 - const loginBtn = document.getElementById('login-btn'); 56 - const logoutBtn = document.getElementById('logout-btn'); 57 - const authStatus = document.getElementById('auth-status'); 58 - const profileAvatar = document.getElementById('profile-avatar') as HTMLImageElement; 59 - const profileDropdown = document.getElementById('profile-dropdown'); 60 - const authSection = document.getElementById('auth-section'); 61 - const contentSection = document.getElementById('content-section'); 62 - 63 - // Check for existing session 64 - loadSession().then(async session => { 65 - if (session) { 66 - try { 67 - const profile = await getProfile(session); 68 - if (profileAvatar && profile.avatar) { 69 - profileAvatar.src = profile.avatar; 70 - profileAvatar.style.display = 'block'; 71 - } 72 - if (authSection) authSection.style.display = 'none'; 73 - if (contentSection) contentSection.style.display = 'block'; 74 - } catch (error) { 75 - console.error('Failed to fetch profile:', error); 76 - if (authSection) authSection.style.display = 'none'; 77 - if (contentSection) contentSection.style.display = 'block'; 78 - if (profileAvatar) profileAvatar.style.display = 'block'; 79 - } 80 - } 81 - }); 82 - 83 - // Login handler 84 - loginBtn?.addEventListener('click', async () => { 85 - let handle = handleInput?.value.trim(); 86 - if (!handle) { 87 - alert('Please enter your handle'); 88 - return; 89 - } 90 - 91 - // Strip @ if present 92 - if (handle.startsWith('@')) { 93 - handle = handle.slice(1); 94 - } 95 - 96 - try { 97 - if (authStatus) authStatus.textContent = 'Logging in...'; 98 - await startLoginProcess(handle); 99 - 100 - // Reload to show logged in state 101 - const session = await loadSession(); 102 - if (session) { 103 - try { 104 - const profile = await getProfile(session); 105 - if (profileAvatar && profile.avatar) { 106 - profileAvatar.src = profile.avatar; 107 - profileAvatar.style.display = 'block'; 108 - } 109 - if (authSection) authSection.style.display = 'none'; 110 - if (contentSection) contentSection.style.display = 'block'; 111 - } catch (error) { 112 - console.error('Failed to fetch profile:', error); 113 - if (authSection) authSection.style.display = 'none'; 114 - if (contentSection) contentSection.style.display = 'block'; 115 - } 116 - } 117 - } catch (error) { 118 - if (authStatus) authStatus.textContent = 'Login failed'; 119 - console.error('Login error:', error); 120 - } 121 - }); 122 - 123 - // Profile dropdown toggle 124 - profileAvatar?.addEventListener('click', () => { 125 - if (profileDropdown) { 126 - profileDropdown.style.display = profileDropdown.style.display === 'none' ? 'block' : 'none'; 127 - } 128 - }); 129 - 130 - // Close dropdown when clicking outside 131 - document.addEventListener('click', (e) => { 132 - if (profileDropdown && profileAvatar && 133 - !profileAvatar.contains(e.target as Node) && 134 - !profileDropdown.contains(e.target as Node)) { 135 - profileDropdown.style.display = 'none'; 136 - } 137 - }); 138 - 139 - // Logout handler 140 - logoutBtn?.addEventListener('click', async () => { 141 - await clearSession(); 142 - if (profileAvatar) profileAvatar.style.display = 'none'; 143 - if (profileDropdown) profileDropdown.style.display = 'none'; 144 - if (authSection) authSection.style.display = 'flex'; 145 - if (contentSection) contentSection.style.display = 'none'; 146 - if (authStatus) authStatus.textContent = ''; 147 - }); 148 - 149 - // Listen for selection changes 150 - browser.runtime.onMessage.addListener((message) => { 151 - if (message.type === 'SELECTION_CHANGED') { 152 - currentSelection = message.data; 153 - if (selectedTextEl) { 154 - selectedTextEl.innerHTML = ` 155 - <strong>Selected text:</strong> 156 - <blockquote>${currentSelection.text}</blockquote> 157 - `; 158 - } 159 - console.log('Selection updated:', currentSelection); 160 - } 161 - }); 162 - 163 - // Get current page info 164 - browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 165 - if (tabs[0]?.id) { 166 - browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }).then(response => { 167 - currentUrl = response.url; 168 - currentSelection = response.selection; 169 - 170 - if (currentSelection && selectedTextEl) { 171 - selectedTextEl.innerHTML = ` 172 - <strong>Selected text:</strong> 59 + 60 + const selectedTextEl = document.getElementById('selected-text'); 61 + const annotationTextarea = document.getElementById('annotation-text') as HTMLTextAreaElement; 62 + const saveBtn = document.getElementById('save-btn'); 63 + const annotationsContainer = document.getElementById('annotations'); 64 + const handleInput = document.getElementById('handle-input') as HTMLInputElement; 65 + const loginBtn = document.getElementById('login-btn'); 66 + const logoutBtn = document.getElementById('logout-btn'); 67 + const authStatus = document.getElementById('auth-status'); 68 + const profileAvatar = document.getElementById('profile-avatar') as HTMLImageElement; 69 + const profileDropdown = document.getElementById('profile-dropdown'); 70 + const authSection = document.getElementById('auth-section'); 71 + const contentSection = document.getElementById('content-section'); 72 + const annotationForm = document.getElementById('annotation-form'); 73 + const clearSelectionBtn = document.getElementById('clear-selection-btn'); 74 + 75 + // Check for existing session 76 + loadSession().then(async session => { 77 + if (session) { 78 + try { 79 + const profile = await getProfile(session); 80 + if (profileAvatar && profile.avatar) { 81 + profileAvatar.src = profile.avatar; 82 + profileAvatar.style.display = 'block'; 83 + } 84 + if (authSection) authSection.style.display = 'none'; 85 + if (contentSection) contentSection.style.display = 'block'; 86 + } catch (error) { 87 + console.error('Failed to fetch profile:', error); 88 + if (authSection) authSection.style.display = 'none'; 89 + if (contentSection) contentSection.style.display = 'block'; 90 + if (profileAvatar) profileAvatar.style.display = 'block'; 91 + } 92 + } 93 + }); 94 + 95 + // Login handler 96 + loginBtn?.addEventListener('click', async () => { 97 + let handle = handleInput?.value.trim(); 98 + if (!handle) { 99 + alert('Please enter your handle'); 100 + return; 101 + } 102 + 103 + // Strip @ if present 104 + if (handle.startsWith('@')) { 105 + handle = handle.slice(1); 106 + } 107 + 108 + try { 109 + if (authStatus) authStatus.textContent = 'Logging in...'; 110 + await startLoginProcess(handle); 111 + 112 + // Reload to show logged in state 113 + const session = await loadSession(); 114 + if (session) { 115 + try { 116 + const profile = await getProfile(session); 117 + if (profileAvatar && profile.avatar) { 118 + profileAvatar.src = profile.avatar; 119 + profileAvatar.style.display = 'block'; 120 + } 121 + if (authSection) authSection.style.display = 'none'; 122 + if (contentSection) contentSection.style.display = 'block'; 123 + } catch (error) { 124 + console.error('Failed to fetch profile:', error); 125 + if (authSection) authSection.style.display = 'none'; 126 + if (contentSection) contentSection.style.display = 'block'; 127 + } 128 + } 129 + } catch (error) { 130 + if (authStatus) authStatus.textContent = 'Login failed'; 131 + console.error('Login error:', error); 132 + } 133 + }); 134 + 135 + // Profile dropdown toggle 136 + profileAvatar?.addEventListener('click', () => { 137 + if (profileDropdown) { 138 + profileDropdown.style.display = profileDropdown.style.display === 'none' ? 'block' : 'none'; 139 + } 140 + }); 141 + 142 + // Close dropdown when clicking outside 143 + document.addEventListener('click', (e) => { 144 + if (profileDropdown && profileAvatar && 145 + !profileAvatar.contains(e.target as Node) && 146 + !profileDropdown.contains(e.target as Node)) { 147 + profileDropdown.style.display = 'none'; 148 + } 149 + }); 150 + 151 + // Logout handler 152 + logoutBtn?.addEventListener('click', async () => { 153 + await clearSession(); 154 + if (profileAvatar) profileAvatar.style.display = 'none'; 155 + if (profileDropdown) profileDropdown.style.display = 'none'; 156 + if (authSection) authSection.style.display = 'flex'; 157 + if (contentSection) contentSection.style.display = 'none'; 158 + if (authStatus) authStatus.textContent = ''; 159 + }); 160 + 161 + // Clear selection handler 162 + clearSelectionBtn?.addEventListener('click', () => { 163 + currentSelection = null; 164 + if (selectedTextEl) selectedTextEl.innerHTML = ''; 165 + if (annotationTextarea) annotationTextarea.value = ''; 166 + if (annotationForm) annotationForm.style.display = 'none'; 167 + }); 168 + 169 + // Listen for selection changes 170 + browser.runtime.onMessage.addListener((message) => { 171 + if (message.type === 'SELECTION_CHANGED') { 172 + currentSelection = message.data; 173 + if (currentSelection && currentSelection.text) { 174 + if (selectedTextEl) { 175 + selectedTextEl.innerHTML = `<blockquote>${currentSelection.text}</blockquote>`; 176 + } 177 + if (annotationForm) annotationForm.style.display = 'block'; 178 + } else { 179 + if (annotationForm) annotationForm.style.display = 'none'; 180 + currentSelection = null; 181 + } 182 + console.log('Selection updated:', currentSelection); 183 + } 184 + }); 185 + 186 + // Get current page info 187 + browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 188 + if (tabs[0]?.id) { 189 + browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }).then(response => { 190 + currentUrl = response.url; 191 + currentSelection = response.selection; 192 + 193 + if (currentSelection && currentSelection.text && selectedTextEl) { 194 + selectedTextEl.innerHTML = ` 173 195 <blockquote>${currentSelection.text}</blockquote> 174 196 `; 175 - } 176 - 177 - loadAnnotations(currentUrl); 178 - }).catch(err => { 179 - console.error('Failed to get selection:', err); 180 - }); 181 - } 182 - }); 183 - 184 - // Send highlights to content script when annotations change 185 - async function updateHighlights(annotations: Annotation[]) { 186 - console.log('[synthesis] Sending highlights to content script:', annotations.length); 187 - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 188 - console.log('[synthesis] Active tab:', tabs[0]?.id, tabs[0]?.url); 189 - 190 - if (tabs[0]?.id) { 191 - browser.tabs.sendMessage(tabs[0].id, { 192 - type: 'UPDATE_HIGHLIGHTS', 193 - annotations 194 - }).then(response => { 195 - console.log('[synthesis] Highlights updated successfully:', response); 196 - }).catch(err => { 197 - console.error('[synthesis] Failed to update highlights:', err); 198 - }); 199 - } else { 200 - console.error('[synthesis] No active tab found'); 201 - } 202 - } 203 - 204 - // Save annotation 205 - saveBtn?.addEventListener('click', async () => { 206 - if (!currentSelection) { 207 - alert('Please select text on the page first'); 208 - return; 209 - } 210 - 211 - const body = annotationTextarea.value.trim(); 212 - 213 - const annotation: Annotation = { 214 - $type: 'community.lexicon.annotation', 215 - target: [{ 216 - source: currentUrl, 217 - selector: currentSelection.selectors 218 - }], 219 - body: body || undefined, 220 - createdAt: new Date().toISOString() 221 - }; 222 - 223 - // Store locally for now (will replace with atproto) 224 - await saveAnnotationLocal(annotation); 225 - 226 - // Clear form 227 - annotationTextarea.value = ''; 228 - if (selectedTextEl) selectedTextEl.innerHTML = ''; 229 - currentSelection = null; 230 - 231 - // Reload annotations 232 - await loadAnnotations(currentUrl); 233 - }); 234 - 235 - async function saveAnnotationLocal(annotation: Annotation) { 236 - try { 237 - const savedAnnotation = await createAnnotation(annotation); 238 - console.log('Annotation saved to PDS:', savedAnnotation); 239 - return savedAnnotation; 240 - } catch (error) { 241 - console.error('Failed to save annotation to PDS:', error); 242 - throw error; 243 - } 244 - } 245 - 246 - async function loadAnnotations(url: string) { 247 - try { 248 - const allAnnotations = await listAnnotations(); 249 - 250 - // Filter by current URL 251 - const pageAnnotations = allAnnotations.filter(ann => 252 - ann.target[0]?.source === url 253 - ); 254 - 255 - if (!annotationsContainer) return; 256 - 257 - if (pageAnnotations.length === 0) { 258 - annotationsContainer.innerHTML = '<p class="empty">No annotations yet. Select text to create one.</p>'; 259 - return; 260 - } 261 - 262 - annotationsContainer.innerHTML = pageAnnotations.map(ann => { 263 - const quote = ann.target[0]?.selector?.find((s: any) => s.type === 'TextQuoteSelector'); 264 - const text = quote?.exact || ''; 265 - 266 - return ` 267 - <div class="annotation-card"> 268 - ${text ? `<blockquote>${text}</blockquote>` : ''} 269 - ${ann.body ? `<p>${ann.body}</p>` : ''} 270 - <div class="annotation-meta"> 271 - <small>${new Date(ann.createdAt).toLocaleString()}</small> 272 - </div> 273 - </div> 274 - `; 275 - }).join(''); 276 - 277 - // Update highlights on page 278 - await updateHighlights(pageAnnotations); 279 - } catch (error) { 280 - console.error('Failed to load annotations from PDS:', error); 281 - if (annotationsContainer) { 282 - annotationsContainer.innerHTML = '<p class="error">Failed to load annotations. Please check your connection.</p>'; 283 - } 284 - } 285 - } 197 + if (annotationForm) annotationForm.style.display = 'block'; 198 + } else { 199 + if (annotationForm) annotationForm.style.display = 'none'; 200 + } 201 + 202 + loadAnnotations(currentUrl); 203 + }).catch(err => { 204 + console.error('Failed to get selection:', err); 205 + }); 206 + } 207 + }); 208 + 209 + // Send highlights to content script when annotations change 210 + async function updateHighlights(annotations: Annotation[]) { 211 + console.log('[synthesis] Sending highlights to content script:', annotations.length); 212 + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 213 + console.log('[synthesis] Active tab:', tabs[0]?.id, tabs[0]?.url); 214 + 215 + if (tabs[0]?.id) { 216 + browser.tabs.sendMessage(tabs[0].id, { 217 + type: 'UPDATE_HIGHLIGHTS', 218 + annotations 219 + }).then(response => { 220 + console.log('[synthesis] Highlights updated successfully:', response); 221 + }).catch(err => { 222 + console.error('[synthesis] Failed to update highlights:', err); 223 + }); 224 + } else { 225 + console.error('[synthesis] No active tab found'); 226 + } 227 + } 228 + 229 + // Save annotation 230 + saveBtn?.addEventListener('click', async () => { 231 + if (!currentSelection) { 232 + alert('Please select text on the page first'); 233 + return; 234 + } 235 + 236 + const body = annotationTextarea.value.trim(); 237 + 238 + const annotation: Annotation = { 239 + $type: 'community.lexicon.annotation', 240 + target: [{ 241 + source: currentUrl, 242 + selector: currentSelection.selectors 243 + }], 244 + body: body || undefined, 245 + createdAt: new Date().toISOString() 246 + }; 247 + 248 + // Store locally for now (will replace with atproto) 249 + await saveAnnotationLocal(annotation); 250 + 251 + // Clear form 252 + annotationTextarea.value = ''; 253 + if (selectedTextEl) selectedTextEl.innerHTML = ''; 254 + currentSelection = null; 255 + if (annotationForm) annotationForm.style.display = 'none'; 256 + 257 + // Reload annotations 258 + await loadAnnotations(currentUrl); 259 + }); 260 + 261 + async function saveAnnotationLocal(annotation: Annotation) { 262 + try { 263 + const savedAnnotation = await createAnnotation(annotation); 264 + console.log('Annotation saved to PDS:', savedAnnotation); 265 + return savedAnnotation; 266 + } catch (error) { 267 + console.error('Failed to save annotation to PDS:', error); 268 + throw error; 269 + } 270 + } 271 + 272 + function buildCommentThread(parentUri: string, parentHasReplies: boolean = false): string { 273 + const replies = allComments.filter(c => c.reply?.parent === parentUri); 274 + if (replies.length === 0) return ''; 275 + 276 + const isParentCollapsed = collapsedThreads.has(parentUri); 277 + 278 + if (isParentCollapsed) { 279 + return ` 280 + <div class="comment-thread collapsed"> 281 + <button class="thread-toggle-btn" data-uri="${parentUri}">▸</button> 282 + <div class="collapsed-indicator">${replies.length} hidden ${replies.length === 1 ? 'reply' : 'replies'}</div> 283 + </div> 284 + `; 285 + } 286 + 287 + return ` 288 + <div class="comment-thread"> 289 + ${parentHasReplies ? `<button class="thread-toggle-btn" data-uri="${parentUri}">▾</button>` : ''} 290 + <div class="thread-children ${replies.length === 1 ? 'single-child' : ''}"> 291 + ${replies.map(comment => { 292 + const hasReplies = allComments.some(c => c.reply?.parent === comment.uri); 293 + const isReplyFormActive = activeReplyForms.has(comment.uri!); 294 + 295 + return ` 296 + <div class="comment" data-uri="${comment.uri}"> 297 + <div class="comment-content"> 298 + <div class="comment-text">${comment.plaintext}</div> 299 + <div class="comment-meta"> 300 + <small>${new Date(comment.createdAt).toLocaleString()}</small> 301 + <button class="reply-btn" data-uri="${comment.uri}">Reply</button> 302 + </div> 303 + </div> 304 + ${isReplyFormActive ? ` 305 + <div class="reply-form" data-parent="${comment.uri}"> 306 + <textarea class="reply-input" placeholder="Write a reply..."></textarea> 307 + <div class="reply-actions"> 308 + <button class="save-reply-btn">Post</button> 309 + <button class="cancel-reply-btn">Cancel</button> 310 + </div> 311 + </div> 312 + ` : ''} 313 + ${hasReplies ? buildCommentThread(comment.uri!, true) : ''} 314 + </div> 315 + `; 316 + }).join('')} 317 + </div> 318 + </div> 319 + `; 320 + } 321 + 322 + function renderAnnotationCard(ann: Annotation): string { 323 + const quote = ann.target[0]?.selector?.find((s: any) => s.type === 'TextQuoteSelector'); 324 + const text = quote?.exact || ''; 325 + const comments = allComments.filter(c => c.subject === ann.uri && !c.reply); 326 + const isCommentsCollapsed = collapsedThreads.has(ann.uri!); 327 + const isCommentFormActive = activeReplyForms.has(ann.uri!); 328 + 329 + return ` 330 + <div class="annotation-card" data-uri="${ann.uri}"> 331 + ${text ? `<blockquote>${text}</blockquote>` : ''} 332 + ${ann.body ? `<p>${ann.body}</p>` : ''} 333 + <div class="annotation-meta"> 334 + <small>${new Date(ann.createdAt).toLocaleString()}</small> 335 + </div> 336 + <div class="comments-section"> 337 + <div class="comments-header"> 338 + <button class="toggle-comments-btn" data-uri="${ann.uri}"> 339 + ${isCommentsCollapsed ? '▸' : '▾'} ${comments.length} comment${comments.length !== 1 ? 's' : ''} 340 + </button> 341 + <button class="add-comment-btn" data-uri="${ann.uri}">Add comment</button> 342 + </div> 343 + ${!isCommentsCollapsed ? ` 344 + <div class="comments-list"> 345 + ${isCommentFormActive ? ` 346 + <div class="comment-form" data-subject="${ann.uri}"> 347 + <textarea class="comment-input" placeholder="Write a comment..."></textarea> 348 + <div class="comment-actions"> 349 + <button class="save-comment-btn">Post</button> 350 + <button class="cancel-comment-btn">Cancel</button> 351 + </div> 352 + </div> 353 + ` : ''} 354 + ${comments.map(comment => { 355 + const hasReplies = allComments.some(c => c.reply?.parent === comment.uri); 356 + 357 + return ` 358 + <div class="comment" data-uri="${comment.uri}"> 359 + <div class="comment-content"> 360 + <div class="comment-text">${comment.plaintext}</div> 361 + <div class="comment-meta"> 362 + <small>${new Date(comment.createdAt).toLocaleString()}</small> 363 + <button class="reply-btn" data-uri="${comment.uri}">Reply</button> 364 + </div> 365 + </div> 366 + ${activeReplyForms.has(comment.uri!) ? ` 367 + <div class="reply-form" data-parent="${comment.uri}"> 368 + <textarea class="reply-input" placeholder="Write a reply..."></textarea> 369 + <div class="reply-actions"> 370 + <button class="save-reply-btn">Post</button> 371 + <button class="cancel-reply-btn">Cancel</button> 372 + </div> 373 + </div> 374 + ` : ''} 375 + ${hasReplies ? buildCommentThread(comment.uri!, true) : ''} 376 + </div> 377 + `;}).join('')} 378 + </div> 379 + ` : ''} 380 + </div> 381 + </div> 382 + `; 383 + } 384 + 385 + let pageAnnotations: Annotation[] = []; 386 + 387 + function renderAnnotations() { 388 + if (!annotationsContainer) return; 389 + 390 + if (pageAnnotations.length === 0) { 391 + annotationsContainer.innerHTML = '<p class="empty">No annotations yet. Select text to create one.</p>'; 392 + return; 393 + } 394 + 395 + annotationsContainer.innerHTML = pageAnnotations.map(renderAnnotationCard).join(''); 396 + attachCommentEventListeners(); 397 + } 398 + 399 + async function loadAnnotations(url: string) { 400 + try { 401 + const allAnnotations = await listAnnotations(); 402 + allComments = await listComments(); 403 + 404 + // Filter by current URL 405 + pageAnnotations = allAnnotations.filter(ann => 406 + ann.target[0]?.source === url 407 + ); 408 + 409 + renderAnnotations(); 410 + 411 + // Update highlights on page 412 + await updateHighlights(pageAnnotations); 413 + } catch (error) { 414 + console.error('Failed to load annotations from PDS:', error); 415 + if (annotationsContainer) { 416 + annotationsContainer.innerHTML = '<p class="error">Failed to load annotations. Please check your connection.</p>'; 417 + } 418 + } 419 + } 420 + 421 + function attachCommentEventListeners() { 422 + // Toggle comments visibility 423 + document.querySelectorAll('.toggle-comments-btn').forEach(btn => { 424 + btn.addEventListener('click', (e) => { 425 + const uri = (e.target as HTMLElement).dataset.uri!; 426 + if (collapsedThreads.has(uri)) { 427 + collapsedThreads.delete(uri); 428 + } else { 429 + collapsedThreads.add(uri); 430 + } 431 + renderAnnotations(); 432 + }); 433 + }); 434 + 435 + // Show comment form 436 + document.querySelectorAll('.add-comment-btn').forEach(btn => { 437 + btn.addEventListener('click', (e) => { 438 + const uri = (e.target as HTMLElement).dataset.uri!; 439 + activeReplyForms.add(uri); 440 + renderAnnotations(); 441 + }); 442 + }); 443 + 444 + // Show reply form 445 + document.querySelectorAll('.reply-btn').forEach(btn => { 446 + btn.addEventListener('click', (e) => { 447 + const uri = (e.target as HTMLElement).dataset.uri!; 448 + activeReplyForms.add(uri); 449 + renderAnnotations(); 450 + }); 451 + }); 452 + 453 + // Cancel comment/reply 454 + document.querySelectorAll('.cancel-comment-btn, .cancel-reply-btn').forEach(btn => { 455 + btn.addEventListener('click', (e) => { 456 + const form = (e.target as HTMLElement).closest('.comment-form, .reply-form')!; 457 + const uri = form.getAttribute('data-subject') || form.getAttribute('data-parent')!; 458 + activeReplyForms.delete(uri); 459 + renderAnnotations(); 460 + }); 461 + }); 462 + 463 + // Save comment 464 + document.querySelectorAll('.save-comment-btn').forEach(btn => { 465 + btn.addEventListener('click', async (e) => { 466 + const form = (e.target as HTMLElement).closest('.comment-form')!; 467 + const textarea = form.querySelector('.comment-input') as HTMLTextAreaElement; 468 + const subject = form.getAttribute('data-subject')!; 469 + const plaintext = textarea.value.trim(); 470 + 471 + if (!plaintext) return; 472 + 473 + try { 474 + await createComment({ 475 + $type: 'pub.leaflet.comment', 476 + subject, 477 + plaintext, 478 + createdAt: new Date().toISOString(), 479 + }); 480 + activeReplyForms.delete(subject); 481 + await loadAnnotations(currentUrl); 482 + } catch (error) { 483 + console.error('Failed to create comment:', error); 484 + alert('Failed to post comment'); 485 + } 486 + }); 487 + }); 488 + 489 + // Save reply 490 + document.querySelectorAll('.save-reply-btn').forEach(btn => { 491 + btn.addEventListener('click', async (e) => { 492 + const form = (e.target as HTMLElement).closest('.reply-form')!; 493 + const textarea = form.querySelector('.reply-input') as HTMLTextAreaElement; 494 + const parent = form.getAttribute('data-parent')!; 495 + const plaintext = textarea.value.trim(); 496 + 497 + if (!plaintext) return; 498 + 499 + // Find the parent comment to get its subject 500 + const parentComment = allComments.find(c => c.uri === parent); 501 + if (!parentComment) return; 502 + 503 + try { 504 + await createComment({ 505 + $type: 'pub.leaflet.comment', 506 + subject: parentComment.subject, 507 + plaintext, 508 + createdAt: new Date().toISOString(), 509 + reply: { parent }, 510 + }); 511 + activeReplyForms.delete(parent); 512 + await loadAnnotations(currentUrl); 513 + } catch (error) { 514 + console.error('Failed to create reply:', error); 515 + alert('Failed to post reply'); 516 + } 517 + }); 518 + }); 519 + 520 + // Collapse thread 521 + document.querySelectorAll('.thread-toggle-btn').forEach(btn => { 522 + btn.addEventListener('click', (e) => { 523 + const uri = (e.target as HTMLElement).dataset.uri!; 524 + if (collapsedThreads.has(uri)) { 525 + collapsedThreads.delete(uri); 526 + } else { 527 + collapsedThreads.add(uri); 528 + } 529 + renderAnnotations(); 530 + }); 531 + }); 532 + } 286 533 })();
+291 -30
entrypoints/sidepanel/style.css
··· 6 6 7 7 body { 8 8 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 9 - background: #fff; 9 + background: #fafafa; 10 10 color: #1a1a1a; 11 + position: relative; 12 + } 13 + 14 + body::before { 15 + content: ''; 16 + position: fixed; 17 + inset: 0; 18 + pointer-events: none; 19 + background-image: 20 + radial-gradient(circle, rgba(0, 0, 0, 0.08) 1px, transparent 1px); 21 + background-size: 20px 20px; 22 + z-index: 0; 11 23 } 12 24 13 25 .sidebar { 14 26 padding: 16px; 15 27 max-width: 100%; 28 + position: relative; 29 + z-index: 1; 16 30 } 17 31 18 32 h2 { ··· 24 38 button { 25 39 background: #0085ff; 26 40 color: white; 27 - border: none; 41 + border: 1px dashed #0070dd; 28 42 padding: 8px 16px; 29 - border-radius: 6px; 43 + border-radius: 2px; 30 44 cursor: pointer; 31 45 font-size: 14px; 32 46 } ··· 38 52 .annotation-form { 39 53 margin: 16px 0; 40 54 padding: 16px; 41 - border: 1px solid #e0e0e0; 42 - border-radius: 8px; 55 + border: 1px dashed #d0d0d0; 56 + border-radius: 2px; 57 + background: #fff; 58 + } 59 + 60 + .form-header { 61 + display: flex; 62 + justify-content: space-between; 63 + align-items: center; 64 + margin-bottom: 12px; 65 + } 66 + 67 + .form-header h2 { 68 + margin-bottom: 0; 69 + } 70 + 71 + .clear-btn { 72 + background: transparent; 73 + border: none; 74 + color: #999; 75 + font-size: 24px; 76 + padding: 0; 77 + width: 24px; 78 + height: 24px; 79 + cursor: pointer; 80 + line-height: 1; 81 + display: flex; 82 + align-items: center; 83 + justify-content: center; 84 + } 85 + 86 + .clear-btn:hover { 87 + color: #666; 88 + background: transparent; 43 89 } 44 90 45 91 textarea { 46 92 width: 100%; 47 93 min-height: 80px; 48 94 padding: 8px; 49 - border: 1px solid #e0e0e0; 50 - border-radius: 4px; 95 + border: 1px solid #d0d0d0; 96 + border-radius: 2px; 51 97 font-family: inherit; 52 98 margin-bottom: 8px; 99 + background: #fafafa; 53 100 } 54 101 55 102 .annotations-list { 56 - margin-top: 24px; 103 + margin-top: 12px; 57 104 } 58 105 59 106 #annotations { ··· 64 111 65 112 .annotation-card { 66 113 padding: 12px; 67 - border: 1px solid #e0e0e0; 68 - border-radius: 8px; 69 - background: #fafafa; 114 + border: 1px dashed #d0d0d0; 115 + border-radius: 2px; 116 + background: #fff; 70 117 } 71 118 72 119 .annotation-card blockquote { 73 120 margin: 0 0 8px 0; 74 121 padding: 8px 12px; 75 - background: white; 122 + background: #fafafa; 76 123 border-left: 3px solid #0085ff; 77 124 font-style: italic; 78 125 color: #555; ··· 86 133 .annotation-meta { 87 134 margin-top: 8px; 88 135 padding-top: 8px; 89 - border-top: 1px solid #e0e0e0; 136 + border-top: 1px dashed #e0e0e0; 90 137 } 91 138 92 139 .annotation-meta small { ··· 96 143 97 144 .selected-text { 98 145 margin-bottom: 12px; 99 - padding: 12px; 100 - background: #f0f8ff; 101 - border-radius: 6px; 146 + padding: 0; 147 + background: transparent; 148 + border-radius: 2px; 102 149 } 103 150 104 151 .selected-text blockquote { 105 - margin: 8px 0 0 0; 152 + margin: 0; 106 153 padding: 8px 12px; 107 - background: white; 154 + background: #f5f5f5; 108 155 border-left: 3px solid #0085ff; 109 156 font-style: italic; 110 157 color: #333; ··· 138 185 font-size: 20px; 139 186 } 140 187 141 - .handle-input { 188 + .input-wrapper { 189 + position: relative; 142 190 width: 100%; 143 - padding: 12px; 144 - border: 1px solid #e0e0e0; 145 - border-radius: 6px; 146 - font-size: 14px; 147 - font-family: inherit; 191 + display: flex; 192 + align-items: center; 193 + background: #f5f5f5; 194 + border: 1px dashed #d0d0d0; 195 + border-radius: 2px; 196 + padding: 12px 16px; 148 197 } 149 198 150 - .handle-input:focus { 199 + .at-symbol { 200 + color: #999; 201 + font-size: 16px; 202 + margin-right: 4px; 203 + user-select: none; 204 + } 205 + 206 + .handle-input { 207 + flex: 1; 208 + border: none; 209 + background: transparent; 210 + font-size: 16px; 211 + font-family: inherit; 151 212 outline: none; 152 - border-color: #0085ff; 153 - box-shadow: 0 0 0 3px rgba(0, 133, 255, 0.1); 213 + color: #333; 214 + } 215 + 216 + .handle-input::placeholder { 217 + color: #bbb; 154 218 } 155 219 156 220 #auth-status { ··· 171 235 bottom: 48px; 172 236 right: 0; 173 237 background: white; 174 - border: 1px solid #e0e0e0; 175 - border-radius: 8px; 176 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 238 + border: 1px dashed #d0d0d0; 239 + border-radius: 2px; 240 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 177 241 padding: 8px; 178 242 min-width: 120px; 179 243 } ··· 182 246 width: 100%; 183 247 text-align: left; 184 248 } 249 + 250 + /* Comments */ 251 + .comments-section { 252 + margin-top: 12px; 253 + padding-top: 12px; 254 + border-top: 1px dashed #e0e0e0; 255 + } 256 + 257 + .comments-header { 258 + display: flex; 259 + gap: 8px; 260 + margin-bottom: 8px; 261 + align-items: center; 262 + } 263 + 264 + .toggle-comments-btn, 265 + .add-comment-btn { 266 + background: transparent; 267 + border: 1px solid #d0d0d0; 268 + color: #666; 269 + font-size: 12px; 270 + padding: 4px 8px; 271 + } 272 + 273 + .toggle-comments-btn:hover, 274 + .add-comment-btn:hover { 275 + background: #f5f5f5; 276 + color: #333; 277 + } 278 + 279 + .comments-list { 280 + display: flex; 281 + flex-direction: column; 282 + gap: 8px; 283 + margin-top: 8px; 284 + } 285 + 286 + .comment { 287 + padding: 8px; 288 + background: #fafafa; 289 + border: 1px solid #e0e0e0; 290 + border-radius: 2px; 291 + flex: 1; 292 + } 293 + 294 + .comment-thread { 295 + margin-top: 8px; 296 + margin-left: 0; 297 + padding-left: 12px; 298 + position: relative; 299 + } 300 + 301 + .thread-toggle-btn { 302 + position: absolute; 303 + left: -12px; 304 + top: 0; 305 + background: transparent; 306 + border: none; 307 + color: #666; 308 + font-size: 14px; 309 + width: 4px; 310 + height: 4px; 311 + display: flex; 312 + align-items: center; 313 + justify-content: center; 314 + cursor: pointer; 315 + } 316 + 317 + .thread-toggle-btn:hover { 318 + background: transparent; 319 + border-radius: 2px; 320 + } 321 + 322 + .thread-children { 323 + padding-left: 2px; 324 + position: relative; 325 + margin-top: 2px; 326 + } 327 + 328 + .thread-children > .comment { 329 + margin-bottom: 8px; 330 + } 331 + 332 + .thread-children > .comment:last-child { 333 + margin-bottom: 0; 334 + } 335 + 336 + .comment-thread.collapsed { 337 + padding-left: 16px; 338 + margin-top: 8px; 339 + margin-left: 0; 340 + display: flex; 341 + align-items: center; 342 + gap: 4px; 343 + } 344 + 345 + .comment-thread.collapsed .thread-toggle-btn { 346 + position: absolute; 347 + left: -12px; 348 + } 349 + 350 + .collapsed-indicator { 351 + font-size: 12px; 352 + color: #999; 353 + font-style: italic; 354 + } 355 + 356 + .comment-content { 357 + display: flex; 358 + flex-direction: column; 359 + gap: 4px; 360 + } 361 + 362 + .comment-text { 363 + font-size: 14px; 364 + line-height: 1.4; 365 + color: #333; 366 + } 367 + 368 + .comment-meta { 369 + display: flex; 370 + gap: 8px; 371 + align-items: center; 372 + } 373 + 374 + .comment-meta small { 375 + color: #999; 376 + font-size: 11px; 377 + } 378 + 379 + .reply-btn { 380 + background: transparent; 381 + border: none; 382 + color: #0085ff; 383 + font-size: 11px; 384 + padding: 2px 4px; 385 + cursor: pointer; 386 + } 387 + 388 + .reply-btn:hover { 389 + background: transparent; 390 + text-decoration: underline; 391 + } 392 + 393 + .comment-form, 394 + .reply-form { 395 + margin-top: 8px; 396 + padding: 8px; 397 + background: #fff; 398 + border: 1px dashed #d0d0d0; 399 + border-radius: 2px; 400 + } 401 + 402 + .comment-input, 403 + .reply-input { 404 + width: 100%; 405 + min-height: 60px; 406 + padding: 6px; 407 + border: 1px solid #d0d0d0; 408 + border-radius: 2px; 409 + font-family: inherit; 410 + font-size: 13px; 411 + margin-bottom: 6px; 412 + background: #fafafa; 413 + } 414 + 415 + .comment-actions, 416 + .reply-actions { 417 + display: flex; 418 + gap: 6px; 419 + justify-content: flex-end; 420 + } 421 + 422 + .save-comment-btn, 423 + .save-reply-btn, 424 + .cancel-comment-btn, 425 + .cancel-reply-btn { 426 + font-size: 12px; 427 + padding: 4px 12px; 428 + } 429 + 430 + .cancel-comment-btn, 431 + .cancel-reply-btn { 432 + background: transparent; 433 + border: 1px dashed #d0d0d0; 434 + color: #666; 435 + } 436 + 437 + .cancel-comment-btn:hover, 438 + .cancel-reply-btn:hover { 439 + background: #f5f5f5; 440 + color: #333; 441 + } 442 + 443 + .comment-thread { 444 + margin-top: 8px; 445 + }
+220
lexicon/community/lexicon/annotation/annotation.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "community.lexicon.annotation.annotation", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A W3C-compliant web annotation. Core entity for highlighting, commenting on, and replying to web resources.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["target", "createdAt"], 12 + "properties": { 13 + "target": { 14 + "type": "array", 15 + "items": { 16 + "type": "ref", 17 + "ref": "#target" 18 + }, 19 + "minLength": 1, 20 + "maxLength": 5, 21 + "description": "The resource(s) being annotated (W3C: target property)" 22 + }, 23 + "body": { 24 + "type": "string", 25 + "maxLength": 10000, 26 + "maxGraphemes": 5000, 27 + "description": "The annotation text content (comment, note, etc.)" 28 + }, 29 + "tags": { 30 + "type": "array", 31 + "items": { 32 + "type": "string", 33 + "maxLength": 100 34 + }, 35 + "maxLength": 50, 36 + "description": "Tags for categorization" 37 + }, 38 + "document": { 39 + "type": "ref", 40 + "ref": "#documentMetadata", 41 + "description": "Metadata about the document being annotated" 42 + }, 43 + "createdAt": { 44 + "type": "string", 45 + "format": "datetime" 46 + } 47 + } 48 + } 49 + }, 50 + "target": { 51 + "type": "object", 52 + "description": "W3C SpecificResource: what is being annotated", 53 + "required": ["source"], 54 + "properties": { 55 + "source": { 56 + "type": "string", 57 + "format": "uri", 58 + "maxLength": 8000, 59 + "description": "URI of the resource being annotated" 60 + }, 61 + "selector": { 62 + "type": "array", 63 + "items": { 64 + "type": "union", 65 + "refs": [ 66 + "#textQuoteSelector", 67 + "#textPositionSelector", 68 + "#rangeSelector", 69 + "#fragmentSelector", 70 + "#cssSelector", 71 + "#xpathSelector" 72 + ] 73 + }, 74 + "maxLength": 5, 75 + "description": "How to locate the specific segment (multiple for fallback)" 76 + } 77 + } 78 + }, 79 + "textQuoteSelector": { 80 + "type": "object", 81 + "description": "W3C TextQuoteSelector: identifies text by quoting with context", 82 + "required": ["type", "exact"], 83 + "properties": { 84 + "type": { 85 + "type": "string", 86 + "const": "TextQuoteSelector" 87 + }, 88 + "exact": { 89 + "type": "string", 90 + "maxLength": 5000, 91 + "description": "The exact text being selected" 92 + }, 93 + "prefix": { 94 + "type": "string", 95 + "maxLength": 500, 96 + "description": "Text immediately before for disambiguation" 97 + }, 98 + "suffix": { 99 + "type": "string", 100 + "maxLength": 500, 101 + "description": "Text immediately after for disambiguation" 102 + } 103 + } 104 + }, 105 + "textPositionSelector": { 106 + "type": "object", 107 + "description": "W3C TextPositionSelector: character position offsets", 108 + "required": ["type", "start", "end"], 109 + "properties": { 110 + "type": { 111 + "type": "string", 112 + "const": "TextPositionSelector" 113 + }, 114 + "start": { 115 + "type": "integer", 116 + "minimum": 0, 117 + "description": "Starting character position (inclusive)" 118 + }, 119 + "end": { 120 + "type": "integer", 121 + "minimum": 0, 122 + "description": "Ending character position (exclusive)" 123 + } 124 + } 125 + }, 126 + "rangeSelector": { 127 + "type": "object", 128 + "description": "W3C RangeSelector: defines a range using start/end selectors", 129 + "required": ["type", "startSelector", "endSelector"], 130 + "properties": { 131 + "type": { 132 + "type": "string", 133 + "const": "RangeSelector" 134 + }, 135 + "startSelector": { 136 + "type": "union", 137 + "refs": ["#xpathSelector", "#cssSelector", "#textPositionSelector"] 138 + }, 139 + "endSelector": { 140 + "type": "union", 141 + "refs": ["#xpathSelector", "#cssSelector", "#textPositionSelector"] 142 + } 143 + } 144 + }, 145 + "fragmentSelector": { 146 + "type": "object", 147 + "description": "W3C FragmentSelector: uses URI fragment identifiers", 148 + "required": ["type", "value"], 149 + "properties": { 150 + "type": { 151 + "type": "string", 152 + "const": "FragmentSelector" 153 + }, 154 + "value": { 155 + "type": "string", 156 + "maxLength": 2000, 157 + "description": "Fragment identifier (e.g., 't=30,60', 'section-2')" 158 + }, 159 + "conformsTo": { 160 + "type": "string", 161 + "format": "uri", 162 + "description": "IRI of the fragment syntax specification" 163 + } 164 + } 165 + }, 166 + "cssSelector": { 167 + "type": "object", 168 + "description": "W3C CssSelector: uses CSS selectors for DOM elements", 169 + "required": ["type", "value"], 170 + "properties": { 171 + "type": { 172 + "type": "string", 173 + "const": "CssSelector" 174 + }, 175 + "value": { 176 + "type": "string", 177 + "maxLength": 2000, 178 + "description": "CSS selector string" 179 + } 180 + } 181 + }, 182 + "xpathSelector": { 183 + "type": "object", 184 + "description": "W3C XPathSelector: uses XPath for XML/HTML elements", 185 + "required": ["type", "value"], 186 + "properties": { 187 + "type": { 188 + "type": "string", 189 + "const": "XPathSelector" 190 + }, 191 + "value": { 192 + "type": "string", 193 + "maxLength": 2000, 194 + "description": "XPath expression" 195 + } 196 + } 197 + }, 198 + "documentMetadata": { 199 + "type": "object", 200 + "description": "Metadata about the document being annotated", 201 + "properties": { 202 + "title": { 203 + "type": "string", 204 + "maxLength": 1000, 205 + "description": "Document title" 206 + }, 207 + "doi": { 208 + "type": "string", 209 + "maxLength": 500, 210 + "description": "Digital Object Identifier" 211 + }, 212 + "canonicalUri": { 213 + "type": "string", 214 + "format": "uri", 215 + "description": "Canonical URI for this document" 216 + } 217 + } 218 + } 219 + } 220 + }
+105 -4
lib/pds.ts
··· 1 1 import { OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 2 import { loadSession } from "./oauth"; 3 3 import type { Annotation } from "./types/annotation"; 4 + import type { Comment } from "./types/comment"; 4 5 5 - const COLLECTION = "community.lexicon.annotation"; 6 + const ANNOTATION_COLLECTION = "community.lexicon.annotation"; 7 + const COMMENT_COLLECTION = "pub.leaflet.comment"; 6 8 7 9 export async function createAnnotation(annotation: Annotation): Promise<Annotation> { 8 10 const session = await loadSession(); ··· 28 30 }, 29 31 body: JSON.stringify({ 30 32 repo: session.info.sub, 31 - collection: COLLECTION, 33 + collection: ANNOTATION_COLLECTION, 32 34 record, 33 35 }), 34 36 }); ··· 57 59 const agent = new OAuthUserAgent(session); 58 60 59 61 const response = await agent.handle( 60 - `/xrpc/com.atproto.repo.listRecords?repo=${session.info.sub}&collection=${COLLECTION}`, 62 + `/xrpc/com.atproto.repo.listRecords?repo=${session.info.sub}&collection=${ANNOTATION_COLLECTION}`, 61 63 { method: 'GET' } 62 64 ); 63 65 ··· 91 93 }, 92 94 body: JSON.stringify({ 93 95 repo: session.info.sub, 94 - collection: COLLECTION, 96 + collection: ANNOTATION_COLLECTION, 95 97 rkey, 96 98 }), 97 99 }); ··· 102 104 throw new Error(`Failed to delete annotation: ${response.status} - ${JSON.stringify(error)}`); 103 105 } 104 106 } 107 + 108 + export async function createComment(comment: Omit<Comment, 'uri' | 'cid' | 'author'>): Promise<Comment> { 109 + const session = await loadSession(); 110 + if (!session) { 111 + throw new Error("Not authenticated"); 112 + } 113 + 114 + const agent = new OAuthUserAgent(session); 115 + 116 + const record = { 117 + $type: 'pub.leaflet.comment', 118 + subject: comment.subject, 119 + plaintext: comment.plaintext, 120 + createdAt: comment.createdAt, 121 + reply: comment.reply, 122 + facets: comment.facets, 123 + onPage: comment.onPage, 124 + }; 125 + 126 + const response = await agent.handle('/xrpc/com.atproto.repo.createRecord', { 127 + method: 'POST', 128 + headers: { 129 + 'Content-Type': 'application/json', 130 + }, 131 + body: JSON.stringify({ 132 + repo: session.info.sub, 133 + collection: COMMENT_COLLECTION, 134 + record, 135 + }), 136 + }); 137 + 138 + if (!response.ok) { 139 + const error = await response.json(); 140 + console.error('[pds] Create comment error:', error); 141 + throw new Error(`Failed to create comment: ${response.status} - ${JSON.stringify(error)}`); 142 + } 143 + 144 + const result = await response.json(); 145 + 146 + return { 147 + ...comment, 148 + uri: result.uri, 149 + cid: result.cid, 150 + }; 151 + } 152 + 153 + export async function listComments(): Promise<Comment[]> { 154 + const session = await loadSession(); 155 + if (!session) { 156 + throw new Error("Not authenticated"); 157 + } 158 + 159 + const agent = new OAuthUserAgent(session); 160 + 161 + const response = await agent.handle( 162 + `/xrpc/com.atproto.repo.listRecords?repo=${session.info.sub}&collection=${COMMENT_COLLECTION}`, 163 + { method: 'GET' } 164 + ); 165 + 166 + const result = await response.json(); 167 + 168 + return result.records.map((record: any) => ({ 169 + ...record.value, 170 + uri: record.uri, 171 + cid: record.cid, 172 + })); 173 + } 174 + 175 + export async function deleteComment(uri: string): Promise<void> { 176 + const session = await loadSession(); 177 + if (!session) { 178 + throw new Error("Not authenticated"); 179 + } 180 + 181 + const agent = new OAuthUserAgent(session); 182 + 183 + const rkey = uri.split("/").pop(); 184 + if (!rkey) { 185 + throw new Error("Invalid URI"); 186 + } 187 + 188 + const response = await agent.handle('/xrpc/com.atproto.repo.deleteRecord', { 189 + method: 'POST', 190 + headers: { 191 + 'Content-Type': 'application/json', 192 + }, 193 + body: JSON.stringify({ 194 + repo: session.info.sub, 195 + collection: COMMENT_COLLECTION, 196 + rkey, 197 + }), 198 + }); 199 + 200 + if (!response.ok) { 201 + const error = await response.json(); 202 + console.error('[pds] Delete comment error:', error); 203 + throw new Error(`Failed to delete comment: ${response.status} - ${JSON.stringify(error)}`); 204 + } 205 + }
+68
lib/types/comment.ts
··· 1 + export interface Comment { 2 + $type: 'pub.leaflet.comment'; 3 + subject: string; 4 + plaintext: string; 5 + createdAt: string; 6 + reply?: ReplyRef; 7 + facets?: RichtextFacet[]; 8 + onPage?: string; 9 + 10 + uri?: string; 11 + cid?: string; 12 + author?: { 13 + did: string; 14 + handle: string; 15 + displayName?: string; 16 + avatar?: string; 17 + }; 18 + } 19 + 20 + export interface ReplyRef { 21 + parent: string; 22 + } 23 + 24 + export interface RichtextFacet { 25 + index: { 26 + byteStart: number; 27 + byteEnd: number; 28 + }; 29 + features: FacetFeature[]; 30 + } 31 + 32 + export type FacetFeature = LinkFeature | MentionFeature | TagFeature; 33 + 34 + export interface LinkFeature { 35 + $type: 'app.bsky.richtext.facet#link'; 36 + uri: string; 37 + } 38 + 39 + export interface MentionFeature { 40 + $type: 'app.bsky.richtext.facet#mention'; 41 + did: string; 42 + } 43 + 44 + export interface TagFeature { 45 + $type: 'app.bsky.richtext.facet#tag'; 46 + tag: string; 47 + } 48 + 49 + export function isComment(record: unknown): record is Comment { 50 + return ( 51 + typeof record === 'object' && 52 + record !== null && 53 + '$type' in record && 54 + record.$type === 'pub.leaflet.comment' 55 + ); 56 + } 57 + 58 + export function getCommentThread(comments: Comment[]): Map<string, Comment[]> { 59 + const threads = new Map<string, Comment[]>(); 60 + 61 + for (const comment of comments) { 62 + const parent = comment.reply?.parent || comment.subject; 63 + const existing = threads.get(parent) || []; 64 + threads.set(parent, [...existing, comment]); 65 + } 66 + 67 + return threads; 68 + }