diff --git a/manifest.json b/manifest.json index 2f2ddf3..6dd2a4f 100644 --- a/manifest.json +++ b/manifest.json @@ -77,11 +77,14 @@ }, "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + // Note: 'wasm-unsafe-eval' is required for Pubky SDK WebAssembly support }, "web_accessible_resources": [ { "resources": ["src/profile/profile-renderer.html"], "matches": [""] + // Note: Profile renderer needs to be accessible from any page to display + // pubky:// URLs. This is a core feature requirement. } ] } diff --git a/src/background/background.ts b/src/background/background.ts index 3decbf5..e5236d3 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -64,6 +64,42 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sendResponse({ success: true }); } + if (message.type === 'OPEN_SIDE_PANEL_FOR_ANNOTATION') { + // Open sidepanel in response to annotation click (user gesture preserved) + // Must call sidePanel.open() immediately to preserve user gesture context + const tabId = sender.tab?.id; + if (tabId) { + chrome.sidePanel.open({ tabId }, () => { + if (chrome.runtime.lastError) { + logger.error('Background', 'Failed to open sidepanel', new Error(chrome.runtime.lastError.message)); + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + logger.info('Background', 'Sidepanel opened for annotation', { + annotationId: message.annotationId, + tabId + }); + + // Send scroll message after a short delay to allow sidepanel to load + setTimeout(() => { + chrome.runtime.sendMessage({ + type: 'SCROLL_TO_ANNOTATION', + annotationId: message.annotationId, + }).catch(() => { + // Sidepanel might not be ready yet, that's okay + logger.debug('Background', 'Sidepanel not ready for scroll message yet'); + }); + }, 300); + + sendResponse({ success: true }); + } + }); + } else { + logger.warn('Background', 'No tab ID available for opening sidepanel'); + sendResponse({ success: false, error: 'No tab ID available' }); + } + return true; // Keep message channel open for async response + } + if (message.type === MESSAGE_TYPES.CREATE_ANNOTATION) { // Handle annotation creation handleCreateAnnotation(message.annotation) @@ -643,30 +679,25 @@ chrome.webNavigation.onBeforeNavigate.addListener((details) => { // Handle keyboard commands // NOTE: Must NOT use async/await here to preserve user gesture context -console.log('[Graphiti] Registering keyboard command listener'); +logger.info('Background', 'Registering keyboard command listener'); chrome.commands.onCommand.addListener((command) => { - // Use console.log directly for immediate visibility in service worker console - console.log('[Graphiti] Command received:', command); logger.info('Background', 'Command received', { command }); if (command === COMMAND_NAMES.TOGGLE_SIDEPANEL) { - console.log('[Graphiti] toggle-sidepanel command triggered'); + logger.info('Background', 'toggle-sidepanel command triggered'); // Open the side panel - must call open() immediately to preserve user gesture // Using windowId instead of tabId since it's available synchronously chrome.windows.getCurrent((window) => { if (!window?.id) { - console.warn('[Graphiti] No current window found'); logger.warn('Background', 'No current window found for side panel toggle'); return; } - console.log('[Graphiti] Opening side panel for window:', window.id); + logger.info('Background', 'Opening side panel', { windowId: window.id }); chrome.sidePanel.open({ windowId: window.id }, () => { if (chrome.runtime.lastError) { - console.error('[Graphiti] Failed to open:', chrome.runtime.lastError.message); logger.error('Background', 'Failed to open side panel', new Error(chrome.runtime.lastError.message)); } else { - console.log('[Graphiti] Side panel opened successfully'); logger.info('Background', 'Side panel opened via keyboard shortcut', { windowId: window.id }); } }); @@ -674,23 +705,21 @@ chrome.commands.onCommand.addListener((command) => { } if (command === COMMAND_NAMES.OPEN_ANNOTATIONS) { - console.log('[Graphiti] open-annotations command triggered'); + logger.info('Background', 'open-annotations command triggered'); // Open side panel and switch to annotations tab // Must call open() immediately to preserve user gesture chrome.windows.getCurrent((window) => { if (!window?.id) { - console.warn('[Graphiti] No current window found'); logger.warn('Background', 'No current window found for annotations'); return; } - console.log('[Graphiti] Opening side panel for annotations, window:', window.id); + logger.info('Background', 'Opening side panel for annotations', { windowId: window.id }); chrome.sidePanel.open({ windowId: window.id }, () => { if (chrome.runtime.lastError) { - console.error('[Graphiti] Failed to open:', chrome.runtime.lastError.message); logger.error('Background', 'Failed to open annotations', new Error(chrome.runtime.lastError.message)); } else { - console.log('[Graphiti] Side panel opened, switching to annotations tab...'); + logger.info('Background', 'Side panel opened, switching to annotations tab'); // Send message to sidebar to switch to annotations tab setTimeout(() => { chrome.runtime.sendMessage({ @@ -709,19 +738,18 @@ chrome.commands.onCommand.addListener((command) => { } if (command === COMMAND_NAMES.TOGGLE_DRAWING) { - console.log('[Graphiti] toggle-drawing command triggered'); + logger.info('Background', 'toggle-drawing command triggered'); // Toggle drawing mode on the current tab chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { const tab = tabs[0]; - console.log('[Graphiti] Active tab for drawing:', tab?.id, tab?.url); + logger.info('Background', 'Active tab for drawing', { tabId: tab?.id, url: tab?.url }); if (tab?.id && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:') && !tab.url.startsWith('chrome-extension://')) { - console.log('[Graphiti] Sending TOGGLE_DRAWING_MODE to tab', tab.id); + logger.info('Background', 'Sending TOGGLE_DRAWING_MODE to tab', { tabId: tab.id }); chrome.tabs.sendMessage(tab.id, { type: 'TOGGLE_DRAWING_MODE', }, (response) => { if (chrome.runtime.lastError) { - console.error('[Graphiti] Drawing mode error:', chrome.runtime.lastError.message); logger.error('Background', 'Failed to toggle drawing mode - content script may not be ready', new Error(chrome.runtime.lastError.message)); // Try to notify user chrome.notifications?.create({ @@ -731,7 +759,6 @@ chrome.commands.onCommand.addListener((command) => { message: 'Please refresh the page to use drawing mode on this site.' }); } else { - console.log('[Graphiti] Drawing mode toggled successfully:', response?.active); logger.info('Background', 'Drawing mode toggled via keyboard shortcut', { tabId: tab.id, active: response?.active @@ -739,7 +766,6 @@ chrome.commands.onCommand.addListener((command) => { } }); } else { - console.warn('[Graphiti] Cannot use drawing mode on this page:', tab?.url); logger.warn('Background', 'Cannot use drawing mode on this page', { url: tab?.url }); chrome.notifications?.create({ type: 'basic', @@ -752,7 +778,7 @@ chrome.commands.onCommand.addListener((command) => { } }); -console.log('[Graphiti] Background script command listeners registered'); +logger.info('Background', 'Command listeners registered'); // Handle errors self.addEventListener('error', (event) => { diff --git a/src/config/config.ts b/src/config/config.ts index f67f781..62c1bdc 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -46,7 +46,8 @@ const DEFAULT_CONFIG: AppConfig = { function loadConfig(): AppConfig { // Vite exposes env variables via import.meta.env // In Vite projects, import.meta.env is always available at build time - // @ts-ignore - import.meta is a Vite feature + // @ts-ignore - import.meta is a Vite feature (not in standard TypeScript lib) + // This is safe as Vite transforms this at build time const viteEnv = (globalThis as any).import?.meta?.env || (typeof window !== 'undefined' && (window as any).__VITE_ENV__) || {}; diff --git a/src/content/AnnotationManager.ts b/src/content/AnnotationManager.ts index 1f8e1ce..b155fd9 100644 --- a/src/content/AnnotationManager.ts +++ b/src/content/AnnotationManager.ts @@ -1,5 +1,7 @@ import { contentLogger as logger } from './logger'; -// @ts-ignore - No type definitions available +// @ts-ignore - dom-anchor-text-quote lacks TypeScript definitions +// Tracking: https://github.com/nicksellen/dom-anchor-text-quote +// TODO: Create PR with type definitions or find alternative library import * as textQuote from 'dom-anchor-text-quote'; import { validateSelectedText, diff --git a/src/content/PubkyURLHandler.ts b/src/content/PubkyURLHandler.ts index bd0b341..1445d1f 100644 --- a/src/content/PubkyURLHandler.ts +++ b/src/content/PubkyURLHandler.ts @@ -2,6 +2,8 @@ import { contentLogger as logger } from './logger'; import DOMPurify from 'dompurify'; export class PubkyURLHandler { + private domObserver: MutationObserver | null = null; + constructor() { this.init(); } @@ -202,7 +204,12 @@ export class PubkyURLHandler { private observeDOMForPubkyURLs() { let isProcessing = false; - const observer = new MutationObserver((mutations) => { + // Disconnect existing observer if any + if (this.domObserver) { + this.domObserver.disconnect(); + } + + this.domObserver = new MutationObserver((mutations) => { const isOurMutation = mutations.some(mutation => { return Array.from(mutation.addedNodes).some(node => { if (node.nodeType === Node.ELEMENT_NODE) { @@ -232,10 +239,22 @@ export class PubkyURLHandler { } }); - observer.observe(document.body, { + this.domObserver.observe(document.body, { childList: true, subtree: true, }); } + + /** + * Cleanup method to disconnect observer and remove event listeners + * Should be called when the handler is no longer needed + */ + cleanup(): void { + if (this.domObserver) { + this.domObserver.disconnect(); + this.domObserver = null; + } + document.removeEventListener('click', this.handleClick, true); + } } diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index 8ee1573..858accb 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -11,6 +11,7 @@ */ import { storage } from '../utils/storage'; +import { logger } from '../utils/logger'; // Import Pubky SDK types type Client = any; @@ -52,18 +53,18 @@ class OffscreenHandler { */ private async initialize(): Promise { try { - console.log('[Graphiti Offscreen] Initializing Pubky client...'); + logger.info('Offscreen', 'Initializing Pubky client'); const { getPubkyClientAsync } = await import('../utils/pubky-client-factory'); this.client = await getPubkyClientAsync(); this.isInitialized = true; - console.log('[Graphiti Offscreen] Pubky client initialized successfully'); + logger.info('Offscreen', 'Pubky client initialized successfully'); // Set up message listener this.setupMessageListener(); } catch (error) { - console.error('[Graphiti Offscreen] Failed to initialize Pubky client:', error); + logger.error('Offscreen', 'Failed to initialize Pubky client', error as Error); } } @@ -90,20 +91,20 @@ class OffscreenHandler { return false; } - console.log('[Graphiti Offscreen] Received message:', message.type); + logger.info('Offscreen', 'Received message', { type: message.type }); // Handle async operations this.handleMessage(message) .then(sendResponse) .catch((error) => { - console.error('[Graphiti Offscreen] Error handling message:', error); + logger.error('Offscreen', 'Error handling message', error as Error); sendResponse({ success: false, error: error.message }); }); return true; // Keep channel open for async response }); - console.log('[Graphiti Offscreen] Message listener registered'); + logger.info('Offscreen', 'Message listener registered'); } /** @@ -201,11 +202,11 @@ class OffscreenHandler { }); } - console.log('[Graphiti Offscreen] Annotation synced:', fullPath); + logger.info('Offscreen', 'Annotation synced', { fullPath }); return { success: true, data: { postUri: fullPath } }; } catch (error) { - console.error('[Graphiti Offscreen] Failed to sync annotation:', error); + logger.error('Offscreen', 'Failed to sync annotation', error as Error); return { success: false, error: (error as Error).message }; } } @@ -259,11 +260,11 @@ class OffscreenHandler { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - console.log('[Graphiti Offscreen] Drawing synced:', fullPath); + logger.info('Offscreen', 'Drawing synced', { fullPath }); return { success: true, data: { pubkyUrl: fullPath } }; } catch (error) { - console.error('[Graphiti Offscreen] Failed to sync drawing:', error); + logger.error('Offscreen', 'Failed to sync drawing', error as Error); return { success: false, error: (error as Error).message }; } } @@ -335,14 +336,14 @@ class OffscreenHandler { } } - console.log('[Graphiti Offscreen] Sync complete:', { annotationsSynced, drawingsSynced }); + logger.info('Offscreen', 'Sync complete', { annotationsSynced, drawingsSynced }); return { success: true, data: { annotationsSynced, drawingsSynced } }; } catch (error) { - console.error('[Graphiti Offscreen] Failed to sync all pending:', error); + logger.error('Offscreen', 'Failed to sync all pending', error as Error); return { success: false, error: (error as Error).message }; } } @@ -385,7 +386,7 @@ class OffscreenHandler { } }; } catch (error) { - console.error('[Graphiti Offscreen] Failed to get sync status:', error); + logger.error('Offscreen', 'Failed to get sync status', error as Error); return { success: false, error: (error as Error).message }; } } @@ -394,5 +395,5 @@ class OffscreenHandler { // Initialize the offscreen handler new OffscreenHandler(); -console.log('[Graphiti Offscreen] Offscreen document loaded'); +logger.info('Offscreen', 'Offscreen document loaded'); diff --git a/src/sidepanel/App.tsx b/src/sidepanel/App.tsx index b32ac92..cc686f3 100644 --- a/src/sidepanel/App.tsx +++ b/src/sidepanel/App.tsx @@ -25,9 +25,11 @@ function App() { const [hasMorePosts, setHasMorePosts] = useState(true); const [postsPage, setPostsPage] = useState(0); // @ts-ignore - postsCursor is set for future cursor-based pagination + // This will be used when implementing cursor-based pagination for large feeds const [postsCursor, setPostsCursor] = useState(undefined); const POSTS_PER_PAGE = 20; const sentinelRef = useRef(null); + const [highlightedAnnotationId, setHighlightedAnnotationId] = useState(null); useEffect(() => { initializePanel(); @@ -87,8 +89,9 @@ function App() { // Listen for messages to scroll to annotations or switch tabs const handleMessage = (message: any) => { if (message.type === 'SCROLL_TO_ANNOTATION') { + logger.info('SidePanel', 'Scroll to annotation requested', { annotationId: message.annotationId }); setActiveTab('annotations'); - // Scroll logic will be handled in the render + setHighlightedAnnotationId(message.annotationId); } if (message.type === 'SWITCH_TO_ANNOTATIONS') { @@ -103,6 +106,26 @@ function App() { }; }, []); + // Scroll to annotation when it's loaded and highlighted + useEffect(() => { + if (highlightedAnnotationId && annotations.length > 0) { + // Wait for DOM to update, then scroll + setTimeout(() => { + const annotationElement = document.querySelector(`[data-annotation-id="${highlightedAnnotationId}"]`); + if (annotationElement) { + annotationElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Add a highlight class for visual feedback + annotationElement.classList.add('ring-2', 'ring-[#667eea]', 'ring-offset-2', 'ring-offset-[#1F1F1F]'); + // Remove highlight after a few seconds + setTimeout(() => { + annotationElement.classList.remove('ring-2', 'ring-[#667eea]', 'ring-offset-2', 'ring-offset-[#1F1F1F]'); + setHighlightedAnnotationId(null); + }, 3000); + } + }, 100); + } + }, [highlightedAnnotationId, annotations]); + // Keyboard shortcut listener for Shift+? useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/types/pubky.d.ts b/src/types/pubky.d.ts new file mode 100644 index 0000000..b178f97 --- /dev/null +++ b/src/types/pubky.d.ts @@ -0,0 +1,116 @@ +/** + * TypeScript type definitions for @synonymdev/pubky SDK + * + * These definitions provide type safety for the Pubky SDK. + * Update as needed when SDK types are officially exported. + */ + +declare module '@synonymdev/pubky' { + export type Client = any; + export type AuthRequest = any; + export type PublicKey = any; + /** + * Main Pubky Client class + */ + /** + * Main Pubky Client class + */ + export class Client { + constructor(); + + /** + * Fetch data from a Pubky path + */ + fetch(path: string, init?: RequestInit): Promise; + + /** + * List items at a path + */ + list( + path: string, + cursor?: unknown, + recursive?: boolean, + limit?: number + ): Promise; + + /** + * Create an authentication request + */ + authRequest(relay: string, capabilities: string): AuthRequest; + + /** + * Put data to a path + */ + put(path: string, data: unknown): Promise; + + /** + * Delete data at a path + */ + delete(path: string): Promise; + } + + /** + * Testnet Client (if available) + */ + export class TestnetClient extends Client {} + + /** + * Authentication request object + */ + export interface AuthRequest { + /** + * Get the authentication token + */ + token(): string; + + /** + * Get the authorization URL (pubkyauth:// URL) + */ + url(): string; + + /** + * Wait for user approval and get the response (PublicKey) + */ + response(): Promise; + } + + /** + * Public Key object + */ + export interface PublicKey { + /** + * Get z32 encoded public key + */ + z32(): string; + } + + /** + * Session interface + */ + export interface Session { + publicKey: string; + capabilities: string[]; + } + + /** + * Validate capabilities string format + * @param capabilities - Capabilities string to validate + * @returns Validated capabilities string + * @throws Error if capabilities are invalid + */ + export function validateCapabilities(capabilities: string): string; + + /** + * Set logging level for SDK + * @param level - Log level: 'debug' | 'info' | 'warn' | 'error' + */ + export function setLogLevel(level: 'debug' | 'info' | 'warn' | 'error'): void; + + /** + * Create a recovery file for key backup + * @param passphrase - User-provided passphrase for encryption + * @returns Promise resolving to recovery file data + */ + export function createRecoveryFile(passphrase: string): Promise; +} + diff --git a/src/utils/auth-sdk.ts b/src/utils/auth-sdk.ts index 7fae3c4..da7386b 100644 --- a/src/utils/auth-sdk.ts +++ b/src/utils/auth-sdk.ts @@ -7,9 +7,8 @@ import { getPubkyClientAsync } from './pubky-client-factory'; * Pubky authentication using official @synonymdev/pubky SDK */ -// Import types from SDK -type Client = any; -type AuthRequest = any; +// Import types from SDK type definitions +import type { Client, AuthRequest } from '@synonymdev/pubky'; const REQUIRED_CAPABILITIES = '/pub/pubky.app/:rw'; const RELAY_URL = 'https://httprelay.pubky.app/link/'; diff --git a/src/utils/pubky-client-factory.ts b/src/utils/pubky-client-factory.ts index 464f93b..e828b1c 100644 --- a/src/utils/pubky-client-factory.ts +++ b/src/utils/pubky-client-factory.ts @@ -7,6 +7,7 @@ import { logger } from './logger'; +// Type for Pubky Client (using any until SDK exports proper types) type Client = any; let clientInstance: Client | null = null; @@ -37,7 +38,20 @@ export async function initializePubkyClient(): Promise { } initializationPromise = (async () => { - const { Client } = await import('@synonymdev/pubky'); + const pubkyModule = await import('@synonymdev/pubky'); + // SDK exports Client as default or named export - handle both + const Client = (pubkyModule as any).Client || (pubkyModule as any).default?.Client || pubkyModule.default; + + // Configure log level if available + if ('setLogLevel' in pubkyModule && typeof (pubkyModule as any).setLogLevel === 'function') { + const setLogLevel = (pubkyModule as any).setLogLevel; + if (process.env.NODE_ENV === 'production') { + setLogLevel('error'); + } else { + setLogLevel('debug'); + } + } + clientInstance = new Client(); logger.info('PubkyClientFactory', 'Pubky Client singleton initialized'); return clientInstance; @@ -49,10 +63,46 @@ export async function initializePubkyClient(): Promise { /** * Get or create the singleton Pubky Client instance (async version) * Use this when you need to ensure the SDK is fully loaded + * @param useTestnet - If true, use TestnetClient instead of Client (defaults to env var) * @returns Promise that resolves to the shared Pubky Client instance */ -export async function getPubkyClientAsync(): Promise { - return initializePubkyClient(); +export async function getPubkyClientAsync(useTestnet?: boolean): Promise { + if (clientInstance) { + return clientInstance; + } + + if (initializationPromise) { + return initializationPromise; + } + + // Check environment variable if useTestnet not explicitly provided + const shouldUseTestnet = useTestnet ?? ((import.meta as any).env?.VITE_PUBKY_NETWORK === 'testnet'); + + initializationPromise = (async () => { + const pubkyModule = await import('@synonymdev/pubky'); + + // Configure log level if available + if ('setLogLevel' in pubkyModule && typeof (pubkyModule as any).setLogLevel === 'function') { + const setLogLevel = (pubkyModule as any).setLogLevel; + if (process.env.NODE_ENV === 'production') { + setLogLevel('error'); + } else { + setLogLevel('debug'); + } + } + + // Use TestnetClient if requested, otherwise use regular Client + // SDK exports Client as default or named export - handle both + const ClientClass = shouldUseTestnet && 'TestnetClient' in pubkyModule + ? (pubkyModule as any).TestnetClient + : ((pubkyModule as any).Client || (pubkyModule as any).default?.Client || pubkyModule.default); + + clientInstance = new ClientClass(); + logger.info('PubkyClientFactory', `Pubky Client singleton initialized (${shouldUseTestnet ? 'testnet' : 'mainnet'})`); + return clientInstance; + })(); + + return initializationPromise; } /**