this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit.

alice 45270f0c

+437
+10
_locales/en/messages.json
··· 1 + { 2 + "extension_name": { 3 + "message": "Wormhole", 4 + "description": "The display name for the extension." 5 + }, 6 + "extension_description": { 7 + "message": "Open at:// links or active tab URL in various BlueSky tools.", 8 + "description": "Description of what the extension does." 9 + } 10 + }
+17
background.js
··· 1 + // chrome.runtime.onInstalled.addListener(() => { 2 + // chrome.contextMenus.create({ 3 + // id: "wormhole-open", 4 + // title: "Open in Wormhole...", 5 + // contexts: ["all"], 6 + // }); 7 + // }); 8 + 9 + // chrome.contextMenus.onClicked.addListener((info, tab) => { 10 + // const payload = info.linkUrl || info.selectionText || tab.url; 11 + // chrome.tabs.create({ 12 + // url: 13 + // chrome.runtime.getURL("popup.html") + 14 + // "?payload=" + 15 + // encodeURIComponent(payload), 16 + // }); 17 + // });
images/icon-128.png

This is a binary file and will not be displayed.

images/icon-256.png

This is a binary file and will not be displayed.

images/icon-48.png

This is a binary file and will not be displayed.

images/icon-512.png

This is a binary file and will not be displayed.

images/icon-64.png

This is a binary file and will not be displayed.

images/icon-96.png

This is a binary file and will not be displayed.

