Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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});