Firefox WebExtension (Desktop and Mobile) that lets you share the current tab to Margit.at, frontpage.fyi, etc. with minimal effort.

feat(extension): add margin annotations and rebrand UI

- add margin submit API, content script, and popup tab experience
- rename extension to ATProto Social and bump version to 0.1.5

+325 -27
+80
extension/background.js
··· 160 160 return res.json(); 161 161 } 162 162 163 + const MARGIN_ANNOTATION_COLLECTION = "at.margin.annotation"; 164 + const MARGIN_HIGHLIGHT_COLLECTION = "at.margin.highlight"; 165 + 166 + async function createMarginRecord({ url, title, exact, prefix, suffix, comment }, authOverride) { 167 + if (!url) throw new Error("URL is required."); 168 + if (!exact) throw new Error("Selected text is required."); 169 + 170 + const auth = authOverride ?? (await ensureSession()); 171 + 172 + const selector = { 173 + $type: "at.margin.annotation#textQuoteSelector", 174 + exact, 175 + ...(prefix ? { prefix } : {}), 176 + ...(suffix ? { suffix } : {}) 177 + }; 178 + 179 + const target = { 180 + $type: "at.margin.annotation#target", 181 + source: url, 182 + ...(title ? { title } : {}), 183 + selector 184 + }; 185 + 186 + const generator = { 187 + id: "https://github.com/Galiglobal/frontpage_firefox_plugin", 188 + name: "Frontpage Submitter" 189 + }; 190 + 191 + const hasComment = comment && comment.trim(); 192 + const collection = hasComment ? MARGIN_ANNOTATION_COLLECTION : MARGIN_HIGHLIGHT_COLLECTION; 193 + 194 + const record = hasComment 195 + ? { 196 + $type: MARGIN_ANNOTATION_COLLECTION, 197 + motivation: "commenting", 198 + body: { value: comment.trim(), format: "text/plain" }, 199 + target, 200 + generator, 201 + createdAt: new Date().toISOString() 202 + } 203 + : { 204 + $type: MARGIN_HIGHLIGHT_COLLECTION, 205 + target, 206 + generator, 207 + createdAt: new Date().toISOString() 208 + }; 209 + 210 + const body = { 211 + repo: auth.did, 212 + collection, 213 + record 214 + }; 215 + 216 + const res = await fetch(`${auth.pds}/xrpc/com.atproto.repo.createRecord`, { 217 + method: "POST", 218 + headers: { 219 + Authorization: `Bearer ${auth.accessJwt}`, 220 + "Content-Type": "application/json" 221 + }, 222 + body: JSON.stringify(body) 223 + }); 224 + 225 + if (res.status === 401 && !authOverride) { 226 + const refreshed = await refreshSession(auth); 227 + return createMarginRecord({ url, title, exact, prefix, suffix, comment }, refreshed); 228 + } 229 + 230 + if (!res.ok) { 231 + const errorText = await res.text(); 232 + throw new Error(`Post failed (${res.status}): ${errorText || res.statusText}`); 233 + } 234 + 235 + return res.json(); 236 + } 237 + 163 238 browser.runtime.onMessage.addListener((message) => { 164 239 switch (message?.type) { 165 240 case "frontpage-submit": 166 241 return createFrontpageRecord(message.payload).then( 242 + (result) => ({ ok: true, result }), 243 + (error) => ({ ok: false, error: error.message }) 244 + ); 245 + case "margin-submit": 246 + return createMarginRecord(message.payload).then( 167 247 (result) => ({ ok: true, result }), 168 248 (error) => ({ ok: false, error: error.message }) 169 249 );
+33
extension/content.js
··· 1 + const CONTEXT_CHARS = 150; 2 + 3 + browser.runtime.onMessage.addListener((message) => { 4 + if (message.type !== "margin-get-selection") return; 5 + 6 + const selection = window.getSelection(); 7 + if (!selection || selection.isCollapsed) { 8 + return Promise.resolve({ selection: null }); 9 + } 10 + 11 + const exact = selection.toString().trim(); 12 + if (!exact) return Promise.resolve({ selection: null }); 13 + 14 + let prefix = ""; 15 + let suffix = ""; 16 + try { 17 + const range = selection.getRangeAt(0); 18 + 19 + const prefixRange = document.createRange(); 20 + prefixRange.selectNodeContents(document.body); 21 + prefixRange.setEnd(range.startContainer, range.startOffset); 22 + prefix = prefixRange.toString().slice(-CONTEXT_CHARS); 23 + 24 + const suffixRange = document.createRange(); 25 + suffixRange.selectNodeContents(document.body); 26 + suffixRange.setStart(range.endContainer, range.endOffset); 27 + suffix = suffixRange.toString().slice(0, CONTEXT_CHARS); 28 + } catch { 29 + // context extraction failed, proceed without it 30 + } 31 + 32 + return Promise.resolve({ selection: { exact, prefix, suffix } }); 33 + });
+12 -6
extension/manifest.json
··· 1 1 { 2 2 "manifest_version": 3, 3 - "name": "Frontpage Submitter", 3 + "name": "ATProto Social", 4 4 "description": "Quickly share the current tab to frontpage.fyi via ATProto.", 5 - "version": "0.1.4", 6 - "author": "Frontpage Submitter Contributors", 5 + "version": "0.1.5", 6 + "author": "ATProto Social Contributors", 7 7 "homepage_url": "https://frontpage.fyi", 8 8 "icons": { 9 9 "16": "icons/icon-16.svg", ··· 25 25 "16": "icons/icon-16.svg", 26 26 "32": "icons/icon-32.svg" 27 27 }, 28 - "default_title": "Post to Frontpage" 28 + "default_title": "ATProto Submitter" 29 29 }, 30 30 "options_ui": { 31 31 "page": "options.html", 32 32 "open_in_tab": true, 33 33 "browser_style": true 34 34 }, 35 + "content_scripts": [ 36 + { 37 + "matches": ["https://*/*", "http://*/*"], 38 + "js": ["content.js"], 39 + "run_at": "document_idle" 40 + } 41 + ], 35 42 "background": { 36 43 "scripts": [ 37 44 "background.js" 38 - ], 39 - "persistent": false 45 + ] 40 46 }, 41 47 "browser_specific_settings": { 42 48 "gecko": {
+2 -2
extension/options.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <title>Frontpage Submitter Options</title> 5 + <title>ATProto Social Options</title> 6 6 <link rel="stylesheet" href="styles.css" /> 7 7 </head> 8 8 <body> 9 9 <main class="options"> 10 - <h1>Frontpage Submitter</h1> 10 + <h1>ATProto Social</h1> 11 11 <section> 12 12 <h2>Account</h2> 13 13 <p class="help">
+40 -18
extension/popup.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <title>Frontpage Submitter</title> 5 + <title>ATProto Social</title> 6 6 <link rel="stylesheet" href="styles.css" /> 7 7 </head> 8 8 <body> 9 9 <main class="popup"> 10 10 <header> 11 - <h1>Frontpage</h1> 11 + <h1>ATProto</h1> 12 12 <button id="open-options" type="button" title="Configure account">⚙</button> 13 13 </header> 14 - <form id="submit-form"> 15 - <label for="title"> 16 - Title 17 - <span id="title-count" class="counter">0/120</span> 18 - </label> 19 - <input id="title" name="title" type="text" maxlength="120" required /> 14 + 15 + <div class="tabs" role="tablist"> 16 + <button class="tab active" id="tab-margin" role="tab" aria-selected="true" aria-controls="panel-margin">Margin</button> 17 + <button class="tab" id="tab-frontpage" role="tab" aria-selected="false" aria-controls="panel-frontpage">Frontpage</button> 18 + </div> 20 19 21 - <label for="url"> 22 - URL 23 - </label> 24 - <input id="url" name="url" type="url" required /> 20 + <div id="panel-margin" role="tabpanel"> 21 + <form id="margin-form"> 22 + <label for="margin-selection">Selected text</label> 23 + <textarea id="margin-selection" name="selection" rows="4" readonly placeholder="Select text on the page first…"></textarea> 25 24 26 - <button id="submit-btn" type="submit">Post to Frontpage</button> 27 - </form> 28 - <p id="status" role="status" aria-live="polite"></p> 29 - <footer> 30 - <a id="open-frontpage" href="https://frontpage.fyi" target="_blank" rel="noreferrer">Open frontpage.fyi</a> 31 - </footer> 25 + <label for="margin-comment">Comment <span class="counter">(optional)</span></label> 26 + <textarea id="margin-comment" name="comment" rows="3" placeholder="Add a note… (leave empty to just highlight)"></textarea> 27 + 28 + <button id="margin-submit-btn" type="submit">Highlight on Margin</button> 29 + </form> 30 + <p id="margin-status" role="status" aria-live="polite"></p> 31 + <footer> 32 + <a id="open-margin" href="https://margin.at" target="_blank" rel="noreferrer">Open margin.at</a> 33 + </footer> 34 + </div> 35 + 36 + <div id="panel-frontpage" role="tabpanel" hidden> 37 + <form id="submit-form"> 38 + <label for="title"> 39 + Title 40 + <span id="title-count" class="counter">0/120</span> 41 + </label> 42 + <input id="title" name="title" type="text" maxlength="120" required /> 43 + 44 + <label for="url">URL</label> 45 + <input id="url" name="url" type="url" required /> 46 + 47 + <button id="submit-btn" type="submit">Post to Frontpage</button> 48 + </form> 49 + <p id="status" role="status" aria-live="polite"></p> 50 + <footer> 51 + <a id="open-frontpage" href="https://frontpage.fyi" target="_blank" rel="noreferrer">Open frontpage.fyi</a> 52 + </footer> 53 + </div> 32 54 </main> 33 55 <script type="module" src="popup.js"></script> 34 56 </body>
+120 -1
extension/popup.js
··· 10 10 const openOptionsBtn = document.getElementById("open-options"); 11 11 const openFrontpageLink = document.getElementById("open-frontpage"); 12 12 13 + // Tabs 14 + const tabFrontpage = document.getElementById("tab-frontpage"); 15 + const tabMargin = document.getElementById("tab-margin"); 16 + const panelFrontpage = document.getElementById("panel-frontpage"); 17 + const panelMargin = document.getElementById("panel-margin"); 18 + 19 + // Margin panel 20 + const marginForm = document.getElementById("margin-form"); 21 + const marginSelectionEl = document.getElementById("margin-selection"); 22 + const marginCommentEl = document.getElementById("margin-comment"); 23 + const marginSubmitBtn = document.getElementById("margin-submit-btn"); 24 + const marginStatusEl = document.getElementById("margin-status"); 25 + const openMarginLink = document.getElementById("open-margin"); 26 + 27 + let activeTab = null; // the browser tab object 28 + 13 29 function showStatus(message, isError = false) { 14 30 statusEl.textContent = message; 15 31 statusEl.className = isError ? "status error" : "status success"; ··· 30 46 const tabs = await api.tabs.query({ active: true, currentWindow: true }); 31 47 const tab = tabs?.[0]; 32 48 if (!tab) return; 49 + activeTab = tab; 33 50 if (tab.title) { 34 51 titleInput.value = tab.title.trim().slice(0, MAX_TITLE); 35 52 } ··· 39 56 updateTitleCounter(); 40 57 } catch (error) { 41 58 console.error("Unable to read active tab", error); 59 + } 60 + } 61 + 62 + function showMarginStatus(message, isError = false) { 63 + marginStatusEl.textContent = message; 64 + marginStatusEl.className = isError ? "status error" : "status success"; 65 + } 66 + 67 + async function loadMarginSelection() { 68 + marginSelectionEl.value = ""; 69 + showMarginStatus(""); 70 + if (!activeTab?.id) return; 71 + if (!/^https?:/i.test(activeTab.url ?? "")) { 72 + showMarginStatus("Margin annotations require an http/https page.", true); 73 + return; 74 + } 75 + try { 76 + const response = await api.tabs.sendMessage(activeTab.id, { type: "margin-get-selection" }); 77 + if (response?.selection?.exact) { 78 + marginSelectionEl.value = response.selection.exact; 79 + } else { 80 + showMarginStatus("No text selected. Select text on the page and reopen.", true); 81 + } 82 + } catch { 83 + showMarginStatus("Could not read selection. Try reloading the page.", true); 84 + } 85 + } 86 + 87 + function switchTab(tab) { 88 + if (tab === "margin") { 89 + tabFrontpage.classList.remove("active"); 90 + tabFrontpage.setAttribute("aria-selected", "false"); 91 + tabMargin.classList.add("active"); 92 + tabMargin.setAttribute("aria-selected", "true"); 93 + panelFrontpage.hidden = true; 94 + panelMargin.hidden = false; 95 + loadMarginSelection(); 96 + } else { 97 + tabMargin.classList.remove("active"); 98 + tabMargin.setAttribute("aria-selected", "false"); 99 + tabFrontpage.classList.add("active"); 100 + tabFrontpage.setAttribute("aria-selected", "true"); 101 + panelMargin.hidden = true; 102 + panelFrontpage.hidden = false; 42 103 } 43 104 } 44 105 ··· 100 161 api.tabs.create({ url: "https://frontpage.fyi" }); 101 162 }); 102 163 103 - populateFromTab(); 164 + tabFrontpage.addEventListener("click", () => switchTab("frontpage")); 165 + tabMargin.addEventListener("click", () => switchTab("margin")); 166 + 167 + marginCommentEl.addEventListener("input", () => { 168 + marginSubmitBtn.textContent = marginCommentEl.value.trim() ? "Annotate on Margin" : "Highlight on Margin"; 169 + }); 170 + 171 + marginForm.addEventListener("submit", async (event) => { 172 + event.preventDefault(); 173 + if (!hasAuth) { 174 + showMarginStatus("Configure your credentials in the options page.", true); 175 + return; 176 + } 177 + const exact = marginSelectionEl.value.trim(); 178 + if (!exact) { 179 + showMarginStatus("No text selected. Select text on the page and reopen.", true); 180 + return; 181 + } 182 + showMarginStatus(""); 183 + marginSubmitBtn.disabled = true; 184 + try { 185 + const tabs = await api.tabs.query({ active: true, currentWindow: true }); 186 + const tab = tabs?.[0]; 187 + let selectionData = { exact, prefix: "", suffix: "" }; 188 + try { 189 + const res = await api.tabs.sendMessage(tab.id, { type: "margin-get-selection" }); 190 + if (res?.selection) selectionData = res.selection; 191 + } catch { 192 + // use what we have 193 + } 194 + const payload = { 195 + url: tab?.url ?? "", 196 + title: tab?.title ?? "", 197 + exact: selectionData.exact, 198 + prefix: selectionData.prefix, 199 + suffix: selectionData.suffix, 200 + comment: marginCommentEl.value 201 + }; 202 + const response = await api.runtime.sendMessage({ type: "margin-submit", payload }); 203 + if (!response?.ok) { 204 + throw new Error(response?.error ?? "Unknown error"); 205 + } 206 + const type = payload.comment.trim() ? "Annotation" : "Highlight"; 207 + showMarginStatus(`${type} published to Margin!`); 208 + marginCommentEl.value = ""; 209 + } catch (error) { 210 + console.error("Margin submission failed", error); 211 + showMarginStatus(error.message, true); 212 + } finally { 213 + marginSubmitBtn.disabled = false; 214 + } 215 + }); 216 + 217 + openMarginLink.addEventListener("click", (event) => { 218 + event.preventDefault(); 219 + api.tabs.create({ url: "https://margin.at" }); 220 + }); 221 + 222 + populateFromTab().then(() => switchTab("margin")); 104 223 checkAuth();
+38
extension/styles.css
··· 110 110 font-size: 0.85rem; 111 111 } 112 112 113 + .tabs { 114 + display: flex; 115 + gap: 4px; 116 + border-bottom: 2px solid #e2e8f0; 117 + } 118 + 119 + .tab { 120 + padding: 6px 14px; 121 + border: none; 122 + border-bottom: 2px solid transparent; 123 + background: transparent; 124 + cursor: pointer; 125 + font: inherit; 126 + font-size: 0.9rem; 127 + color: #64748b; 128 + margin-bottom: -2px; 129 + } 130 + 131 + .tab.active { 132 + color: #2563eb; 133 + border-bottom-color: #2563eb; 134 + font-weight: 600; 135 + } 136 + 137 + textarea { 138 + padding: 6px 8px; 139 + border: 1px solid #cbd5f5; 140 + border-radius: 6px; 141 + background: white; 142 + font: inherit; 143 + resize: vertical; 144 + } 145 + 146 + textarea[readonly] { 147 + background: #f1f5f9; 148 + color: #475569; 149 + } 150 + 113 151 main.options { 114 152 max-width: 640px; 115 153 margin: 0 auto;