+5
images/toolbar-icon.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.9375 15.9453"> 3 + <path d="M7.96875 15.9375C12.3281 15.9375 15.9375 12.3203 15.9375 7.96875C15.9375 3.60938 12.3203 0 7.96094 0C3.60938 0 0 3.60938 0 7.96875C0 12.3203 3.61719 15.9375 7.96875 15.9375ZM7.96875 14.6094C4.28125 14.6094 1.33594 11.6562 1.33594 7.96875C1.33594 4.28125 4.27344 1.32812 7.96094 1.32812C11.6484 1.32812 14.6094 4.28125 14.6094 7.96875C14.6094 11.6562 11.6562 14.6094 7.96875 14.6094Z" /> 4 + <path d="M4.53906 8.50781C4.53906 8.70312 4.69531 8.84375 4.89844 8.84375L7.54688 8.84375L6.13281 12.6406C5.94531 13.1406 6.47656 13.4141 6.80469 13.0078L11.0859 7.63281C11.1719 7.53906 11.2188 7.42969 11.2188 7.32812C11.2188 7.13281 11.0625 6.99219 10.8594 6.99219L8.21094 6.99219L9.625 3.19531C9.8125 2.69531 9.28125 2.42188 8.95312 2.82031L4.67188 8.19531C4.58594 8.29688 4.53906 8.40625 4.53906 8.50781Z" /> 5 + </svg>
+33
manifest.json
··· 1 + { 2 + "manifest_version": 3, 3 + "default_locale": "en", 4 + 5 + "name": "__MSG_extension_name__", 6 + "description": "__MSG_extension_description__", 7 + "version": "1.0", 8 + 9 + "icons": { 10 + "48": "images/icon-48.png", 11 + "96": "images/icon-96.png", 12 + "128": "images/icon-128.png", 13 + "256": "images/icon-256.png", 14 + "512": "images/icon-512.png" 15 + }, 16 + 17 + "permissions": [ 18 + "activeTab", 19 + "contextMenus", 20 + "tabs" 21 + ], 22 + "host_permissions": [ 23 + "<all_urls>" 24 + ], 25 + 26 + "action": { 27 + "default_popup": "popup.html", 28 + "default_icon": { 29 + "48": "images/icon-48.png", 30 + "96": "images/icon-96.png" 31 + } 32 + } 33 + }
+20
popup.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Wormhole</title> 6 + <style> 7 + body { font-family: -apple-system, sans-serif; margin: 0; padding: 10px; width: 280px; } 8 + ul { list-style: none; padding: 0; } 9 + li { margin: 4px 0; } 10 + button { width: 100%; text-align: left; padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; background: #fafafa; font-size: 14px; } 11 + button:hover { background: #eee; } 12 + </style> 13 + </head> 14 + <body> 15 + <p>Wormhole!</p> 16 + <ul id="dest"></ul> 17 + <script src="transform.js"></script> 18 + <script src="popup.js"></script> 19 + </body> 20 + </html>
+31
popup.js
··· 1 + (async function () { 2 + const params = new URLSearchParams(window.location.search); 3 + let raw = params.get("payload"); 4 + if (!raw) { 5 + const [tab] = await chrome.tabs.query({ 6 + active: true, 7 + currentWindow: true, 8 + }); 9 + raw = tab.url; 10 + } 11 + const info = await window.WormholeTransform.parseInput(raw); 12 + const list = document.getElementById("dest"); 13 + if (!info?.atUri) { 14 + list.innerHTML = "<li>No at:// reference found.</li>"; 15 + return; 16 + } 17 + const dests = window.WormholeTransform.buildDestinations(info); 18 + list.innerHTML = dests 19 + .map( 20 + (d) => 21 + `<li> 22 + <a href="${d.url}" 23 + target="_blank" 24 + rel="noopener noreferrer" 25 + style="display:block; padding:6px 8px; border:1px solid #ccc; border-radius:6px; background:#fafafa; text-decoration:none; color:inherit; font-size:14px;"> 26 + ${d.label} 27 + </a> 28 + </li>` 29 + ) 30 + .join(""); 31 + })();
+127
test.js
··· 1 + // Minimal test file for transform.js 2 + const { parseInput, resolveHandle } = require("./transform.js"); 3 + 4 + function deepEqual(a, b) { 5 + if (a === b) return true; 6 + if (typeof a !== typeof b) return false; 7 + if (a && b && typeof a === "object") { 8 + const aKeys = Object.keys(a); 9 + const bKeys = Object.keys(b); 10 + if (aKeys.length !== bKeys.length) return false; 11 + for (const k of aKeys) { 12 + if (!deepEqual(a[k], b[k])) return false; 13 + } 14 + return true; 15 + } 16 + return false; 17 + } 18 + 19 + async function run() { 20 + let passed = 0, 21 + failed = 0; 22 + function check(name, actual, expected) { 23 + if (deepEqual(actual, expected)) { 24 + console.log(`PASS: ${name}`); 25 + passed++; 26 + } else { 27 + console.log(`FAIL: ${name}\nExpected:`, expected, "\nGot: ", actual); 28 + failed++; 29 + } 30 + } 31 + 32 + // Test cases for parseInput 33 + const tests = [ 34 + { 35 + name: "parseInput/feed/cozy", 36 + input: "https://deer.social/profile/why.bsky.team/feed/cozy", 37 + expected: { 38 + atUri: 39 + "at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/cozy", 40 + did: "did:plc:vpkhqolt662uhesyj6nxm7ys", 41 + handle: "why.bsky.team", 42 + rkey: "cozy", 43 + nsid: "app.bsky.feed.generator", 44 + bskyAppPath: "/profile/why.bsky.team/feed/cozy", 45 + }, 46 + }, 47 + { 48 + name: "parseInput/feed.post", 49 + input: 50 + "https://deer.social/profile/did:plc:kkkcb7sys7623hcf7oefcffg/post/3lpe6ek6xhs2n", 51 + expected: { 52 + atUri: 53 + "at://did:plc:kkkcb7sys7623hcf7oefcffg/app.bsky.feed.post/3lpe6ek6xhs2n", 54 + did: "did:plc:kkkcb7sys7623hcf7oefcffg", 55 + handle: null, 56 + rkey: "3lpe6ek6xhs2n", 57 + nsid: "app.bsky.feed.post", 58 + bskyAppPath: 59 + "/profile/did:plc:kkkcb7sys7623hcf7oefcffg/post/3lpe6ek6xhs2n", 60 + }, 61 + }, 62 + { 63 + name: "parseInput/lists", 64 + input: 65 + "https://deer.social/profile/alice.mosphere.at/lists/3l7vfhhfqcz2u", 66 + expected: { 67 + atUri: 68 + "at://did:plc:by3jhwdqgbtrcc7q4tkkv3cf/app.bsky.graph.list/3l7vfhhfqcz2u", 69 + did: "did:plc:by3jhwdqgbtrcc7q4tkkv3cf", 70 + handle: "alice.mosphere.at", 71 + rkey: "3l7vfhhfqcz2u", 72 + nsid: "app.bsky.graph.list", 73 + bskyAppPath: "/profile/alice.mosphere.at/lists/3l7vfhhfqcz2u", 74 + }, 75 + }, 76 + { 77 + name: "parseInput/did:web", 78 + input: "https://deer.social/profile/did:web:didweb.watch", 79 + expected: { 80 + atUri: "at://did:web:didweb.watch", 81 + did: "did:web:didweb.watch", 82 + handle: null, 83 + rkey: undefined, 84 + nsid: undefined, 85 + bskyAppPath: "/profile/did:web:didweb.watch", 86 + }, 87 + }, 88 + { 89 + name: "parseInput/did:web/post", 90 + input: 91 + "https://deer.social/profile/did:web:didweb.watch/post/3lpaioe62qk2j", 92 + expected: { 93 + atUri: "at://did:web:didweb.watch/app.bsky.feed.post/3lpaioe62qk2j", 94 + did: "did:web:didweb.watch", 95 + handle: null, 96 + rkey: "3lpaioe62qk2j", 97 + nsid: "app.bsky.feed.post", 98 + bskyAppPath: "/profile/did:web:didweb.watch/post/3lpaioe62qk2j", 99 + }, 100 + }, 101 + ]; 102 + for (const test of tests) { 103 + try { 104 + const out = await parseInput(test.input); 105 + check(test.name, out, test.expected); 106 + } catch (e) { 107 + console.log(`FAIL: ${test.name} (exception)`, e); 108 + failed++; 109 + } 110 + } 111 + 112 + // Test case for resolveHandle 113 + try { 114 + const handle = "alice.mosphere.at"; 115 + const expectedDid = "did:plc:by3jhwdqgbtrcc7q4tkkv3cf"; 116 + const out = await resolveHandle(handle); 117 + check("resolveHandle", out, expectedDid); 118 + } catch (e) { 119 + console.log("FAIL: resolveHandle (exception)", e); 120 + failed++; 121 + } 122 + 123 + console.log(`\nTest results: ${passed} passed, ${failed} failed.`); 124 + process.exit(failed ? 1 : 0); 125 + } 126 + 127 + run();
+194
transform.js
··· 1 + const NSID_SHORTCUTS = { 2 + post: "app.bsky.feed.post", 3 + feed: "app.bsky.feed.generator", 4 + // list: "app.bsky.graph.list", 5 + lists: "app.bsky.graph.list", 6 + }; 7 + 8 + async function parseInput(raw) { 9 + if (!raw) return null; 10 + let str = decodeURIComponent(raw.trim()); 11 + if (!str.startsWith("http")) { 12 + return await canonicalize(str); 13 + } 14 + // Try to pull out at://... from inside web URL, else treat full URL as possible at link 15 + const m = str.match(/at:\/\/[\w:.\-\/]+/); 16 + if (m) { 17 + return await canonicalize(m[0]); 18 + } 19 + const parts = str.split(/[/?#]/); 20 + for (let i = 0; i < parts.length; i++) { 21 + const p = parts[i]; 22 + if ( 23 + p.startsWith("did:") || 24 + (p.includes(".") && parts[i - 1] === "profile") 25 + ) { 26 + // Re-attach everything that comes *after* the id so we keep /post/<rkey> 27 + const rest = parts.slice(i + 1).join("/"); 28 + return await canonicalize(p + (rest ? "/" + rest : "")); 29 + } 30 + } 31 + return null; 32 + } 33 + 34 + async function canonicalize(fragment) { 35 + // Ensure at:// prefix 36 + let f = fragment.replace(/^at:\/([^/])/, "at://$1"); 37 + if (!f.startsWith("at://")) f = "at://" + f; 38 + // at://did/path or at://handle/path 39 + const [, idAndRest] = f.split("at://"); 40 + const [idPart, ...restParts] = idAndRest.split("/"); 41 + let did = idPart.startsWith("did:") ? idPart : null; 42 + const handle = did ? null : idPart; 43 + if (!did && handle) { 44 + did = await resolveHandle(handle); 45 + } 46 + 47 + if (restParts.length) { 48 + const first = restParts[0]; 49 + if (NSID_SHORTCUTS[first]) { 50 + restParts[0] = NSID_SHORTCUTS[first]; 51 + } 52 + } 53 + const pathRest = restParts.join("/"); 54 + const [nsid, rkey] = pathRest.split("/").filter(Boolean); 55 + 56 + // --- bskyAppPath logic --- 57 + let bskyAppPath = ""; 58 + const acct = handle || did; 59 + if (acct) { 60 + bskyAppPath = `/profile/${acct}`; 61 + if (nsid && rkey) { 62 + if (nsid === "app.bsky.feed.generator") { 63 + bskyAppPath += `/feed/${rkey}`; 64 + } else if (nsid === "app.bsky.feed.post") { 65 + bskyAppPath += `/post/${rkey}`; 66 + } else if (nsid === "app.bsky.graph.list") { 67 + bskyAppPath += `/lists/${rkey}`; 68 + } 69 + } 70 + } 71 + 72 + // Bail if did is falsy 73 + if (!did) return null; 74 + 75 + return { 76 + atUri: "at://" + (did || handle) + (pathRest ? "/" + pathRest : ""), 77 + did, 78 + handle, 79 + rkey, 80 + nsid, 81 + bskyAppPath, 82 + }; 83 + } 84 + 85 + async function resolveHandle(handle) { 86 + // If handle is a did:web:... and looks like did:web:domain 87 + if (typeof handle === "string" && handle.startsWith("did:web:")) { 88 + const parts = handle.split(":"); 89 + if (parts.length === 3) { 90 + // Try to fetch the full DID from .well-known/did.json 91 + const domain = parts[2]; 92 + try { 93 + const resp = await fetch( 94 + "https://" + domain + "/.well-known/did.json" 95 + ).catch(() => null); 96 + if (resp && resp.ok) { 97 + const data = await resp.json(); 98 + return data.id || handle; 99 + } 100 + } catch (_) { 101 + // fallback to returning the input 102 + } 103 + return handle; 104 + } 105 + // If it's a longer did:web:...:... just return as-is 106 + return handle; 107 + } 108 + // Otherwise, resolve handle via bsky API 109 + try { 110 + const url = 111 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=" + 112 + encodeURIComponent(handle); 113 + const resp = await fetch(url); 114 + if (!resp.ok) return null; 115 + const data = await resp.json(); 116 + return data.did || null; 117 + } catch (_) { 118 + return null; 119 + } 120 + } 121 + 122 + function buildDestinations(info) { 123 + // Use the new bskyAppPath for both deer.social and bsky.app 124 + const { atUri, did, handle, rkey, bskyAppPath } = info; 125 + const isDidWeb = did && did.startsWith("did:web:"); 126 + return [ 127 + { label: "🦌 deer.social", url: `https://deer.social${bskyAppPath}` }, 128 + { label: "🦋 bsky.app", url: `https://bsky.app${bskyAppPath}` }, 129 + { 130 + label: "⚙️ pdsls.dev", 131 + url: `https://pdsls.dev/${atUri}`, 132 + }, 133 + { 134 + label: "🛠️ atp.tools", 135 + url: `https://atp.tools/${atUri}`, 136 + }, 137 + { 138 + label: "☀️ clearsky", 139 + url: `https://clearsky.app/${did}/blocked-by`, 140 + }, 141 + ...(rkey 142 + ? [ 143 + { 144 + label: "☁️ skythread", 145 + url: `https://blue.mackuba.eu/skythread/?author=${did}&post=${rkey}`, 146 + }, 147 + ] 148 + : []), 149 + ...(handle 150 + ? [ 151 + { 152 + label: "🍥 cred.blue", 153 + url: `https://cred.blue/${handle}`, 154 + }, 155 + { 156 + label: "🪢 tangled.sh", 157 + url: `https://tangled.sh/@${handle}`, 158 + }, 159 + { 160 + label: "📰 frontpage.fyi", 161 + url: `https://frontpage.fyi/profile/${handle}`, 162 + }, 163 + ] 164 + : []), 165 + ...(!isDidWeb 166 + ? [ 167 + { 168 + label: "⛵ boat.kelinci", 169 + url: `https://boat.kelinci.net/plc-oplogs?q=${did}`, 170 + }, 171 + { 172 + label: "🪪 plc.directory", 173 + url: `https://plc.directory/${did}`, 174 + }, 175 + ] 176 + : []), 177 + ].filter((d) => !!d && !!d.url); 178 + } 179 + 180 + // Node.js (test) and browser (extension) export compatibility 181 + if (typeof module !== "undefined" && module.exports) { 182 + module.exports = { 183 + NSID_SHORTCUTS, 184 + parseInput, 185 + canonicalize, 186 + resolveHandle, 187 + buildDestinations, 188 + }; 189 + } else if (typeof window !== "undefined") { 190 + window.WormholeTransform = { 191 + parseInput, 192 + buildDestinations, 193 + }; 194 + }