Margin is an open annotation layer for 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 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});