Social Annotations in the Atmosphere
15
fork

Configure Feed

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

remove old plans

-2245
-1733
ARCHITECTURE.md
··· 1 - # Synthesis Architecture Documentation 2 - 3 - ## Table of Contents 4 - 1. [Overview](#overview) 5 - 2. [System Architecture](#system-architecture) 6 - 3. [Component Deep Dive](#component-deep-dive) 7 - 4. [Data Flow](#data-flow) 8 - 5. [Message Passing Protocol](#message-passing-protocol) 9 - 6. [Authentication System](#authentication-system) 10 - 7. [Annotation Lifecycle](#annotation-lifecycle) 11 - 8. [Highlight System](#highlight-system) 12 - 9. [Selector System](#selector-system) 13 - 10. [Comment/Reply System](#comment-reply-system) 14 - 11. [Landing Page](#landing-page) 15 - 12. [Storage Architecture](#storage-architecture) 16 - 13. [Known Issues](#known-issues) 17 - 18 - --- 19 - 20 - ## Overview 21 - 22 - **Synthesis** is a browser extension for creating web annotations using the AT Protocol (ATProto/Bluesky infrastructure). It's a Hypothesis clone that stores annotations as ATProto records on personal data servers (PDS). 23 - 24 - ### Tech Stack 25 - - **Framework**: WXT (Web Extension Framework) 26 - - **Language**: TypeScript 27 - - **Authentication**: AT Protocol OAuth (`@atcute/oauth-browser-client`) 28 - - **API Client**: `@atproto/api` 29 - - **Selector Libraries**: `dom-anchor-text-quote`, `dom-anchor-text-position` 30 - - **Build Tool**: Vite (via WXT) 31 - 32 - ### Project Structure 33 - ``` 34 - seams.so/ 35 - ├── entrypoints/ # Extension entry points 36 - │ ├── background.ts # Service worker (context menus, auth events) 37 - │ ├── content.ts # Content script (page interaction) 38 - │ └── sidepanel/ 39 - │ └── main.ts # Sidepanel UI (annotation management) 40 - ├── lib/ # Core libraries 41 - │ ├── oauth.ts # AT Protocol OAuth flow 42 - │ ├── pds.ts # PDS API integration (CRUD operations) 43 - │ ├── types/ # TypeScript type definitions 44 - │ │ ├── annotation.ts # Annotation data model 45 - │ │ └── comment.ts # Comment data model 46 - │ ├── selectors/ # Selector generation/matching 47 - │ │ ├── generate.ts # Create selectors from selections 48 - │ │ └── match.ts # Find DOM ranges from selectors 49 - │ └── highlights/ # Visual highlight rendering 50 - │ ├── apply.ts # Apply highlights to page 51 - │ └── popover.ts # Annotation edit popover 52 - ├── public/ # Landing page (static site) 53 - │ ├── landing.js # Feed viewer (slices.network integration) 54 - │ └── landing.css # Landing page styles 55 - └── lexicon/ # AT Protocol lexicon definitions 56 - ``` 57 - 58 - --- 59 - 60 - ## System Architecture 61 - 62 - ### High-Level Components 63 - 64 - ``` 65 - ┌─────────────────────────────────────────────────────────────┐ 66 - │ Web Page │ 67 - │ ┌────────────────────────────────────────────────────┐ │ 68 - │ │ Content Script (content.ts) │ │ 69 - │ │ - Track text selections │ │ 70 - │ │ - Generate selectors │ │ 71 - │ │ - Apply highlights to DOM │ │ 72 - │ │ - Show annotation popovers │ │ 73 - │ └─────┬────────────────────────────────────────▲─────┘ │ 74 - │ │ messages messages │ │ 75 - └────────┼────────────────────────────────────────┼──────────┘ 76 - │ │ 77 - ▼ │ 78 - ┌─────────────────────────────────────────────────┼──────────┐ 79 - │ Background Script (background.ts) │ │ 80 - │ - Context menu registration │ │ 81 - │ - Extension icon click handler │ │ 82 - │ - Auth state change broadcast │ │ 83 - │ - (Currently minimal state management) │ │ 84 - └────────┬────────────────────────────────────────┘ │ 85 - │ │ 86 - ▼ │ 87 - ┌───────────────────────────────────────────────────┐ │ 88 - │ Sidepanel (sidepanel/main.ts) │ │ 89 - │ - Login UI (AT Protocol OAuth) │────────┘ 90 - │ - Annotation form (create/edit) │ 91 - │ - Annotation list (with comments/replies) │ 92 - │ - Profile menu │ 93 - └────────┬──────────────────────────────────────────┘ 94 - │ XRPC calls 95 - 96 - ┌────────────────────────────────────────────────────────────┐ 97 - │ AT Protocol / Bluesky Infrastructure │ 98 - │ - Personal Data Server (PDS) │ 99 - │ - OAuth authorization │ 100 - │ - Record storage (annotations, comments) │ 101 - └────────────────────────────────────────────────────────────┘ 102 - 103 - 104 - ┌────────────────────────────────────────────────────────────┐ 105 - │ slices.network (GraphQL API) │ 106 - │ - Public annotation feed aggregation │ 107 - │ - Used by landing page (landing.js) │ 108 - └────────────────────────────────────────────────────────────┘ 109 - ``` 110 - 111 - ### Component Relationships 112 - 113 - ```mermaid 114 - graph TB 115 - User[User] 116 - WebPage[Web Page] 117 - Content[Content Script] 118 - Background[Background Script] 119 - Sidepanel[Sidepanel UI] 120 - OAuth[OAuth Module] 121 - PDS[PDS Module] 122 - Selectors[Selector System] 123 - Highlights[Highlight System] 124 - ATProto[AT Protocol PDS] 125 - SlicesNet[slices.network] 126 - Landing[Landing Page] 127 - 128 - User -->|selects text| WebPage 129 - WebPage -->|mouseup event| Content 130 - Content -->|generates| Selectors 131 - Content -->|sends SELECTION_CHANGED| Sidepanel 132 - Content -->|renders| Highlights 133 - 134 - User -->|clicks icon| Background 135 - Background -->|opens| Sidepanel 136 - Background -->|registers| ContextMenu[Context Menu] 137 - 138 - Sidepanel -->|requests GET_SELECTION| Content 139 - Sidepanel -->|uses| OAuth 140 - Sidepanel -->|uses| PDS 141 - 142 - OAuth -->|XRPC calls| ATProto 143 - PDS -->|XRPC calls| ATProto 144 - 145 - ATProto -->|indexed by| SlicesNet 146 - SlicesNet -->|GraphQL| Landing 147 - 148 - User -->|visits seams.so| Landing 149 - ``` 150 - 151 - --- 152 - 153 - ## Component Deep Dive 154 - 155 - ### 1. Background Script (`entrypoints/background.ts`) 156 - 157 - **Purpose**: Service Worker that handles extension-wide events and initialization. 158 - 159 - **Responsibilities**: 160 - - **Context Menu**: Registers "Annotate" option on text selection 161 - - **Icon Click**: Opens sidepanel when extension icon is clicked (Chrome only) 162 - - **Auth Broadcast**: Relays authentication state changes between components 163 - - **Logout Handler**: Clears OAuth session on logout 164 - 165 - **Key Functions**: 166 - ```typescript 167 - // Context menu creation (on install) 168 - browser.contextMenus.create({ 169 - id: 'annotate-selection', 170 - title: 'Annotate', 171 - contexts: ['selection'], 172 - }); 173 - 174 - // Context menu click → save annotation 175 - browser.contextMenus.onClicked.addListener((info, tab) => { 176 - if (info.menuItemId === 'annotate-selection') { 177 - browser.tabs.sendMessage(tab.id, { type: 'SAVE_ANNOTATION' }); 178 - } 179 - }); 180 - 181 - // Extension icon click → open sidepanel 182 - browser.action.onClicked.addListener((tab) => { 183 - if (browser.sidePanel) { 184 - browser.sidePanel.open({ tabId: tab.id }); 185 - } 186 - }); 187 - ``` 188 - 189 - **Current Limitations**: 190 - - No centralized state management (planned in refactor) 191 - - No URL change tracking 192 - - No per-tab state storage 193 - 194 - --- 195 - 196 - ### 2. Content Script (`entrypoints/content.ts`) 197 - 198 - **Purpose**: Runs on every web page to track selections and render highlights. 199 - 200 - **Lifecycle**: Injected at `document_idle` (after DOM loads) 201 - 202 - **State**: 203 - ```typescript 204 - let currentSelection: { 205 - text: string; 206 - selectors: Selector[]; 207 - } | null = null; 208 - 209 - let currentAnnotations: Annotation[] = []; 210 - ``` 211 - 212 - **Event Handlers**: 213 - 214 - #### Text Selection Tracking 215 - ```typescript 216 - document.addEventListener('mouseup', () => { 217 - const selection = window.getSelection(); 218 - if (selection && selection.toString().trim().length > 0) { 219 - const text = selection.toString().trim(); 220 - const root = document.querySelector('main') || 221 - document.querySelector('article') || 222 - document.body; 223 - const selectors = generateSelectors(selection, root); 224 - 225 - currentSelection = { text, selectors }; 226 - 227 - // Notify sidepanel 228 - browser.runtime.sendMessage({ 229 - type: 'SELECTION_CHANGED', 230 - data: { text, selectors } 231 - }); 232 - } else { 233 - currentSelection = null; 234 - } 235 - }); 236 - ``` 237 - 238 - **Why `mouseup`?** 239 - - `selectionchange` fires too frequently (on every character) 240 - - `mouseup` captures the final selection after user releases mouse 241 - - Combined with `keyup` for keyboard selections (not currently implemented) 242 - 243 - #### Message Handlers 244 - 245 - **GET_SELECTION** (from sidepanel): 246 - ```typescript 247 - // Sidepanel requests current state on load 248 - if (message.type === 'GET_SELECTION') { 249 - sendResponse({ 250 - selection: currentSelection, 251 - url: window.location.href, 252 - title: document.title 253 - }); 254 - return true; 255 - } 256 - ``` 257 - 258 - **UPDATE_HIGHLIGHTS** (from sidepanel): 259 - ```typescript 260 - // Sidepanel sends annotations after creating/loading 261 - if (message.type === 'UPDATE_HIGHLIGHTS') { 262 - currentAnnotations = message.annotations || []; 263 - applyHighlights(currentAnnotations); // Render yellow highlights 264 - sendResponse({ success: true }); 265 - return true; 266 - } 267 - ``` 268 - 269 - **CLEAR_HIGHLIGHTS**: 270 - ```typescript 271 - if (message.type === 'CLEAR_HIGHLIGHTS') { 272 - clearHighlights(); 273 - currentAnnotations = []; 274 - sendResponse({ success: true }); 275 - return true; 276 - } 277 - ``` 278 - 279 - **Known Issues**: 280 - - No debouncing on selection events (can spam messages) 281 - - Selection inside `<input>`, `<textarea>`, or `contenteditable` not filtered 282 - - No handling of SPA navigation (URL changes without page reload) 283 - - Race condition: sidepanel may request state before content script is ready 284 - 285 - --- 286 - 287 - ### 3. Sidepanel UI (`entrypoints/sidepanel/main.ts`) 288 - 289 - **Purpose**: Main user interface for authentication, creating annotations, and viewing annotations. 290 - 291 - **UI Sections**: 292 - 1. **Auth Section** (login form) 293 - 2. **Content Section** (annotation form + list) 294 - 3. **Profile Menu** (avatar dropdown with logout) 295 - 296 - #### State Management 297 - 298 - ```typescript 299 - let currentUrl = ''; // Current page URL 300 - let currentSelection: { text: string; selectors: any[] } | null = null; 301 - let allComments: Comment[] = []; // All comments for current page 302 - const collapsedThreads = new Set<string>(); // Collapsed comment threads 303 - const activeReplyForms = new Set<string>(); // Active reply forms 304 - ``` 305 - 306 - #### Initialization Flow 307 - 308 - ```typescript 309 - // 1. Check for existing OAuth session 310 - loadSession().then(async session => { 311 - if (session) { 312 - // Fetch profile and show logged-in UI 313 - const profile = await getProfile(session); 314 - profileAvatar.src = profile.avatar; 315 - authSection.style.display = 'none'; 316 - contentSection.style.display = 'block'; 317 - } 318 - }); 319 - 320 - // 2. Get current page state from content script 321 - browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 322 - if (tabs[0]?.id) { 323 - browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }) 324 - .then(response => { 325 - currentUrl = response.url; 326 - currentSelection = response.selection; 327 - 328 - if (currentSelection) { 329 - // Show annotation form with selected text 330 - annotationForm.style.display = 'block'; 331 - } 332 - 333 - loadAnnotations(currentUrl); // Fetch annotations for this page 334 - }); 335 - } 336 - }); 337 - ``` 338 - 339 - **Race Condition**: `GET_SELECTION` may fail if content script hasn't loaded yet. 340 - 341 - #### Login Flow 342 - 343 - ```typescript 344 - loginBtn.addEventListener('click', async () => { 345 - let handle = handleInput.value.trim(); 346 - 347 - // Strip @ prefix if present 348 - if (handle.startsWith('@')) { 349 - handle = handle.slice(1); 350 - } 351 - 352 - // Start OAuth flow 353 - await startLoginProcess(handle); 354 - 355 - // Reload session and show logged-in UI 356 - const session = await loadSession(); 357 - if (session) { 358 - const profile = await getProfile(session); 359 - profileAvatar.src = profile.avatar; 360 - authSection.style.display = 'none'; 361 - contentSection.style.display = 'block'; 362 - } 363 - }); 364 - ``` 365 - 366 - #### Creating Annotations 367 - 368 - ```typescript 369 - saveBtn.addEventListener('click', async () => { 370 - if (!currentSelection) { 371 - alert('Please select text on the page first'); 372 - return; 373 - } 374 - 375 - const body = annotationTextarea.value.trim(); 376 - 377 - const annotation: Annotation = { 378 - $type: 'community.lexicon.annotation.annotation', 379 - target: [{ 380 - source: currentUrl, // BUG: Can be empty if race condition occurs 381 - selector: currentSelection.selectors 382 - }], 383 - body: body || undefined, 384 - createdAt: new Date().toISOString() 385 - }; 386 - 387 - // Save locally (legacy - for backward compatibility) 388 - await saveAnnotationLocal(annotation); 389 - 390 - // Save to PDS (AT Protocol) 391 - try { 392 - const savedAnnotation = await createAnnotation(annotation); 393 - console.log('Annotation saved to PDS:', savedAnnotation); 394 - } catch (error) { 395 - console.error('Failed to save to PDS:', error); 396 - } 397 - 398 - // Reload annotations and update highlights 399 - await loadAnnotations(currentUrl); 400 - }); 401 - ``` 402 - 403 - **Bug**: `currentUrl` can be empty string if `GET_SELECTION` fails, resulting in annotations with empty `source` field. 404 - 405 - #### Loading Annotations 406 - 407 - ```typescript 408 - async function loadAnnotations(url: string) { 409 - try { 410 - // Fetch from PDS 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(); // Update UI 420 - 421 - // Send highlights to content script 422 - await updateHighlights(pageAnnotations); 423 - } catch (error) { 424 - console.error('Failed to load annotations from PDS:', error); 425 - } 426 - } 427 - ``` 428 - 429 - #### Comment System 430 - 431 - **Comment Structure**: 432 - ```typescript 433 - interface Comment { 434 - $type: 'pub.leaflet.comment'; 435 - subject: string; // URI of annotation being commented on 436 - plaintext: string; // Comment text 437 - createdAt: string; 438 - reply?: { 439 - parent: string; // URI of parent comment (for threaded replies) 440 - }; 441 - uri?: string; // AT Protocol record URI 442 - cid?: string; // Content ID 443 - } 444 - ``` 445 - 446 - **Comment Threading**: 447 - - Top-level comments: `subject` points to annotation URI, no `reply` field 448 - - Replies: `subject` still points to annotation URI, `reply.parent` points to parent comment URI 449 - 450 - **Rendering Logic**: 451 - ```typescript 452 - // Build threaded comment tree 453 - function buildCommentThread(parentUri: string, parentHasReplies: boolean): string { 454 - const replies = allComments.filter(c => c.reply?.parent === parentUri); 455 - 456 - if (collapsedThreads.has(parentUri)) { 457 - return `<button>▸ ${replies.length} hidden</button>`; 458 - } 459 - 460 - return ` 461 - <div class="comment-thread"> 462 - ${replies.map(comment => ` 463 - <div class="comment"> 464 - <div class="comment-text">${comment.plaintext}</div> 465 - <button class="reply-btn">Reply</button> 466 - ${hasReplies ? buildCommentThread(comment.uri!, true) : ''} 467 - </div> 468 - `).join('')} 469 - </div> 470 - `; 471 - } 472 - ``` 473 - 474 - **Reply Creation**: 475 - ```typescript 476 - // Save reply button handler 477 - document.querySelectorAll('.save-reply-btn').forEach(btn => { 478 - btn.addEventListener('click', async (e) => { 479 - const form = e.target.closest('.reply-form'); 480 - const textarea = form.querySelector('.reply-input'); 481 - const parent = form.getAttribute('data-parent'); // Parent comment URI 482 - const plaintext = textarea.value.trim(); 483 - 484 - // Find parent comment to get subject (annotation URI) 485 - const parentComment = allComments.find(c => c.uri === parent); 486 - 487 - await createComment({ 488 - $type: 'pub.leaflet.comment', 489 - subject: parentComment.subject, // Still points to annotation 490 - plaintext, 491 - createdAt: new Date().toISOString(), 492 - reply: { parent }, // Link to parent comment 493 - }); 494 - 495 - await loadAnnotations(currentUrl); // Refresh 496 - }); 497 - }); 498 - ``` 499 - 500 - --- 501 - 502 - ## Data Flow 503 - 504 - ### Annotation Creation Flow 505 - 506 - ``` 507 - 1. User selects text on page 508 - 509 - 510 - 2. Content Script (mouseup handler) 511 - - Extracts selection text 512 - - Generates selectors (TextQuote + TextPosition) 513 - - Stores in currentSelection 514 - 515 - 516 - 3. Content Script → Sidepanel 517 - - Sends: { type: 'SELECTION_CHANGED', data: { text, selectors } } 518 - 519 - 520 - 4. Sidepanel receives message 521 - - Updates currentSelection 522 - - Shows annotation form with quoted text 523 - 524 - 525 - 5. User writes note and clicks "Save" 526 - 527 - 528 - 6. Sidepanel (saveBtn handler) 529 - - Creates Annotation object with: 530 - * target[0].source = currentUrl 531 - * target[0].selector = currentSelection.selectors 532 - * body = note text 533 - 534 - 535 - 7. Sidepanel → PDS Module 536 - - Calls: createAnnotation(annotation) 537 - 538 - 539 - 8. PDS Module → AT Protocol 540 - - XRPC call: com.atproto.repo.createRecord 541 - - Collection: community.lexicon.annotation.annotation 542 - - Returns: { uri, cid } 543 - 544 - 545 - 9. Sidepanel reloads annotations 546 - - Calls: listAnnotations() from PDS 547 - - Filters by currentUrl 548 - 549 - 550 - 10. Sidepanel → Content Script 551 - - Sends: { type: 'UPDATE_HIGHLIGHTS', annotations } 552 - 553 - 554 - 11. Content Script applies highlights 555 - - For each annotation: 556 - * Find DOM range using selectors (match.ts) 557 - * Wrap in <span class="synthesis-highlight"> 558 - * Add click handler to show popover 559 - ``` 560 - 561 - ### Page Load Flow 562 - 563 - ``` 564 - 1. Extension loads on page 565 - 566 - 567 - 2. Content Script initializes 568 - - Sets up mouseup listener 569 - - Waits for selection events 570 - 571 - 572 - 3. User clicks extension icon 573 - 574 - 575 - 4. Background Script (action.onClicked) 576 - - Opens sidepanel: browser.sidePanel.open() 577 - 578 - 579 - 5. Sidepanel loads (main.ts IIFE) 580 - - Checks for OAuth session 581 - - If logged in: show content section 582 - - If not: show login form 583 - 584 - 585 - 6. Sidepanel → Content Script 586 - - Sends: { type: 'GET_SELECTION' } 587 - - Receives: { url, title, selection } 588 - 589 - 590 - 7. Sidepanel stores state 591 - - currentUrl = response.url 592 - - currentSelection = response.selection 593 - - If selection exists: show annotation form 594 - 595 - 596 - 8. Sidepanel loads annotations 597 - - Calls: listAnnotations() + listComments() 598 - - Filters by currentUrl 599 - - Renders annotation cards with comments 600 - 601 - 602 - 9. Sidepanel → Content Script 603 - - Sends: { type: 'UPDATE_HIGHLIGHTS', annotations } 604 - 605 - 606 - 10. Content Script renders highlights 607 - - Yellow highlighted text appears on page 608 - - Click highlight → show popover 609 - ``` 610 - 611 - --- 612 - 613 - ## Message Passing Protocol 614 - 615 - ### Current Messages (Fragile Architecture) 616 - 617 - #### Content Script → Sidepanel 618 - | Message Type | Direction | Payload | Purpose | 619 - |--------------|-----------|---------|---------| 620 - | `SELECTION_CHANGED` | Content → Sidepanel | `{ text, selectors }` | Notify sidepanel of new selection | 621 - 622 - **How it works**: `browser.runtime.sendMessage()` broadcasts to all extension contexts. Sidepanel has a listener that updates its state. 623 - 624 - #### Sidepanel → Content Script 625 - | Message Type | Direction | Payload | Purpose | 626 - |--------------|-----------|---------|---------| 627 - | `GET_SELECTION` | Sidepanel → Content | - | Request current page state | 628 - | `UPDATE_HIGHLIGHTS` | Sidepanel → Content | `{ annotations }` | Render highlights on page | 629 - | `CLEAR_HIGHLIGHTS` | Sidepanel → Content | - | Remove all highlights | 630 - 631 - **How it works**: `browser.tabs.sendMessage(tabId, message)` sends to specific tab's content script. 632 - 633 - **Response format for GET_SELECTION**: 634 - ```typescript 635 - { 636 - selection: { text: string; selectors: Selector[] } | null, 637 - url: string, 638 - title: string 639 - } 640 - ``` 641 - 642 - #### Background Script Messages 643 - | Message Type | Direction | Payload | Purpose | 644 - |--------------|-----------|---------|---------| 645 - | `AUTH_STATE_CHANGED` | Background → All | `{ isAuthenticated: boolean }` | Broadcast auth state | 646 - | `LOGOUT` | Any → Background | - | Request logout | 647 - 648 - --- 649 - 650 - ## Authentication System 651 - 652 - ### AT Protocol OAuth Flow 653 - 654 - **Library**: `@atcute/oauth-browser-client` 655 - 656 - **OAuth Configuration**: 657 - ```typescript 658 - // lib/oauth.ts 659 - configureOAuth({ 660 - metadata: { 661 - client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 662 - redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 663 - }, 664 - }); 665 - ``` 666 - 667 - **Redirect Strategy**: 668 - - AT Protocol OAuth expects a web redirect URL 669 - - Extension uses `https://synthes-is.netlify.app/oauth-callback.html` 670 - - Callback page relays to `chromiumapp://` URL for extension capture 671 - 672 - ### Login Process 673 - 674 - ``` 675 - 1. User enters handle (e.g., "user.bsky.social") 676 - 677 - 678 - 2. Sidepanel → OAuth Module 679 - - Calls: startLoginProcess(handle) 680 - 681 - 682 - 3. OAuth Module 683 - - Calls: resolveFromIdentity(handle) 684 - - Returns: PDS metadata (authorization server URL, etc.) 685 - 686 - 687 - 4. OAuth Module 688 - - Calls: createAuthorizationUrl({ metadata, scope }) 689 - - Returns: Authorization URL (e.g., https://bsky.social/oauth/authorize?...) 690 - 691 - 692 - 5. OAuth Module → Browser 693 - - Calls: browser.identity.launchWebAuthFlow({ url, interactive: true }) 694 - - Opens popup with authorization page 695 - 696 - 697 - 6. User authorizes in popup 698 - 699 - 700 - 7. PDS redirects to: https://synthes-is.netlify.app/oauth-callback.html?code=...&state=... 701 - 702 - 703 - 8. Callback page redirects to: chromiumapp://[extension-id]/extension-callback.html?code=... 704 - 705 - 706 - 9. browser.identity.launchWebAuthFlow captures redirect 707 - - Returns: capturedUrl 708 - 709 - 710 - 10. OAuth Module 711 - - Parses OAuth params from URL (code, state, etc.) 712 - - Calls: finalizeAuthorization(params) 713 - - Exchanges code for tokens 714 - - Returns: OAuthSession { info: { sub: "did:plc:..." }, ... } 715 - 716 - 717 - 11. OAuth Module 718 - - Saves session to: browser.storage.local['synthesis-oauth:session'] 719 - 720 - 721 - 12. Sidepanel 722 - - Fetches profile: getProfile(session) 723 - - Shows logged-in UI 724 - ``` 725 - 726 - ### Session Management 727 - 728 - **Storage Key**: `synthesis-oauth:session` 729 - 730 - **Session Structure**: 731 - ```typescript 732 - interface OAuthSession { 733 - info: { 734 - sub: string; // DID (e.g., "did:plc:abc123...") 735 - // ... other fields 736 - }; 737 - // ... tokens and metadata 738 - } 739 - ``` 740 - 741 - **Session Functions**: 742 - ```typescript 743 - // Save session after login 744 - async function saveSession(session: OAuthSession) { 745 - await browser.storage.local.set({ 746 - 'synthesis-oauth:session': session 747 - }); 748 - } 749 - 750 - // Load session on startup 751 - async function loadSession(): Promise<OAuthSession | null> { 752 - const result = await browser.storage.local.get('synthesis-oauth:session'); 753 - return result['synthesis-oauth:session'] || null; 754 - } 755 - 756 - // Clear session on logout 757 - async function clearSession() { 758 - await browser.storage.local.remove('synthesis-oauth:session'); 759 - } 760 - 761 - // Get user profile 762 - async function getProfile(session: OAuthSession) { 763 - const agent = new OAuthUserAgent(session); 764 - const response = await agent.handle( 765 - '/xrpc/app.bsky.actor.getProfile?actor=' + session.info.sub 766 - ); 767 - return await response.json(); 768 - } 769 - ``` 770 - 771 - --- 772 - 773 - ## Annotation Lifecycle 774 - 775 - ### Data Model 776 - 777 - **AT Protocol Record Type**: `community.lexicon.annotation.annotation` 778 - 779 - **TypeScript Interface**: 780 - ```typescript 781 - interface Annotation { 782 - $type: 'community.lexicon.annotation.annotation'; 783 - target: Target[]; // What is being annotated 784 - body?: string; // Annotation note/comment 785 - tags?: string[]; // Tags for categorization 786 - document?: { 787 - title?: string; 788 - canonicalUri?: string; 789 - }; 790 - createdAt: string; // ISO 8601 timestamp 791 - 792 - // ATProto metadata (added after creation) 793 - uri?: string; // at://did:plc:abc123/collection/rkey 794 - cid?: string; // Content ID (hash) 795 - author?: { 796 - did: string; 797 - handle: string; 798 - displayName?: string; 799 - avatar?: string; 800 - }; 801 - } 802 - 803 - interface Target { 804 - source: string; // Page URL 805 - selector?: Selector[]; // How to find the text 806 - } 807 - ``` 808 - 809 - ### Selector Types 810 - 811 - **TextQuoteSelector** (Most robust - survives content changes): 812 - ```typescript 813 - { 814 - $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 815 - exact: "the selected text", 816 - prefix: "text before ", // Context before selection 817 - suffix: " text after" // Context after selection 818 - } 819 - ``` 820 - 821 - **TextPositionSelector** (Precise but fragile): 822 - ```typescript 823 - { 824 - $type: 'community.lexicon.annotation.annotation#textPositionSelector', 825 - start: 1234, // Character offset in document 826 - end: 1298 // Character offset in document 827 - } 828 - ``` 829 - 830 - **Strategy**: Generate both types. Try TextPositionSelector first (fast), fall back to TextQuoteSelector (slower but more robust). 831 - 832 - ### CRUD Operations 833 - 834 - All operations go through `lib/pds.ts` using `@atcute/oauth-browser-client`. 835 - 836 - #### Create Annotation 837 - ```typescript 838 - // lib/pds.ts 839 - async function createAnnotation(annotation: Annotation): Promise<Annotation> { 840 - const session = await loadSession(); 841 - const agent = new OAuthUserAgent(session); 842 - 843 - const response = await agent.handle('/xrpc/com.atproto.repo.createRecord', { 844 - method: 'POST', 845 - headers: { 'Content-Type': 'application/json' }, 846 - body: JSON.stringify({ 847 - repo: session.info.sub, // User's DID 848 - collection: 'community.lexicon.annotation.annotation', 849 - record: { 850 - $type: annotation.$type, 851 - target: annotation.target, 852 - body: annotation.body, 853 - tags: annotation.tags, 854 - document: annotation.document, 855 - createdAt: annotation.createdAt, 856 - }, 857 - }), 858 - }); 859 - 860 - const result = await response.json(); 861 - 862 - return { 863 - ...annotation, 864 - uri: result.uri, // at://did:plc:abc123/collection/rkey 865 - cid: result.cid, 866 - }; 867 - } 868 - ``` 869 - 870 - #### List Annotations 871 - ```typescript 872 - async function listAnnotations(): Promise<Annotation[]> { 873 - const session = await loadSession(); 874 - const agent = new OAuthUserAgent(session); 875 - 876 - const response = await agent.handle( 877 - `/xrpc/com.atproto.repo.listRecords?` + 878 - `repo=${session.info.sub}&` + 879 - `collection=community.lexicon.annotation.annotation`, 880 - { method: 'GET' } 881 - ); 882 - 883 - const result = await response.json(); 884 - 885 - return result.records.map((record: any) => ({ 886 - ...record.value, 887 - uri: record.uri, 888 - cid: record.cid, 889 - })); 890 - } 891 - ``` 892 - 893 - #### Delete Annotation 894 - ```typescript 895 - async function deleteAnnotation(uri: string): Promise<void> { 896 - const session = await loadSession(); 897 - const agent = new OAuthUserAgent(session); 898 - 899 - // Parse rkey from URI: at://did:plc:xxx/collection/rkey 900 - const rkey = uri.split("/").pop(); 901 - 902 - await agent.handle('/xrpc/com.atproto.repo.deleteRecord', { 903 - method: 'POST', 904 - headers: { 'Content-Type': 'application/json' }, 905 - body: JSON.stringify({ 906 - repo: session.info.sub, 907 - collection: 'community.lexicon.annotation.annotation', 908 - rkey, 909 - }), 910 - }); 911 - } 912 - ``` 913 - 914 - --- 915 - 916 - ## Highlight System 917 - 918 - ### Rendering Pipeline 919 - 920 - ``` 921 - Annotation → Selector Matching → DOM Range → Highlight Span 922 - ``` 923 - 924 - ### Step 1: Find DOM Range 925 - 926 - **File**: `lib/selectors/match.ts` 927 - 928 - ```typescript 929 - function findAnnotationRange( 930 - annotation: Annotation, 931 - container: HTMLElement = document.body 932 - ): Range | null { 933 - const selectors = annotation.target?.[0]?.selector; 934 - 935 - // Try each selector in order 936 - for (const selector of selectors) { 937 - let range: Range | null = null; 938 - 939 - switch (selector.$type) { 940 - case 'community.lexicon.annotation.annotation#textPositionSelector': 941 - range = matchTextPositionSelector(selector, container); 942 - break; 943 - case 'community.lexicon.annotation.annotation#textQuoteSelector': 944 - range = matchTextQuoteSelector(selector, container); 945 - break; 946 - } 947 - 948 - if (range) return range; 949 - } 950 - 951 - return null; 952 - } 953 - ``` 954 - 955 - **TextPositionSelector Matching**: 956 - ```typescript 957 - function matchTextPositionSelector( 958 - selector: TextPositionSelector, 959 - container: HTMLElement 960 - ): Range | null { 961 - try { 962 - // Uses dom-anchor-text-position library 963 - return textPosition.toRange(container, selector); 964 - } catch (e) { 965 - return null; 966 - } 967 - } 968 - ``` 969 - 970 - **TextQuoteSelector Matching**: 971 - ```typescript 972 - function matchTextQuoteSelector( 973 - selector: TextQuoteSelector, 974 - container: HTMLElement 975 - ): Range | null { 976 - try { 977 - // Uses dom-anchor-text-quote library 978 - return textQuote.toRange(container, selector); 979 - } catch (e) { 980 - return null; 981 - } 982 - } 983 - ``` 984 - 985 - ### Step 2: Apply Highlight 986 - 987 - **File**: `lib/highlights/apply.ts` 988 - 989 - ```typescript 990 - function highlightRange(range: Range, annotation: Annotation) { 991 - // Create highlight span 992 - const highlight = document.createElement('span'); 993 - highlight.className = 'synthesis-highlight'; 994 - highlight.dataset.annotationId = annotation.uri || annotation.createdAt; 995 - highlight.style.cssText = ` 996 - background-color: rgba(255, 235, 59, 0.6) !important; 997 - cursor: pointer !important; 998 - transition: background-color 0.2s !important; 999 - `; 1000 - 1001 - // Hover effect 1002 - highlight.addEventListener('mouseenter', () => { 1003 - highlight.style.backgroundColor = 'rgba(255, 235, 59, 0.8)'; 1004 - }); 1005 - 1006 - highlight.addEventListener('mouseleave', () => { 1007 - highlight.style.backgroundColor = 'rgba(255, 235, 59, 0.6)'; 1008 - }); 1009 - 1010 - // Click to show popover 1011 - highlight.addEventListener('click', (e) => { 1012 - e.preventDefault(); 1013 - e.stopPropagation(); 1014 - showAnnotationPopover(annotation, highlight, onSave, onDelete); 1015 - }); 1016 - 1017 - // Wrap the range content 1018 - const contents = range.extractContents(); // Extract nodes from DOM 1019 - highlight.appendChild(contents); // Put them in highlight span 1020 - range.insertNode(highlight); // Insert span back into DOM 1021 - } 1022 - ``` 1023 - 1024 - **Why `extractContents` instead of `surroundContents`?** 1025 - - `surroundContents` fails if the range spans multiple elements (e.g., `<p>some <strong>bold</strong> text</p>`) 1026 - - `extractContents` + `appendChild` + `insertNode` works for all cases 1027 - 1028 - ### Step 3: Clear Highlights 1029 - 1030 - ```typescript 1031 - function clearHighlights(container: HTMLElement = document.body) { 1032 - const highlights = container.querySelectorAll('.synthesis-highlight'); 1033 - 1034 - highlights.forEach(highlight => { 1035 - const parent = highlight.parentNode; 1036 - if (parent) { 1037 - // Move children back to parent (unwrap) 1038 - while (highlight.firstChild) { 1039 - parent.insertBefore(highlight.firstChild, highlight); 1040 - } 1041 - // Remove empty span 1042 - parent.removeChild(highlight); 1043 - // Merge adjacent text nodes 1044 - parent.normalize(); 1045 - } 1046 - }); 1047 - } 1048 - ``` 1049 - 1050 - ### Annotation Popover 1051 - 1052 - **File**: `lib/highlights/popover.ts` 1053 - 1054 - **Purpose**: Show edit/delete UI when clicking a highlight. 1055 - 1056 - ```typescript 1057 - function showAnnotationPopover( 1058 - annotation: Annotation, 1059 - targetElement: HTMLElement, 1060 - onSave: (updatedAnnotation: Annotation) => void, 1061 - onDelete: () => void 1062 - ) { 1063 - const popover = document.createElement('div'); 1064 - popover.className = 'synthesis-popover'; 1065 - 1066 - // Position below the highlight 1067 - const rect = targetElement.getBoundingClientRect(); 1068 - popover.style.left = `${rect.left + window.scrollX}px`; 1069 - popover.style.top = `${rect.bottom + window.scrollY + 5}px`; 1070 - 1071 - // Show quoted text + editable note 1072 - const quote = annotation.target[0]?.selector?.find( 1073 - s => s.$type === 'community.lexicon.annotation.annotation#textQuoteSelector' 1074 - ); 1075 - 1076 - popover.innerHTML = ` 1077 - <div class="quote">${quote?.exact || ''}</div> 1078 - <textarea class="note-input">${annotation.body || ''}</textarea> 1079 - <button class="save-btn">Save</button> 1080 - <button class="delete-btn">Delete</button> 1081 - `; 1082 - 1083 - document.body.appendChild(popover); 1084 - 1085 - // Save handler 1086 - saveBtn.addEventListener('click', () => { 1087 - const updatedAnnotation = { 1088 - ...annotation, 1089 - body: textarea.value.trim() || undefined, 1090 - }; 1091 - onSave(updatedAnnotation); 1092 - hidePopover(); 1093 - }); 1094 - 1095 - // Delete handler 1096 - deleteBtn.addEventListener('click', () => { 1097 - if (confirm('Delete this annotation?')) { 1098 - onDelete(); 1099 - hidePopover(); 1100 - } 1101 - }); 1102 - } 1103 - ``` 1104 - 1105 - **Update Flow (from popover)**: 1106 - ```typescript 1107 - // In lib/highlights/apply.ts 1108 - showAnnotationPopover( 1109 - annotation, 1110 - highlight, 1111 - // onSave callback 1112 - async (updatedAnnotation) => { 1113 - // Update in local storage (legacy) 1114 - const stored = await browser.storage.local.get('annotations'); 1115 - const annotations = stored.annotations || []; 1116 - const index = annotations.findIndex(a => a.createdAt === annotation.createdAt); 1117 - if (index !== -1) { 1118 - annotations[index] = updatedAnnotation; 1119 - await browser.storage.local.set({ annotations }); 1120 - } 1121 - 1122 - // Update in-memory object (so next click shows updated note) 1123 - Object.assign(annotation, updatedAnnotation); 1124 - }, 1125 - // onDelete callback 1126 - async () => { 1127 - // Remove from local storage 1128 - const stored = await browser.storage.local.get('annotations'); 1129 - const filtered = annotations.filter(a => a.createdAt !== annotation.createdAt); 1130 - await browser.storage.local.set({ annotations: filtered }); 1131 - 1132 - // Remove highlight from DOM 1133 - highlight.remove(); 1134 - } 1135 - ); 1136 - ``` 1137 - 1138 - **Note**: Popover save/delete only updates `browser.storage.local`, not the PDS. This is a bug/limitation. 1139 - 1140 - --- 1141 - 1142 - ## Selector System 1143 - 1144 - ### Generation (`lib/selectors/generate.ts`) 1145 - 1146 - **Purpose**: Convert a DOM Selection into portable selectors. 1147 - 1148 - ```typescript 1149 - function generateSelectors(selection: Selection, root?: HTMLElement): Selector[] { 1150 - if (!selection.rangeCount) return []; 1151 - 1152 - const range = selection.getRangeAt(0); 1153 - const container = root || document.body; 1154 - const selectors: Selector[] = []; 1155 - 1156 - // Generate TextQuoteSelector (robust) 1157 - const textQuoteSelector = generateTextQuoteSelector(range, container); 1158 - if (textQuoteSelector) selectors.push(textQuoteSelector); 1159 - 1160 - // Generate TextPositionSelector (precise) 1161 - const textPositionSelector = generateTextPositionSelector(range, container); 1162 - if (textPositionSelector) selectors.push(textPositionSelector); 1163 - 1164 - return selectors; 1165 - } 1166 - ``` 1167 - 1168 - **TextQuoteSelector Generation**: 1169 - ```typescript 1170 - function generateTextQuoteSelector( 1171 - range: Range, 1172 - root: HTMLElement 1173 - ): TextQuoteSelector | null { 1174 - const exact = range.toString().trim(); 1175 - if (!exact) return null; 1176 - 1177 - try { 1178 - // Uses dom-anchor-text-quote library 1179 - const selector = textQuote.fromRange(root, range); 1180 - 1181 - return { 1182 - $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 1183 - exact: selector.exact, 1184 - prefix: selector.prefix || undefined, 1185 - suffix: selector.suffix || undefined 1186 - }; 1187 - } catch (error) { 1188 - console.warn('Failed to generate TextQuoteSelector:', error); 1189 - return null; 1190 - } 1191 - } 1192 - ``` 1193 - 1194 - **How it works**: 1195 - - `exact`: The selected text itself 1196 - - `prefix`: 32 characters before the selection (for context) 1197 - - `suffix`: 32 characters after the selection (for context) 1198 - 1199 - **Example**: 1200 - ``` 1201 - Page text: "The quick brown fox jumps over the lazy dog." 1202 - Selection: "brown fox" 1203 - 1204 - TextQuoteSelector: 1205 - { 1206 - exact: "brown fox", 1207 - prefix: "The quick ", 1208 - suffix: " jumps over" 1209 - } 1210 - ``` 1211 - 1212 - **TextPositionSelector Generation**: 1213 - ```typescript 1214 - function generateTextPositionSelector( 1215 - range: Range, 1216 - root: HTMLElement 1217 - ): TextPositionSelector | null { 1218 - const exact = range.toString().trim(); 1219 - if (!exact) return null; 1220 - 1221 - try { 1222 - // Uses dom-anchor-text-position library 1223 - const selector = textPosition.fromRange(root, range); 1224 - 1225 - return { 1226 - $type: 'community.lexicon.annotation.annotation#textPositionSelector', 1227 - start: selector.start, 1228 - end: selector.end 1229 - }; 1230 - } catch (error) { 1231 - console.warn('Failed to generate TextPositionSelector:', error); 1232 - return null; 1233 - } 1234 - } 1235 - ``` 1236 - 1237 - **How it works**: 1238 - - `start`: Character offset from start of container's text content 1239 - - `end`: Character offset from start of container's text content 1240 - 1241 - **Example**: 1242 - ``` 1243 - Page text: "The quick brown fox jumps over the lazy dog." 1244 - Selection: "brown fox" 1245 - 1246 - TextPositionSelector: 1247 - { 1248 - start: 10, // 0-indexed position of "b" in "brown" 1249 - end: 19 // 0-indexed position after "x" in "fox" 1250 - } 1251 - ``` 1252 - 1253 - ### Matching (`lib/selectors/match.ts`) 1254 - 1255 - **Purpose**: Convert selectors back into DOM Range for highlighting. 1256 - 1257 - **Strategy**: 1258 - 1. Try TextPositionSelector first (fast, precise) 1259 - 2. If fails, try TextQuoteSelector (slower, more robust) 1260 - 1261 - **Why this order?** 1262 - - TextPositionSelector is O(n) where n = text length 1263 - - TextQuoteSelector is O(n*m) where m = search window size 1264 - - But TextQuoteSelector works even if content changed slightly 1265 - 1266 - --- 1267 - 1268 - ## Comment/Reply System 1269 - 1270 - ### Data Model 1271 - 1272 - **AT Protocol Record Type**: `pub.leaflet.comment` 1273 - 1274 - **TypeScript Interface**: 1275 - ```typescript 1276 - interface Comment { 1277 - $type: 'pub.leaflet.comment'; 1278 - subject: string; // URI of annotation being commented on 1279 - plaintext: string; // Comment text 1280 - createdAt: string; 1281 - reply?: { 1282 - parent: string; // URI of parent comment (for replies) 1283 - }; 1284 - facets?: RichtextFacet[]; // For mentions, links, hashtags 1285 - onPage?: string; // URL of page (optional) 1286 - 1287 - uri?: string; // at://did:plc:abc123/collection/rkey 1288 - cid?: string; 1289 - author?: { 1290 - did: string; 1291 - handle: string; 1292 - displayName?: string; 1293 - avatar?: string; 1294 - }; 1295 - } 1296 - ``` 1297 - 1298 - ### Comment Hierarchy 1299 - 1300 - ``` 1301 - Annotation (at://did:plc:abc/annotation/123) 1302 - ├─ Comment 1 (subject: annotation URI, no reply) 1303 - │ ├─ Reply 1.1 (subject: annotation URI, reply.parent: comment1 URI) 1304 - │ └─ Reply 1.2 (subject: annotation URI, reply.parent: comment1 URI) 1305 - │ └─ Reply 1.2.1 (subject: annotation URI, reply.parent: reply1.2 URI) 1306 - └─ Comment 2 (subject: annotation URI, no reply) 1307 - ``` 1308 - 1309 - **Key insight**: All comments/replies have the same `subject` (annotation URI). The `reply.parent` field creates the tree structure. 1310 - 1311 - ### Rendering Algorithm 1312 - 1313 - ```typescript 1314 - // Get top-level comments (no reply field) 1315 - const topLevelComments = allComments.filter(c => 1316 - c.subject === annotation.uri && !c.reply 1317 - ); 1318 - 1319 - // Recursively build thread 1320 - function buildCommentThread(parentUri: string): string { 1321 - const replies = allComments.filter(c => c.reply?.parent === parentUri); 1322 - 1323 - if (collapsedThreads.has(parentUri)) { 1324 - return `<button>▸ ${replies.length} hidden</button>`; 1325 - } 1326 - 1327 - return ` 1328 - <div class="comment-thread"> 1329 - ${replies.map(comment => ` 1330 - <div class="comment"> 1331 - ${comment.plaintext} 1332 - <button class="reply-btn">Reply</button> 1333 - ${buildCommentThread(comment.uri!)} <!-- Recursion --> 1334 - </div> 1335 - `).join('')} 1336 - </div> 1337 - `; 1338 - } 1339 - ``` 1340 - 1341 - ### Creating Comments 1342 - 1343 - **Top-level comment**: 1344 - ```typescript 1345 - await createComment({ 1346 - $type: 'pub.leaflet.comment', 1347 - subject: annotationUri, // at://did:plc:abc/annotation/123 1348 - plaintext: "Great annotation!", 1349 - createdAt: new Date().toISOString(), 1350 - // No reply field 1351 - }); 1352 - ``` 1353 - 1354 - **Reply to comment**: 1355 - ```typescript 1356 - await createComment({ 1357 - $type: 'pub.leaflet.comment', 1358 - subject: annotationUri, // Still the annotation URI 1359 - plaintext: "I agree!", 1360 - createdAt: new Date().toISOString(), 1361 - reply: { 1362 - parent: parentCommentUri // at://did:plc:abc/comment/456 1363 - }, 1364 - }); 1365 - ``` 1366 - 1367 - --- 1368 - 1369 - ## Landing Page 1370 - 1371 - **File**: `public/landing.js` + `public/landing.css` 1372 - 1373 - **Purpose**: Public-facing website at `seams.so` that shows recent annotations from all users. 1374 - 1375 - ### Architecture 1376 - 1377 - ``` 1378 - Landing Page (static HTML/JS) 1379 - 1380 - 1381 - slices.network GraphQL API 1382 - 1383 - 1384 - AT Protocol Network (federated PDS nodes) 1385 - ``` 1386 - 1387 - ### slices.network Integration 1388 - 1389 - **Slice ID**: `at://did:plc:dy6ekftqerqu5bcz76kgy6ux/network.slices.slice/3m3ugigrrz52k` 1390 - 1391 - **GraphQL Endpoint**: `https://api.slices.network/graphql?slice=[SLICE_ID]` 1392 - 1393 - **Query**: 1394 - ```graphql 1395 - query RecentAnnotations($cursor: String) { 1396 - communityLexiconAnnotationAnnotations( 1397 - sortBy: [{ field: "createdAt", direction: desc }] 1398 - first: 20 1399 - after: $cursor 1400 - ) { 1401 - edges { 1402 - cursor 1403 - node { 1404 - uri 1405 - did 1406 - actorHandle 1407 - target { 1408 - source 1409 - selector 1410 - } 1411 - body 1412 - createdAt 1413 - } 1414 - } 1415 - pageInfo { 1416 - hasNextPage 1417 - endCursor 1418 - } 1419 - } 1420 - } 1421 - ``` 1422 - 1423 - ### Data Flow 1424 - 1425 - ```typescript 1426 - // Fetch annotations 1427 - const response = await fetch(GRAPHQL_ENDPOINT, { 1428 - method: 'POST', 1429 - headers: { 'Content-Type': 'application/json' }, 1430 - body: JSON.stringify({ 1431 - query: QUERY, 1432 - variables: { cursor } 1433 - }) 1434 - }); 1435 - 1436 - const data = await response.json(); 1437 - const annotations = data.data.communityLexiconAnnotationAnnotations.edges 1438 - .map(edge => edge.node); 1439 - 1440 - // Render each annotation 1441 - for (const annotation of annotations) { 1442 - const quotedText = getQuotedText(annotation.target); 1443 - const sourceUrl = annotation.target[0]?.source; 1444 - const fragmentUrl = buildTextFragmentUrl(sourceUrl, quotedText); 1445 - 1446 - // Fetch avatar from Bluesky 1447 - const profile = await fetch( 1448 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${annotation.did}` 1449 - ); 1450 - const avatarUrl = profile.avatar; 1451 - 1452 - // Render card 1453 - renderAnnotationCard({ 1454 - quote: quotedText, 1455 - body: annotation.body, 1456 - author: annotation.actorHandle, 1457 - avatar: avatarUrl, 1458 - time: annotation.createdAt, 1459 - link: fragmentUrl 1460 - }); 1461 - } 1462 - ``` 1463 - 1464 - ### Text Fragment URL 1465 - 1466 - **Purpose**: Link to the annotated text on the original page. 1467 - 1468 - **Browser Feature**: Chrome supports scroll-to-text-highlight via `#:~:text=` fragment. 1469 - 1470 - **Implementation**: 1471 - ```typescript 1472 - function buildTextFragmentUrl(sourceUrl: string, textQuoteSelector: TextQuoteSelector) { 1473 - if (!sourceUrl || !textQuoteSelector?.exact) { 1474 - return sourceUrl; 1475 - } 1476 - 1477 - try { 1478 - const url = new URL(sourceUrl); 1479 - // Simple version: just exact text 1480 - url.hash = `:~:text=${encodeURIComponent(textQuoteSelector.exact)}`; 1481 - return url.toString(); 1482 - } catch { 1483 - return sourceUrl; 1484 - } 1485 - } 1486 - ``` 1487 - 1488 - **Example**: 1489 - ``` 1490 - Original: https://example.com/article 1491 - With fragment: https://example.com/article#:~:text=brown%20fox 1492 - 1493 - Browser behavior: 1494 - 1. Loads page 1495 - 2. Searches for "brown fox" 1496 - 3. Scrolls to match 1497 - 4. Highlights in yellow (native browser highlight, not extension) 1498 - ``` 1499 - 1500 - **Limitation**: Only works with simple `exact` text. Doesn't use `prefix`/`suffix` for context. 1501 - 1502 - --- 1503 - 1504 - ## Storage Architecture 1505 - 1506 - ### Extension Storage 1507 - 1508 - **API**: `browser.storage.local` (Chrome Extension Storage) 1509 - 1510 - **Keys**: 1511 - | Key | Value | Purpose | 1512 - |-----|-------|---------| 1513 - | `synthesis-oauth:session` | `OAuthSession` | OAuth tokens and user DID | 1514 - | `annotations` | `Annotation[]` | Legacy local storage (not used in current flow) | 1515 - 1516 - **Note**: Current implementation stores annotations primarily on PDS, not in local storage. Local storage is only used by the popover edit/delete feature (which is a bug - it should update PDS). 1517 - 1518 - ### AT Protocol Storage 1519 - 1520 - **Personal Data Server (PDS)**: User's own data repository (e.g., `https://bsky.social/xrpc`) 1521 - 1522 - **Collections**: 1523 - | Collection | Type | Records | 1524 - |------------|------|---------| 1525 - | `community.lexicon.annotation.annotation` | Annotations | User's annotations | 1526 - | `pub.leaflet.comment` | Comments | Comments and replies | 1527 - 1528 - **Record URIs**: 1529 - ``` 1530 - at://[did]/[collection]/[rkey] 1531 - 1532 - Examples: 1533 - - at://did:plc:abc123/community.lexicon.annotation.annotation/3k2j4h5g6f 1534 - - at://did:plc:abc123/pub.leaflet.comment/3k2j4h5g6f 1535 - ``` 1536 - 1537 - ### Storage Flow 1538 - 1539 - ``` 1540 - User creates annotation 1541 - 1542 - 1543 - Sidepanel → PDS Module → com.atproto.repo.createRecord 1544 - │ │ 1545 - │ ▼ 1546 - │ User's PDS (e.g., bsky.social) 1547 - │ │ 1548 - │ ▼ 1549 - │ Record stored with URI + CID 1550 - │ │ 1551 - │ ▼ 1552 - │ Federated to ATProto network 1553 - │ │ 1554 - ▼ ▼ 1555 - (Optional) Local storage slices.network indexes 1556 - ``` 1557 - 1558 - --- 1559 - 1560 - ## Known Issues 1561 - 1562 - ### 1. Race Condition on Sidepanel Load 1563 - 1564 - **Problem**: Sidepanel sends `GET_SELECTION` to content script on load, but content script may not be ready yet. 1565 - 1566 - **Result**: `currentUrl` is empty string, annotations saved with empty `source` field. 1567 - 1568 - **Location**: `entrypoints/sidepanel/main.ts:187-207` 1569 - 1570 - **Fix**: See `STATE_REFACTOR_PLAN.md` for event-driven architecture. 1571 - 1572 - ### 2. No SPA Navigation Tracking 1573 - 1574 - **Problem**: Content script doesn't detect URL changes from `pushState`/`replaceState`. 1575 - 1576 - **Result**: After navigating in a single-page app, annotations for the new page don't load until sidepanel is reopened. 1577 - 1578 - **Fix**: See `STATE_REFACTOR_PLAN.md` for `webNavigation.onHistoryStateUpdated` solution. 1579 - 1580 - ### 3. No Selection Debouncing 1581 - 1582 - **Problem**: `mouseup` event fires even for clicks (no actual selection). 1583 - 1584 - **Result**: Unnecessary `SELECTION_CHANGED` messages sent to sidepanel. 1585 - 1586 - **Fix**: Add 250ms debounce and ignore empty selections. 1587 - 1588 - ### 4. Editable Field Selections Not Filtered 1589 - 1590 - **Problem**: Selections inside `<input>`, `<textarea>`, or `contenteditable` are captured. 1591 - 1592 - **Result**: Confusing UX - user can't annotate form fields, but extension shows form. 1593 - 1594 - **Fix**: Check `document.activeElement` and ignore editable elements. 1595 - 1596 - ### 5. Popover Edit/Delete Doesn't Update PDS 1597 - 1598 - **Problem**: When editing an annotation via highlight popover, it only updates `browser.storage.local`. 1599 - 1600 - **Result**: Changes aren't synced to PDS or visible on other devices. 1601 - 1602 - **Location**: `lib/highlights/apply.ts:85-112` 1603 - 1604 - **Fix**: Call `updateAnnotation()` function in PDS module (needs to be implemented). 1605 - 1606 - ### 6. No Service Worker State Persistence 1607 - 1608 - **Problem**: Background script doesn't persist state across restarts (MV3 service workers suspend). 1609 - 1610 - **Result**: State is lost if service worker is terminated. 1611 - 1612 - **Fix**: Use `chrome.storage.session` (see `STATE_REFACTOR_PLAN.md`). 1613 - 1614 - ### 7. Text Fragment URL Limitation 1615 - 1616 - **Problem**: Landing page only uses `exact` text for text fragments, ignoring `prefix`/`suffix`. 1617 - 1618 - **Result**: If the same text appears multiple times on a page, browser may highlight the wrong instance. 1619 - 1620 - **Location**: `public/landing.js:159-171` 1621 - 1622 - **Fix**: Implement complex text fragment syntax with prefix/suffix (non-trivial). 1623 - 1624 - ### 8. No Cross-Tab Communication 1625 - 1626 - **Problem**: If user has multiple tabs open, creating an annotation in one tab doesn't update highlights in other tabs with the same URL. 1627 - 1628 - **Result**: User must manually reload sidepanel to see new annotations. 1629 - 1630 - **Fix**: Broadcast `ANNOTATIONS_CHANGED` event to all tabs with matching URL. 1631 - 1632 - ### 9. No Error Recovery for Selector Matching 1633 - 1634 - **Problem**: If all selectors fail to match (e.g., page content changed), annotation is silently skipped. 1635 - 1636 - **Result**: User sees annotations in list but no highlights on page. 1637 - 1638 - **Fix**: Show "Could not locate annotation" warning in UI, offer to re-anchor manually. 1639 - 1640 - ### 10. Comment Threading UI Complexity 1641 - 1642 - **Problem**: Nested replies can become deeply indented and hard to read. 1643 - 1644 - **Result**: Poor UX for long discussion threads. 1645 - 1646 - **Fix**: Implement "Show more replies" collapse/expand, or switch to flat threading with "Replying to @user" indicators. 1647 - 1648 - --- 1649 - 1650 - ## Summary Diagram 1651 - 1652 - ``` 1653 - ┌─────────────────────────────────────────────────────────────────┐ 1654 - │ USER ACTIONS │ 1655 - └──────────┬────────────────────────────────────────┬─────────────┘ 1656 - │ │ 1657 - │ Select text │ Click icon 1658 - │ │ 1659 - ┌──────────▼─────────────────┐ ┌─────────▼──────────────┐ 1660 - │ Web Page (Content) │ │ Extension UI │ 1661 - │ │ │ (Sidepanel) │ 1662 - │ ┌────────────────────────┐ │ │ │ 1663 - │ │ Content Script │ │ │ ┌────────────────────┐ │ 1664 - │ │ │ │ │ │ Auth Section │ │ 1665 - │ │ • Track selection │◄┼────────────┼─┤ - Login form │ │ 1666 - │ │ • Generate selectors │ │ GET_SEL │ │ - OAuth flow │ │ 1667 - │ │ • Apply highlights │ │ │ └────────────────────┘ │ 1668 - │ │ • Show popovers │ │ │ ┌────────────────────┐ │ 1669 - │ └──────┬─────────▲───────┘ │ │ │ Content Section │ │ 1670 - │ │ │ │ │ │ - Annotation form │ │ 1671 - │ │ SELECTION│UPDATE │ │ │ - Annotation list │ │ 1672 - │ │ _CHANGED │_HIGHLIGHTS │ │ - Comment threads │ │ 1673 - │ │ │ │ │ └────────────────────┘ │ 1674 - └────────┼─────────┼──────────┘ └─────────┬──────────────┘ 1675 - │ │ │ 1676 - │ │ │ createAnnotation 1677 - │ │ │ listAnnotations 1678 - ▼ │ │ createComment 1679 - ┌────────────────────────────┐ │ 1680 - │ Background Script │ │ 1681 - │ │ │ 1682 - │ • Context menu │ │ 1683 - │ • Auth broadcast │ │ 1684 - │ • Icon click → sidepanel │ │ 1685 - └────────────────────────────┘ │ 1686 - 1687 - ┌──────────────────────────────────────────┘ 1688 - 1689 - 1690 - ┌─────────────────────────────────────────────────────┐ 1691 - │ lib/ │ 1692 - │ │ 1693 - │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ 1694 - │ │ oauth.ts │ │ pds.ts │ │ selectors/ │ │ 1695 - │ │ │ │ │ │ │ │ 1696 - │ │ • OAuth │ │ • XRPC calls │ │ • generate │ │ 1697 - │ │ flow │ │ • Create │ │ • match │ │ 1698 - │ │ • Session │ │ • List │ └────────────┘ │ 1699 - │ │ mgmt │ │ • Delete │ ┌────────────┐ │ 1700 - │ └─────────────┘ └──────────────┘ │highlights/ │ │ 1701 - │ │ │ │ 1702 - │ │ • apply │ │ 1703 - │ │ • popover │ │ 1704 - │ └────────────┘ │ 1705 - └──────────────────┬──────────────────────────────────┘ 1706 - │ XRPC 1707 - 1708 - ┌──────────────────────────────────────────────────────┐ 1709 - │ AT Protocol Infrastructure │ 1710 - │ │ 1711 - │ ┌──────────────────┐ ┌────────────────────┐ │ 1712 - │ │ PDS (bsky.social)│ │ slices.network │ │ 1713 - │ │ │ │ │ │ 1714 - │ │ • OAuth server │────────▶│ • GraphQL API │ │ 1715 - │ │ • Record storage │ indexes │ • Annotation feed │ │ 1716 - │ └──────────────────┘ └────────────────────┘ │ 1717 - └─────────────────────────────────────────────────┬────┘ 1718 - 1719 - │ GraphQL 1720 - 1721 - ┌────────────────┐ 1722 - │ Landing Page │ 1723 - │ (seams.so) │ 1724 - │ │ 1725 - │ • Public feed │ 1726 - │ • Avatar fetch │ 1727 - │ • Text frags │ 1728 - └────────────────┘ 1729 - ``` 1730 - 1731 - --- 1732 - 1733 - This documentation represents the **current state** of the codebase as of the analysis. For the planned refactoring to event-driven architecture, see [`STATE_REFACTOR_PLAN.md`](file:///home/anish/usr/seams.so/STATE_REFACTOR_PLAN.md).
-512
STATE_REFACTOR_PLAN.md
··· 1 - # State Management Refactoring Plan 2 - 3 - ## Problem Statement 4 - 5 - The current extension architecture relies on fragile, on-demand message passing from the sidepanel to the content script to fetch the current URL and selection state. This leads to: 6 - 7 - - **Race conditions**: Sidepanel tries to pull state from content script on load (may not be ready) 8 - - **Empty source URLs**: Annotations saved with empty `source` field 9 - - **No SPA navigation tracking**: URL changes via pushState/replaceState aren't detected 10 - - **Fragmented state**: Each component maintains its own copy of url/selection state 11 - 12 - ## Current Architecture 13 - 14 - ``` 15 - Sidepanel (pulls) → Content Script 16 - - On mount: browser.tabs.sendMessage({ type: 'GET_SELECTION' }) 17 - - Content may not be ready 18 - - No proactive updates 19 - ``` 20 - 21 - ## Target Architecture (Oracle-Approved) 22 - 23 - ``` 24 - Content Script → Background (State Manager) ⇄ Sidepanel 25 - 26 - storage.session (persistence) 27 - ``` 28 - 29 - ### Key Principles 30 - 31 - 1. **Event-driven**: Content proactively pushes state updates 32 - 2. **MV3-resilient**: State persists in `chrome.storage.session` to survive service worker suspension 33 - 3. **Port-based communication**: Long-lived connections keep SW alive and prevent cross-tab leakage 34 - 4. **Background-managed URL tracking**: Use browser APIs (`tabs.onUpdated`, `webNavigation.onHistoryStateUpdated`) 35 - 5. **Debounced selection**: Avoid spam from `selectionchange` events 36 - 6. **Timestamped updates**: Prevent race conditions with `updatedAt` field 37 - 38 - ## Implementation Plan 39 - 40 - ### 1. Add webNavigation Permission 41 - 42 - **File**: `wxt.config.ts` or manifest 43 - 44 - ```typescript 45 - permissions: [ 46 - 'storage', 47 - 'contextMenus', 48 - 'sidePanel', 49 - 'webNavigation', // ADD THIS 50 - ] 51 - ``` 52 - 53 - ### 2. Background Script State Manager 54 - 55 - **File**: `entrypoints/background.ts` 56 - 57 - #### State Shape 58 - ```typescript 59 - interface TabState { 60 - url: string; 61 - title: string; 62 - selection: { text: string; selectors: Selector[] } | null; 63 - updatedAt: number; // Date.now() 64 - } 65 - 66 - // In-memory cache 67 - const tabStateCache = new Map<number, TabState>(); 68 - 69 - // Port registry 70 - const sidepanelPorts = new Map<number, chrome.runtime.Port>(); // tabId → port 71 - ``` 72 - 73 - #### Storage Session Integration 74 - - Mirror `tabStateCache` to `chrome.storage.session` 75 - - Throttle writes to ≤1/sec per tab 76 - - On SW restart: restore from `storage.session` 77 - 78 - #### Event Listeners 79 - 80 - **tabs.onUpdated** (URL/title changes) 81 - ```typescript 82 - browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 83 - if (changeInfo.url || changeInfo.title) { 84 - updateTabState(tabId, { 85 - url: tab.url, 86 - title: tab.title, 87 - selection: null, // Clear selection on navigation 88 - updatedAt: Date.now(), 89 - }); 90 - 91 - // Clear highlights on content script 92 - browser.tabs.sendMessage(tabId, { type: 'CLEAR_HIGHLIGHTS' }).catch(() => {}); 93 - 94 - // Notify sidepanel 95 - broadcastToSidepanel(tabId); 96 - } 97 - }); 98 - ``` 99 - 100 - **webNavigation.onHistoryStateUpdated** (SPA navigation) 101 - ```typescript 102 - browser.webNavigation.onHistoryStateUpdated.addListener((details) => { 103 - if (details.frameId === 0) { // Top frame only 104 - updateTabState(details.tabId, { 105 - url: details.url, 106 - selection: null, 107 - updatedAt: Date.now(), 108 - }); 109 - 110 - browser.tabs.sendMessage(details.tabId, { type: 'CLEAR_HIGHLIGHTS' }).catch(() => {}); 111 - broadcastToSidepanel(details.tabId); 112 - } 113 - }); 114 - ``` 115 - 116 - **tabs.onRemoved** (cleanup) 117 - ```typescript 118 - browser.tabs.onRemoved.addListener((tabId) => { 119 - tabStateCache.delete(tabId); 120 - sidepanelPorts.delete(tabId); 121 - browser.storage.session.remove(`tab_${tabId}`); 122 - }); 123 - ``` 124 - 125 - #### Message Handlers 126 - 127 - **TAB_STATE_UPDATE** (from content script) 128 - ```typescript 129 - // Content sends: { type: 'TAB_STATE_UPDATE', url, title, selection, updatedAt } 130 - if (message.type === 'TAB_STATE_UPDATE') { 131 - const tabId = sender.tab?.id; 132 - if (!tabId) return; 133 - 134 - const currentState = tabStateCache.get(tabId); 135 - 136 - // Last-write-wins: only accept if newer 137 - if (!currentState || message.updatedAt > currentState.updatedAt) { 138 - updateTabState(tabId, message); 139 - broadcastToSidepanel(tabId); 140 - } 141 - 142 - sendResponse({ success: true }); 143 - return true; 144 - } 145 - ``` 146 - 147 - **GET_TAB_STATE** (from sidepanel) 148 - ```typescript 149 - // Sidepanel sends: { type: 'GET_TAB_STATE', tabId } 150 - if (message.type === 'GET_TAB_STATE') { 151 - const tabId = message.tabId; 152 - let state = tabStateCache.get(tabId); 153 - 154 - if (!state) { 155 - // Try storage.session 156 - const stored = await browser.storage.session.get(`tab_${tabId}`); 157 - state = stored[`tab_${tabId}`]; 158 - } 159 - 160 - if (!state) { 161 - // Cold start: request snapshot from content 162 - try { 163 - const snapshot = await browser.tabs.sendMessage(tabId, { 164 - type: 'GET_TAB_SNAPSHOT' 165 - }); 166 - state = { ...snapshot, updatedAt: Date.now() }; 167 - updateTabState(tabId, state); 168 - } catch (error) { 169 - // Content script not available (restricted page, PDF, etc.) 170 - sendResponse({ 171 - success: false, 172 - error: 'Content not available on this page' 173 - }); 174 - return true; 175 - } 176 - } 177 - 178 - sendResponse({ success: true, state }); 179 - return true; 180 - } 181 - ``` 182 - 183 - #### Port Management 184 - 185 - **Sidepanel connection** 186 - ```typescript 187 - browser.runtime.onConnect.addListener((port) => { 188 - if (port.name === 'sidepanel') { 189 - port.onMessage.addListener((message) => { 190 - if (message.type === 'REGISTER_TAB') { 191 - sidepanelPorts.set(message.tabId, port); 192 - } 193 - }); 194 - 195 - port.onDisconnect.addListener(() => { 196 - // Remove from registry 197 - for (const [tabId, p] of sidepanelPorts.entries()) { 198 - if (p === port) { 199 - sidepanelPorts.delete(tabId); 200 - break; 201 - } 202 - } 203 - }); 204 - } 205 - }); 206 - ``` 207 - 208 - #### Helper Functions 209 - 210 - ```typescript 211 - function updateTabState(tabId: number, updates: Partial<TabState>) { 212 - const current = tabStateCache.get(tabId) || {} as TabState; 213 - const newState = { ...current, ...updates }; 214 - tabStateCache.set(tabId, newState); 215 - 216 - // Throttled write to storage.session (implement throttle) 217 - throttledSaveToSession(tabId, newState); 218 - } 219 - 220 - function broadcastToSidepanel(tabId: number) { 221 - const port = sidepanelPorts.get(tabId); 222 - const state = tabStateCache.get(tabId); 223 - 224 - if (port && state) { 225 - port.postMessage({ 226 - type: 'TAB_STATE_CHANGED', 227 - tabId, 228 - state, 229 - }); 230 - } 231 - } 232 - ``` 233 - 234 - ### 3. Content Script Refactor 235 - 236 - **File**: `entrypoints/content.ts` 237 - 238 - #### Debounced Selection Tracking 239 - 240 - ```typescript 241 - let selectionDebounceTimer: number | null = null; 242 - const SELECTION_DEBOUNCE_MS = 250; 243 - 244 - document.addEventListener('selectionchange', () => { 245 - if (selectionDebounceTimer) { 246 - clearTimeout(selectionDebounceTimer); 247 - } 248 - 249 - selectionDebounceTimer = setTimeout(() => { 250 - handleSelectionChange(); 251 - }, SELECTION_DEBOUNCE_MS); 252 - }); 253 - 254 - // Also handle immediate updates on mouseup/keyup 255 - document.addEventListener('mouseup', handleSelectionChange); 256 - document.addEventListener('keyup', handleSelectionChange); 257 - 258 - function handleSelectionChange() { 259 - const selection = window.getSelection(); 260 - 261 - if (!selection || selection.toString().trim().length === 0) { 262 - sendStateUpdate(null); 263 - currentSelection = null; 264 - return; 265 - } 266 - 267 - // Ignore selections in editable fields 268 - const activeElement = document.activeElement; 269 - if (activeElement && ( 270 - activeElement.tagName === 'INPUT' || 271 - activeElement.tagName === 'TEXTAREA' || 272 - activeElement.hasAttribute('contenteditable') 273 - )) { 274 - return; 275 - } 276 - 277 - const text = selection.toString().trim(); 278 - const root = document.querySelector('main') || document.querySelector('article') || document.body; 279 - const selectors = generateSelectors(selection, root); 280 - 281 - currentSelection = { text, selectors }; 282 - sendStateUpdate(currentSelection); 283 - } 284 - 285 - function sendStateUpdate(selection: { text: string; selectors: Selector[] } | null) { 286 - browser.runtime.sendMessage({ 287 - type: 'TAB_STATE_UPDATE', 288 - url: window.location.href, 289 - title: document.title, 290 - selection, 291 - updatedAt: Date.now(), 292 - }).catch(() => { 293 - // Background might not be ready 294 - }); 295 - } 296 - ``` 297 - 298 - #### Initial State Push 299 - 300 - ```typescript 301 - // On page load (document_idle is default for WXT) 302 - sendStateUpdate(null); // Initial state with no selection 303 - ``` 304 - 305 - #### Snapshot Responder 306 - 307 - ```typescript 308 - browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 309 - if (message.type === 'GET_TAB_SNAPSHOT') { 310 - sendResponse({ 311 - url: window.location.href, 312 - title: document.title, 313 - selection: currentSelection, 314 - }); 315 - return true; 316 - } 317 - 318 - // Keep existing handlers: UPDATE_HIGHLIGHTS, CLEAR_HIGHLIGHTS 319 - // ... 320 - }); 321 - ``` 322 - 323 - #### Remove Old GET_SELECTION Handler 324 - 325 - ```typescript 326 - // DELETE THIS: 327 - // if (message.type === 'GET_SELECTION') { 328 - // sendResponse({ 329 - // selection: currentSelection, 330 - // url: window.location.href, 331 - // title: document.title 332 - // }); 333 - // return true; 334 - // } 335 - ``` 336 - 337 - ### 4. Sidepanel Refactor 338 - 339 - **File**: `entrypoints/sidepanel/main.ts` 340 - 341 - #### Port Connection & Tab Registration 342 - 343 - ```typescript 344 - let currentTabId: number | null = null; 345 - let backgroundPort: chrome.runtime.Port | null = null; 346 - 347 - // On mount 348 - (async function() { 349 - // Get current tab 350 - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 351 - currentTabId = tabs[0]?.id || null; 352 - 353 - if (!currentTabId) { 354 - console.error('No active tab found'); 355 - return; 356 - } 357 - 358 - // Connect to background via port 359 - backgroundPort = browser.runtime.connect({ name: 'sidepanel' }); 360 - 361 - // Register this sidepanel with the tab 362 - backgroundPort.postMessage({ 363 - type: 'REGISTER_TAB', 364 - tabId: currentTabId, 365 - }); 366 - 367 - // Request initial state 368 - browser.runtime.sendMessage({ 369 - type: 'GET_TAB_STATE', 370 - tabId: currentTabId, 371 - }).then(response => { 372 - if (response.success) { 373 - updateUIState(response.state); 374 - } else { 375 - // Show friendly message for restricted pages 376 - showRestrictedPageMessage(response.error); 377 - } 378 - }); 379 - 380 - // Listen for state changes via port 381 - backgroundPort.onMessage.addListener((message) => { 382 - if (message.type === 'TAB_STATE_CHANGED' && message.tabId === currentTabId) { 383 - updateUIState(message.state); 384 - } 385 - }); 386 - })(); 387 - 388 - function updateUIState(state: { url: string; title: string; selection: any }) { 389 - // Update currentUrl 390 - if (currentUrl !== state.url) { 391 - currentUrl = state.url; 392 - loadAnnotations(currentUrl); // Reload annotations for new URL 393 - } 394 - 395 - // Update currentSelection 396 - currentSelection = state.selection; 397 - 398 - if (currentSelection && currentSelection.text && selectedTextEl) { 399 - selectedTextEl.innerHTML = `<blockquote>${currentSelection.text}</blockquote>`; 400 - if (annotationForm) annotationForm.style.display = 'block'; 401 - } else { 402 - if (annotationForm) annotationForm.style.display = 'none'; 403 - } 404 - } 405 - 406 - function showRestrictedPageMessage(error: string) { 407 - if (annotationsContainer) { 408 - annotationsContainer.innerHTML = ` 409 - <p class="error">This page doesn't support annotations.</p> 410 - <p class="error-detail">${error}</p> 411 - `; 412 - } 413 - if (annotationForm) annotationForm.style.display = 'none'; 414 - } 415 - ``` 416 - 417 - #### Remove Old Initialization Code 418 - 419 - ```typescript 420 - // DELETE THIS (lines 187-207): 421 - // browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 422 - // if (tabs[0]?.id) { 423 - // browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }).then(response => { 424 - // currentUrl = response.url; 425 - // currentSelection = response.selection; 426 - // // ... 427 - // }).catch(err => { 428 - // console.error('Failed to get selection:', err); 429 - // }); 430 - // } 431 - // }); 432 - ``` 433 - 434 - #### Keep SELECTION_CHANGED Listener (for backward compatibility during transition) 435 - 436 - ```typescript 437 - // Can remove this once background state is fully working 438 - browser.runtime.onMessage.addListener((message) => { 439 - if (message.type === 'SELECTION_CHANGED') { 440 - // Deprecated: background should handle this now 441 - console.warn('Deprecated SELECTION_CHANGED message received'); 442 - } 443 - }); 444 - ``` 445 - 446 - ## Message Protocol Summary 447 - 448 - ### Content → Background 449 - - **TAB_STATE_UPDATE**: `{ type, url, title, selection, updatedAt }` 450 - 451 - ### Sidepanel → Background 452 - - **GET_TAB_STATE**: `{ type, tabId }` 453 - - **REGISTER_TAB** (via port): `{ type, tabId }` 454 - 455 - ### Background → Sidepanel 456 - - **TAB_STATE_CHANGED** (via port): `{ type, tabId, state: { url, title, selection, updatedAt } }` 457 - 458 - ### Background → Content 459 - - **GET_TAB_SNAPSHOT**: `{ type }` → Response: `{ url, title, selection }` 460 - - **CLEAR_HIGHLIGHTS**: `{ type }` (on URL change) 461 - 462 - ## Testing Checklist 463 - 464 - - [ ] Initial page load: sidepanel shows correct URL and no selection 465 - - [ ] Text selection: sidepanel updates within 250ms 466 - - [ ] Clear selection: sidepanel hides annotation form 467 - - [ ] SPA navigation (pushState): URL updates, selection clears, highlights clear 468 - - [ ] Traditional navigation: URL updates, selection clears 469 - - [ ] Service worker suspend/resume: state persists via storage.session 470 - - [ ] Restricted pages (chrome://, PDFs): friendly error message shown 471 - - [ ] Multi-tab: each sidepanel only receives updates for its tab 472 - - [ ] Selections in input/textarea: ignored 473 - - [ ] Rapid selection changes: debounced correctly 474 - 475 - ## Rollout Strategy 476 - 477 - 1. **Phase 1**: Add webNavigation permission and background state manager 478 - 2. **Phase 2**: Update content script with debouncing and snapshot responder 479 - 3. **Phase 3**: Update sidepanel to use ports and new protocol 480 - 4. **Phase 4**: Remove deprecated GET_SELECTION handler and SELECTION_CHANGED broadcasts 481 - 5. **Phase 5**: Test thoroughly, especially SPA navigation and service worker suspension 482 - 483 - ## Performance Considerations 484 - 485 - - **Debouncing**: 250ms prevents excessive message spam 486 - - **Throttled storage writes**: Max 1 write/sec per tab to `storage.session` 487 - - **Selector generation**: Only on stable selections (after debounce or pointer events) 488 - - **Port-based communication**: Keeps SW alive, avoids message routing overhead 489 - 490 - ## Edge Cases Handled 491 - 492 - 1. **Service worker suspension**: State persists in `storage.session`, restored on wake 493 - 2. **Content script not ready**: Background falls back to snapshot request 494 - 3. **Restricted pages**: Graceful error message shown to user 495 - 4. **Stale updates**: Timestamped updates prevent out-of-order state changes 496 - 5. **Cross-tab contamination**: Port-based delivery ensures tab isolation 497 - 6. **Editable fields**: Selections inside inputs/textareas are ignored 498 - 7. **Multi-frame pages**: Only top frame (frameId === 0) is tracked 499 - 500 - ## Future Enhancements (Out of Scope) 501 - 502 - - Multi-frame annotation support (include frameId in state) 503 - - Cross-session recovery (persist to `storage.local`) 504 - - Incremental selector generation for performance 505 - - Multi-tab sidepanel support with dynamic tab switching 506 - 507 - ## References 508 - 509 - - [Chrome Extension Messaging](https://developer.chrome.com/docs/extensions/mv3/messaging/) 510 - - [Service Worker Lifecycle](https://developer.chrome.com/docs/extensions/mv3/service_workers/) 511 - - [chrome.storage.session API](https://developer.chrome.com/docs/extensions/reference/storage/#property-session) 512 - - [webNavigation API](https://developer.chrome.com/docs/extensions/reference/webNavigation/)