Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at frontend-rewrite 366 lines 10 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 getUserBookmarks, 10 getUserHighlights, 11 getUserCollections, 12 addToCollection, 13 getItemCollections, 14 getReplies, 15 createReply, 16} from '@/utils/api'; 17import { overlayEnabledItem, apiUrlItem } from '@/utils/storage'; 18 19export default defineBackground(() => { 20 console.log('Margin extension loaded'); 21 22 const annotationCache = new Map<string, { annotations: Annotation[]; timestamp: number }>(); 23 const CACHE_TTL = 60000; 24 25 onMessage('checkSession', async () => { 26 return await checkSession(); 27 }); 28 29 onMessage('getAnnotations', async ({ data }) => { 30 return await getAnnotations(data.url); 31 }); 32 33 onMessage('createAnnotation', async ({ data }) => { 34 return await createAnnotation(data); 35 }); 36 37 onMessage('createBookmark', async ({ data }) => { 38 return await createBookmark(data); 39 }); 40 41 onMessage('createHighlight', async ({ data }) => { 42 return await createHighlight(data); 43 }); 44 45 onMessage('getUserBookmarks', async ({ data }) => { 46 return await getUserBookmarks(data.did); 47 }); 48 49 onMessage('getUserHighlights', async ({ data }) => { 50 return await getUserHighlights(data.did); 51 }); 52 53 onMessage('getUserCollections', async ({ data }) => { 54 return await getUserCollections(data.did); 55 }); 56 57 onMessage('addToCollection', async ({ data }) => { 58 return await addToCollection(data.collectionUri, data.annotationUri); 59 }); 60 61 onMessage('getItemCollections', async ({ data }) => { 62 return await getItemCollections(data.annotationUri); 63 }); 64 65 onMessage('getReplies', async ({ data }) => { 66 return await getReplies(data.uri); 67 }); 68 69 onMessage('createReply', async ({ data }) => { 70 return await createReply(data); 71 }); 72 73 onMessage('getOverlayEnabled', async () => { 74 return await overlayEnabledItem.getValue(); 75 }); 76 77 onMessage('openAppUrl', async ({ data }) => { 78 const apiUrl = await apiUrlItem.getValue(); 79 await browser.tabs.create({ url: `${apiUrl}${data.path}` }); 80 }); 81 82 onMessage('updateBadge', async ({ data }) => { 83 const { count, tabId } = data; 84 const text = count > 0 ? String(count > 99 ? '99+' : count) : ''; 85 86 if (tabId) { 87 await browser.action.setBadgeText({ text, tabId }); 88 await browser.action.setBadgeBackgroundColor({ color: '#6366f1', tabId }); 89 } else { 90 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 91 if (tab?.id) { 92 await browser.action.setBadgeText({ text, tabId: tab.id }); 93 await browser.action.setBadgeBackgroundColor({ color: '#6366f1', tabId: tab.id }); 94 } 95 } 96 }); 97 98 browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { 99 if (changeInfo.status === 'loading' && changeInfo.url) { 100 await browser.action.setBadgeText({ text: '', tabId }); 101 } 102 }); 103 104 onMessage('cacheAnnotations', async ({ data }) => { 105 const { url, annotations } = data; 106 const normalizedUrl = normalizeUrl(url); 107 annotationCache.set(normalizedUrl, { annotations, timestamp: Date.now() }); 108 }); 109 110 onMessage('getCachedAnnotations', async ({ data }) => { 111 const normalizedUrl = normalizeUrl(data.url); 112 const cached = annotationCache.get(normalizedUrl); 113 if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 114 return cached.annotations; 115 } 116 return null; 117 }); 118 119 function normalizeUrl(url: string): string { 120 try { 121 const u = new URL(url); 122 u.hash = ''; 123 const path = u.pathname.replace(/\/$/, '') || '/'; 124 return `${u.origin}${path}${u.search}`; 125 } catch { 126 return url; 127 } 128 } 129 130 async function ensureContextMenus() { 131 await browser.contextMenus.removeAll(); 132 133 browser.contextMenus.create({ 134 id: 'margin-annotate', 135 title: 'Annotate "%s"', 136 contexts: ['selection'], 137 }); 138 139 browser.contextMenus.create({ 140 id: 'margin-highlight', 141 title: 'Highlight "%s"', 142 contexts: ['selection'], 143 }); 144 145 browser.contextMenus.create({ 146 id: 'margin-bookmark', 147 title: 'Bookmark this page', 148 contexts: ['page'], 149 }); 150 151 browser.contextMenus.create({ 152 id: 'margin-open-sidebar', 153 title: 'Open Margin Sidebar', 154 contexts: ['page', 'selection', 'link'], 155 }); 156 } 157 158 browser.runtime.onInstalled.addListener(async () => { 159 await ensureContextMenus(); 160 }); 161 162 browser.runtime.onStartup.addListener(async () => { 163 await ensureContextMenus(); 164 }); 165 166 browser.contextMenus.onClicked.addListener((info, tab) => { 167 if (info.menuItemId === 'margin-open-sidebar') { 168 const browserAny = browser as any; 169 if (browserAny.sidePanel && tab?.windowId) { 170 browserAny.sidePanel.open({ windowId: tab.windowId }).catch((err: Error) => { 171 console.error('Could not open side panel:', err); 172 }); 173 } else if (browserAny.sidebarAction) { 174 browserAny.sidebarAction.open().catch((err: Error) => { 175 console.warn('Could not open Firefox sidebar:', err); 176 }); 177 } 178 return; 179 } 180 181 handleContextMenuAction(info, tab); 182 }); 183 184 async function handleContextMenuAction(info: any, tab?: any) { 185 const apiUrl = await apiUrlItem.getValue(); 186 187 if (info.menuItemId === 'margin-bookmark' && tab?.url) { 188 const session = await checkSession(); 189 if (!session.authenticated) { 190 await browser.tabs.create({ url: `${apiUrl}/login` }); 191 return; 192 } 193 194 const result = await createBookmark({ 195 url: tab.url, 196 title: tab.title, 197 }); 198 199 if (result.success) { 200 showNotification('Margin', 'Page bookmarked!'); 201 } 202 return; 203 } 204 205 if (info.menuItemId === 'margin-annotate' && tab?.url && info.selectionText) { 206 const session = await checkSession(); 207 if (!session.authenticated) { 208 await browser.tabs.create({ url: `${apiUrl}/login` }); 209 return; 210 } 211 212 try { 213 await browser.tabs.sendMessage(tab.id!, { 214 type: 'SHOW_INLINE_ANNOTATE', 215 data: { 216 url: tab.url, 217 title: tab.title, 218 selector: { 219 type: 'TextQuoteSelector', 220 exact: info.selectionText, 221 }, 222 }, 223 }); 224 } catch { 225 let composeUrl = `${apiUrl}/new?url=${encodeURIComponent(tab.url)}`; 226 composeUrl += `&selector=${encodeURIComponent( 227 JSON.stringify({ 228 type: 'TextQuoteSelector', 229 exact: info.selectionText, 230 }) 231 )}`; 232 await browser.tabs.create({ url: composeUrl }); 233 } 234 return; 235 } 236 237 if (info.menuItemId === 'margin-highlight' && tab?.url && info.selectionText) { 238 const session = await checkSession(); 239 if (!session.authenticated) { 240 await browser.tabs.create({ url: `${apiUrl}/login` }); 241 return; 242 } 243 244 const result = await createHighlight({ 245 url: tab.url, 246 title: tab.title, 247 selector: { 248 type: 'TextQuoteSelector', 249 exact: info.selectionText, 250 }, 251 }); 252 253 if (result.success) { 254 showNotification('Margin', 'Text highlighted!'); 255 try { 256 await browser.tabs.sendMessage(tab.id!, { type: 'REFRESH_ANNOTATIONS' }); 257 } catch { 258 /* ignore */ 259 } 260 } 261 return; 262 } 263 } 264 265 function showNotification(title: string, message: string) { 266 const browserAny = browser as any; 267 if (browserAny.notifications) { 268 browserAny.notifications.create({ 269 type: 'basic', 270 iconUrl: '/icons/icon-128.png', 271 title, 272 message, 273 }); 274 } 275 } 276 277 browser.commands?.onCommand.addListener((command) => { 278 if (command === 'open-sidebar') { 279 const browserAny = browser as any; 280 if (browserAny.sidePanel) { 281 chrome.windows.getCurrent((win) => { 282 if (win?.id) { 283 browserAny.sidePanel.open({ windowId: win.id }).catch((err: Error) => { 284 console.error('Could not open side panel:', err); 285 }); 286 } 287 }); 288 } else if (browserAny.sidebarAction) { 289 browserAny.sidebarAction.open().catch((err: Error) => { 290 console.warn('Could not open Firefox sidebar:', err); 291 }); 292 } 293 return; 294 } 295 296 handleCommandAsync(command); 297 }); 298 299 async function handleCommandAsync(command: string) { 300 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 301 302 if (command === 'toggle-overlay') { 303 const current = await overlayEnabledItem.getValue(); 304 await overlayEnabledItem.setValue(!current); 305 return; 306 } 307 308 if (command === 'bookmark-page' && tab?.url) { 309 const session = await checkSession(); 310 if (!session.authenticated) { 311 const apiUrl = await apiUrlItem.getValue(); 312 await browser.tabs.create({ url: `${apiUrl}/login` }); 313 return; 314 } 315 316 const result = await createBookmark({ 317 url: tab.url, 318 title: tab.title, 319 }); 320 321 if (result.success) { 322 showNotification('Margin', 'Page bookmarked!'); 323 } 324 return; 325 } 326 327 if ((command === 'annotate-selection' || command === 'highlight-selection') && tab?.id) { 328 try { 329 const selection = (await browser.tabs.sendMessage(tab.id, { type: 'GET_SELECTION' })) as 330 | { text?: string } 331 | undefined; 332 if (!selection?.text) return; 333 334 const session = await checkSession(); 335 if (!session.authenticated) { 336 const apiUrl = await apiUrlItem.getValue(); 337 await browser.tabs.create({ url: `${apiUrl}/login` }); 338 return; 339 } 340 341 if (command === 'annotate-selection') { 342 await browser.tabs.sendMessage(tab.id, { 343 type: 'SHOW_INLINE_ANNOTATE', 344 data: { selector: { exact: selection.text } }, 345 }); 346 } else if (command === 'highlight-selection') { 347 const result = await createHighlight({ 348 url: tab.url!, 349 title: tab.title, 350 selector: { 351 type: 'TextQuoteSelector', 352 exact: selection.text, 353 }, 354 }); 355 356 if (result.success) { 357 showNotification('Margin', 'Text highlighted!'); 358 await browser.tabs.sendMessage(tab.id, { type: 'REFRESH_ANNOTATIONS' }); 359 } 360 } 361 } catch (err) { 362 console.error('Error handling keyboard shortcut:', err); 363 } 364 } 365 } 366});