Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 478 lines 14 kB view raw
1import { onMessage } from '@/utils/messaging'; 2import type { Annotation } from '@/utils/types'; 3import { 4 checkSession, 5 getAnnotations, 6 createAnnotation, 7 createBookmark, 8 createHighlight, 9 deleteHighlight, 10 getUserBookmarks, 11 getUserHighlights, 12 getUserCollections, 13 addToCollection, 14 getItemCollections, 15 getReplies, 16 createReply, 17 getUserTags, 18 getTrendingTags, 19} from '@/utils/api'; 20import { overlayEnabledItem, apiUrlItem } from '@/utils/storage'; 21 22export default defineBackground(() => { 23 console.log('Margin extension loaded'); 24 25 function getPDFViewerURL(originalUrl: string): string { 26 const viewerBase = browser.runtime.getURL('/pdfjs/web/viewer.html' as any); 27 try { 28 const parsed = new URL(originalUrl); 29 const hash = parsed.hash; 30 parsed.hash = ''; 31 return `${viewerBase}?file=${encodeURIComponent(parsed.href)}${hash}`; 32 } catch { 33 return `${viewerBase}?file=${encodeURIComponent(originalUrl)}`; 34 } 35 } 36 37 function resolveTabUrl(tabUrl: string): string { 38 if (tabUrl.includes('/pdfjs/web/viewer.html')) { 39 try { 40 const fileParam = new URL(tabUrl).searchParams.get('file'); 41 if (fileParam) return fileParam; 42 } catch { 43 /* ignore */ 44 } 45 } 46 return tabUrl; 47 } 48 49 const annotationCache = new Map<string, { annotations: Annotation[]; timestamp: number }>(); 50 const CACHE_TTL = 60000; 51 52 onMessage('checkSession', async () => { 53 return await checkSession(); 54 }); 55 56 onMessage('getAnnotations', async ({ data, sender }) => { 57 let citedUrls: string[] = data.citedUrls ?? []; 58 59 if (data.citedUrls === undefined) { 60 try { 61 const tabId = 62 (sender as any)?.tab?.id ?? 63 (await browser.tabs.query({ active: true, currentWindow: true }))[0]?.id; 64 if (tabId !== undefined) { 65 const res = (await browser.tabs.sendMessage(tabId, { type: 'GET_DOI' })) as 66 | { doiUrl: string | null } 67 | undefined; 68 if (res?.doiUrl) citedUrls = [res.doiUrl]; 69 } 70 } catch { 71 // ignore 72 } 73 } 74 75 return await getAnnotations(data.url, citedUrls, data.cacheBust); 76 }); 77 78 onMessage('activateOnPdf', async ({ data }) => { 79 const { tabId, url } = data; 80 const viewerUrl = getPDFViewerURL(url); 81 await browser.tabs.update(tabId, { url: viewerUrl }); 82 return { redirected: true }; 83 }); 84 85 onMessage('createAnnotation', async ({ data }) => { 86 return await createAnnotation(data); 87 }); 88 89 onMessage('createBookmark', async ({ data }) => { 90 return await createBookmark(data); 91 }); 92 93 onMessage('createHighlight', async ({ data }) => { 94 return await createHighlight(data); 95 }); 96 97 onMessage('deleteHighlight', async ({ data }) => { 98 return await deleteHighlight(data.uri); 99 }); 100 101 onMessage('convertHighlightToAnnotation', async ({ data }) => { 102 const createResult = await createAnnotation({ 103 url: data.url, 104 text: data.text, 105 title: data.title, 106 selector: data.selector, 107 }); 108 109 if (!createResult.success) { 110 return { success: false, error: createResult.error || 'Failed to create annotation' }; 111 } 112 113 const deleteResult = await deleteHighlight(data.highlightUri); 114 if (!deleteResult.success) { 115 console.warn('Created annotation but failed to delete highlight:', deleteResult.error); 116 } 117 118 return { success: true }; 119 }); 120 121 onMessage('getUserBookmarks', async ({ data }) => { 122 return await getUserBookmarks(data.did); 123 }); 124 125 onMessage('getUserHighlights', async ({ data }) => { 126 return await getUserHighlights(data.did); 127 }); 128 129 onMessage('getUserCollections', async ({ data }) => { 130 return await getUserCollections(data.did); 131 }); 132 133 onMessage('addToCollection', async ({ data }) => { 134 return await addToCollection(data.collectionUri, data.annotationUri); 135 }); 136 137 onMessage('getItemCollections', async ({ data }) => { 138 return await getItemCollections(data.annotationUri); 139 }); 140 141 onMessage('getReplies', async ({ data }) => { 142 return await getReplies(data.uri); 143 }); 144 145 onMessage('createReply', async ({ data }) => { 146 return await createReply(data); 147 }); 148 149 onMessage('getOverlayEnabled', async () => { 150 return await overlayEnabledItem.getValue(); 151 }); 152 153 onMessage('getUserTags', async ({ data }) => { 154 return await getUserTags(data.did); 155 }); 156 157 onMessage('getTrendingTags', async () => { 158 return await getTrendingTags(); 159 }); 160 161 onMessage('openAppUrl', async ({ data }) => { 162 const apiUrl = await apiUrlItem.getValue(); 163 await browser.tabs.create({ url: `${apiUrl}${data.path}` }); 164 }); 165 166 onMessage('updateBadge', async ({ data }) => { 167 const { count, tabId } = data; 168 const text = count > 0 ? String(count > 99 ? '99+' : count) : ''; 169 170 if (tabId) { 171 await browser.action.setBadgeText({ text, tabId }); 172 await browser.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId }); 173 } else { 174 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 175 if (tab?.id) { 176 await browser.action.setBadgeText({ text, tabId: tab.id }); 177 await browser.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId: tab.id }); 178 } 179 } 180 }); 181 182 browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { 183 if (changeInfo.status === 'loading' && changeInfo.url) { 184 await browser.action.setBadgeText({ text: '', tabId }); 185 } 186 }); 187 188 onMessage('cacheAnnotations', async ({ data }) => { 189 const { url, annotations } = data; 190 const normalizedUrl = normalizeUrl(url); 191 annotationCache.set(normalizedUrl, { annotations, timestamp: Date.now() }); 192 }); 193 194 onMessage('getCachedAnnotations', async ({ data }) => { 195 const normalizedUrl = normalizeUrl(data.url); 196 const cached = annotationCache.get(normalizedUrl); 197 if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 198 return cached.annotations; 199 } 200 return null; 201 }); 202 203 function normalizeUrl(url: string): string { 204 try { 205 const u = new URL(url); 206 u.hash = ''; 207 const path = u.pathname.replace(/\/$/, '') || '/'; 208 return `${u.origin}${path}${u.search}`; 209 } catch { 210 return url; 211 } 212 } 213 214 async function ensureContextMenus() { 215 await browser.contextMenus.removeAll(); 216 217 browser.contextMenus.create({ 218 id: 'margin-annotate', 219 title: 'Annotate "%s"', 220 contexts: ['selection'], 221 }); 222 223 browser.contextMenus.create({ 224 id: 'margin-highlight', 225 title: 'Highlight "%s"', 226 contexts: ['selection'], 227 }); 228 229 browser.contextMenus.create({ 230 id: 'margin-bookmark', 231 title: 'Bookmark this page', 232 contexts: ['page'], 233 }); 234 235 browser.contextMenus.create({ 236 id: 'margin-open-sidebar', 237 title: 'Open Margin Sidebar', 238 contexts: ['page', 'selection', 'link'], 239 }); 240 } 241 242 browser.runtime.onInstalled.addListener(async () => { 243 await ensureContextMenus(); 244 }); 245 246 browser.runtime.onStartup.addListener(async () => { 247 await ensureContextMenus(); 248 }); 249 250 browser.contextMenus.onClicked.addListener((info, tab) => { 251 if (info.menuItemId === 'margin-open-sidebar') { 252 const browserAny = browser as any; 253 if (browserAny.sidePanel && tab?.windowId) { 254 browserAny.sidePanel.open({ windowId: tab.windowId }).catch((err: Error) => { 255 console.error('Could not open side panel:', err); 256 }); 257 } else if (browserAny.sidebarAction) { 258 browserAny.sidebarAction.open().catch((err: Error) => { 259 console.warn('Could not open Firefox sidebar:', err); 260 }); 261 } 262 return; 263 } 264 265 handleContextMenuAction(info, tab); 266 }); 267 268 async function handleContextMenuAction(info: any, tab?: any) { 269 const apiUrl = await apiUrlItem.getValue(); 270 271 if (info.menuItemId === 'margin-bookmark' && tab?.url) { 272 const session = await checkSession(); 273 if (!session.authenticated) { 274 await browser.tabs.create({ url: `${apiUrl}/login` }); 275 return; 276 } 277 278 const result = await createBookmark({ 279 url: resolveTabUrl(tab.url), 280 title: tab.title, 281 }); 282 283 if (result.success) { 284 showNotification('Margin', 'Page bookmarked!'); 285 } 286 return; 287 } 288 289 if (info.menuItemId === 'margin-annotate' && tab?.url && info.selectionText) { 290 const session = await checkSession(); 291 if (!session.authenticated) { 292 await browser.tabs.create({ url: `${apiUrl}/login` }); 293 return; 294 } 295 296 try { 297 await browser.tabs.sendMessage(tab.id!, { 298 type: 'SHOW_INLINE_ANNOTATE', 299 data: { 300 url: resolveTabUrl(tab.url), 301 title: tab.title, 302 selector: { 303 type: 'TextQuoteSelector', 304 exact: info.selectionText, 305 }, 306 }, 307 }); 308 } catch { 309 let composeUrl = `${apiUrl}/new?url=${encodeURIComponent(resolveTabUrl(tab.url))}`; 310 composeUrl += `&selector=${encodeURIComponent( 311 JSON.stringify({ 312 type: 'TextQuoteSelector', 313 exact: info.selectionText, 314 }) 315 )}`; 316 await browser.tabs.create({ url: composeUrl }); 317 } 318 return; 319 } 320 321 if (info.menuItemId === 'margin-highlight' && tab?.url && info.selectionText) { 322 const session = await checkSession(); 323 if (!session.authenticated) { 324 await browser.tabs.create({ url: `${apiUrl}/login` }); 325 return; 326 } 327 328 let highlightUrl = resolveTabUrl(tab.url); 329 try { 330 const res = (await browser.tabs.sendMessage(tab.id!, { 331 type: 'GET_CITE_URL', 332 text: info.selectionText, 333 })) as { citeUrl: string | null } | undefined; 334 if (res?.citeUrl) highlightUrl = res.citeUrl; 335 } catch { 336 /* ignore */ 337 } 338 339 const result = await createHighlight({ 340 url: highlightUrl, 341 title: tab.title, 342 selector: { 343 type: 'TextQuoteSelector', 344 exact: info.selectionText, 345 }, 346 }); 347 348 if (result.success) { 349 showNotification('Margin', 'Text highlighted!'); 350 try { 351 await browser.tabs.sendMessage(tab.id!, { type: 'REFRESH_ANNOTATIONS' }); 352 } catch { 353 /* ignore */ 354 } 355 } 356 return; 357 } 358 } 359 360 function showNotification(title: string, message: string) { 361 const browserAny = browser as any; 362 if (browserAny.notifications) { 363 browserAny.notifications.create({ 364 type: 'basic', 365 iconUrl: '/icons/icon-128.png', 366 title, 367 message, 368 }); 369 } 370 } 371 372 let sidePanelOpen = false; 373 374 browser.runtime.onConnect.addListener((port) => { 375 if (port.name === 'sidepanel') { 376 sidePanelOpen = true; 377 port.onDisconnect.addListener(() => { 378 sidePanelOpen = false; 379 }); 380 } 381 }); 382 383 browser.commands?.onCommand.addListener((command) => { 384 if (command === 'toggle-sidebar') { 385 const browserAny = browser as any; 386 if (browserAny.sidePanel) { 387 chrome.windows.getCurrent((win) => { 388 if (win?.id) { 389 if (sidePanelOpen && typeof browserAny.sidePanel.close === 'function') { 390 browserAny.sidePanel.close({ windowId: win.id }).catch((err: Error) => { 391 console.error('Could not close side panel:', err); 392 }); 393 } else { 394 browserAny.sidePanel.open({ windowId: win.id }).catch((err: Error) => { 395 console.error('Could not open side panel:', err); 396 }); 397 } 398 } 399 }); 400 } else if (browserAny.sidebarAction) { 401 browserAny.sidebarAction.toggle().catch((err: Error) => { 402 console.warn('Could not toggle Firefox sidebar:', err); 403 }); 404 } 405 return; 406 } 407 408 handleCommandAsync(command); 409 }); 410 411 async function handleCommandAsync(command: string) { 412 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 413 414 if (command === 'toggle-overlay') { 415 const current = await overlayEnabledItem.getValue(); 416 await overlayEnabledItem.setValue(!current); 417 return; 418 } 419 420 if (command === 'bookmark-page' && tab?.url) { 421 const session = await checkSession(); 422 if (!session.authenticated) { 423 const apiUrl = await apiUrlItem.getValue(); 424 await browser.tabs.create({ url: `${apiUrl}/login` }); 425 return; 426 } 427 428 const result = await createBookmark({ 429 url: resolveTabUrl(tab.url), 430 title: tab.title, 431 }); 432 433 if (result.success) { 434 showNotification('Margin', 'Page bookmarked!'); 435 } 436 return; 437 } 438 439 if ((command === 'annotate-selection' || command === 'highlight-selection') && tab?.id) { 440 try { 441 const selection = (await browser.tabs.sendMessage(tab.id, { type: 'GET_SELECTION' })) as 442 | { text?: string } 443 | undefined; 444 if (!selection?.text) return; 445 446 const session = await checkSession(); 447 if (!session.authenticated) { 448 const apiUrl = await apiUrlItem.getValue(); 449 await browser.tabs.create({ url: `${apiUrl}/login` }); 450 return; 451 } 452 453 if (command === 'annotate-selection') { 454 await browser.tabs.sendMessage(tab.id, { 455 type: 'SHOW_INLINE_ANNOTATE', 456 data: { selector: { exact: selection.text } }, 457 }); 458 } else if (command === 'highlight-selection') { 459 const result = await createHighlight({ 460 url: resolveTabUrl(tab.url!), 461 title: tab.title, 462 selector: { 463 type: 'TextQuoteSelector', 464 exact: selection.text, 465 }, 466 }); 467 468 if (result.success) { 469 showNotification('Margin', 'Text highlighted!'); 470 await browser.tabs.sendMessage(tab.id, { type: 'REFRESH_ANNOTATIONS' }); 471 } 472 } 473 } catch (err) { 474 console.error('Error handling keyboard shortcut:', err); 475 } 476 } 477 } 478});