Last active
January 25, 2025 01:38
-
-
Save gsuberland/f72d40f4f6eb2614ca8e3adb9d710cfe to your computer and use it in GitHub Desktop.
Tampermonkey script that adds a link to view remote posts locally.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Mastodon Local Post Link | |
// @namespace http://tampermonkey.net/ | |
// @version 0.6 | |
// @description This script identifies links to posts on other Mastodon instances and adds an additional link to open it on the local instance. | |
// @author Graham Sutherland | |
// @match https://chaos.social/* | |
// @icon https://chaos.social/favicon.ico | |
// @grant none | |
// ==/UserScript== | |
/* | |
CHANGELOG | |
v0.1: initial release | |
v0.2: added allowlist / excludelist functions. added volatile caching of requests. | |
v0.3: minor bugfixes. debug functionality. in-flight request tracking to avoid duplicate API calls. | |
v0.4: add support for Firefish (aka Calckey) post URLs. | |
v0.5: add support for explicit mastodon post URLs (i.e. /users/<user>/statuses/<id> format) | |
v0.6: literally no code changes, just bumped the favicon to chaos.social because the mastodon.social one 404s and apparently that breaks user script autoinstalls. | |
*/ | |
(function() { | |
'use strict'; | |
// change this if you want the added link to say something else | |
const linkText = '[↓]'; | |
// change this if you want the added link's title to say something else | |
const linkTitle = 'View the linked post on this instance.'; | |
// add hostnames (e.g. "mastodon.social") to this list if you want to only look up posts for these remote instances. if empty, allow list is not used and all hostnames (aside from the exclude list) are looked up. | |
const allowList = []; | |
// add hostnames (e.g. "bad.example") to this list if you want to exclude them from being looked up. this is disabled if the allow list above is used. | |
const excludeList = []; | |
// regex for URLs that look like fediverse posts | |
const mastodonUrlRegex = /^https?:\/\/(?<host>[^/]+)\/@(?<user>[a-zA-Z0-9_]+)\/(?<id>[0-9]+)$/; | |
const mastodonExplicitUrlRegex = /^https?:\/\/(?<host>[^/]+)\/users\/(?<user>[a-zA-Z0-9_]+)\/statuses\/(?<id>[0-9]+)$/; | |
const firefishUrlRegex = /^https?:\/\/(?<host>[^/]+)\/notes\/(?<id>[a-z0-9]{16})$/; | |
const urlRegexes = { | |
"mastodon": mastodonUrlRegex, | |
"mastodon_explicit": mastodonExplicitUrlRegex, | |
"firefish": firefishUrlRegex /* aka calckey */ | |
}; | |
// note: this should always be the correct base path for Mastodon, since installing to a subdirectory is not supported. | |
// see: https://github.com/mastodon/mastodon/issues/5089 | |
const baseURL = window.location.protocol + '//' + window.location.host + '/'; | |
// cache stores a lookup from original (cross-instance) URLs to local instance URLs. this is a volatile cache and is cleared on page reload. | |
// each cache entry's key is the URL of the post on the remote instance, and the value is the local instance URL for the post. | |
const cache = {}; | |
// in-flight requests are tracked so we don't end up firing off multiple API calls for the same target post URL. | |
// each entry's key is the URL of the post on the remote instance. each value is an object containing the request and an array of elements to be updated once the request completes. | |
const inFlightRequests = {}; | |
// if the URL ends in #debug_mastodon_local_links then turn on some extra debug functionality | |
if (window.location.hash === "#debug_mastodon_local_links") | |
{ | |
console.log("mastodon local links debug enabled!"); | |
window.cache = cache; | |
window.ifr = inFlightRequests; | |
window.debug_mastodon_local_links = true; | |
} | |
const debugLog = function(v) { if (window.debug_mastodon_local_links) { console.log(v); } }; | |
// this is the function that actually does the DOM manipulation. it takes the target element (an 'a' element) and the URL to be placed in the new local link | |
const applyLink = function(el, targetURL) | |
{ | |
// if the element is invalid, no longer present in the DOM, has no parent, or the parent element is a local link container (added by us), don't process it again. | |
// this helps prevent a race condition when two API calls are made about the same post at the same time, resulting in two calls to applyLink | |
if (el === null || | |
!document.body.contains(el) || | |
el.parentNode === undefined || | |
el.parentNode === null || | |
el.parentNode.hasAttribute('data-local-link')) | |
{ | |
return; | |
} | |
// store references to the original parent and sibling for the link | |
const nextSibling = el.nextSibling; | |
const parent = el.parentNode; | |
// remove the link element from the parent (we'll add it back as a child later) | |
parent.removeChild(el); | |
// make a container element that'll store the original link and our new link together | |
const linkContainer = document.createElement('span'); | |
linkContainer.className = 'post-link-container'; | |
linkContainer.setAttribute('data-local-link', 'local'); | |
// make a link element that'll point to the local URL | |
const localLink = document.createElement('a'); | |
localLink.innerText = ' ' + linkText; | |
localLink.setAttribute('href', targetURL); | |
localLink.setAttribute('title', linkTitle); | |
// put the original link and appended link in the container | |
linkContainer.appendChild(el); | |
linkContainer.appendChild(localLink); | |
// re-insert the link. | |
if (nextSibling === null) | |
{ | |
// if there wasn't a sibling element after the link's position, it was at the end so we just append it. | |
parent.appendChild(linkContainer); | |
} | |
else | |
{ | |
// if there was a sibling element after the link, insert our container before it. | |
parent.insertBefore(linkContainer, nextSibling); | |
} | |
}; | |
new MutationObserver(mutationList => { | |
mutationList.forEach(mutation => { | |
// watch for new 'a' elements with the unhandled-link class. | |
Array.from(mutation.addedNodes) | |
.filter(el => el.nodeName != '#text') | |
.flatMap(el => Array.from(el.querySelectorAll('a.unhandled-link'))) | |
.forEach(el => { | |
// if the link was already processed, its parent node will have the data-local-link attribute set | |
if (el.parentNode.hasAttribute('data-local-link')) | |
{ | |
return; | |
} | |
// get the link href so we can see if it looks like a fediverse post | |
const href = el.getAttribute("href"); | |
// try all the fediverse post link regexes | |
let match = null; | |
for (const urlType in urlRegexes) | |
{ | |
match = urlRegexes[urlType].exec(href); | |
if (match !== null) | |
{ | |
break; | |
} | |
} | |
// if it looks like a mastodon instance, start processing it | |
if (match !== null) | |
{ | |
// if the allow list is in effect, only process links to remote instances in the allow list | |
if (allowList.length > 0) | |
{ | |
if (!allowList.includes(match.groups.host.toLowerCase())) | |
{ | |
return; | |
} | |
} | |
else | |
{ | |
// allow list is not in effect. check if the exclude list contains the host, and skip further processing if it is. | |
if (excludeList.includes(match.groups.host.toLowerCase())) | |
{ | |
return; | |
} | |
} | |
// check to see if we already did a lookup for this post | |
if (href in cache) | |
{ | |
// add the link | |
debugLog("cache hit: " + href + " => " + cache[href]); | |
applyLink(el, cache[href]); | |
} | |
else if (href in inFlightRequests) | |
{ | |
debugLog("in-flight request found for " + href); | |
// if there's an in-flight request for this href already, and the element isn't already being processed by that in-flight request, add it to the elements list | |
if (inFlightRequests[href].elements.indexOf(el) === -1) | |
{ | |
debugLog("added new element to in-flight request " + href); | |
inFlightRequests[href].elements.push(el); | |
} | |
} | |
else | |
{ | |
// URL isn't cached and there aren't any pending API calls querying for this post. | |
// create an in-flight request entry for this href, with the current element added to the list. we'll fill in the request field in a moment. | |
inFlightRequests[href] = { 'request': null, 'elements': [ el ] }; | |
debugLog("created in-flight request entry for " + href); | |
// do an API lookup for the remote post. the lookup is required because the post's ID on the remote instance is not the same as the post's ID on the local instance. | |
const request = fetch(baseURL + 'api/v2/search?resolve=true&q=' + href, { | |
headers: { 'Accept': 'application/json' } | |
}) | |
.then(data => data.json()) | |
.then(json => { | |
if (json.statuses !== undefined && json.statuses.length > 0) | |
{ | |
// grab the status info and build the target URL | |
const status = json.statuses[0]; | |
const targetURL = baseURL + '@' + status.account.acct + '/' + status.id; | |
// cache the lookup result | |
cache[href] = targetURL; | |
debugLog("cache add: " + href + " => " + cache[href]); | |
// find all the elements that need updating as a result of this request | |
const elements = inFlightRequests[href].elements; | |
// delete the in-flight request because it is now resolved | |
delete inFlightRequests[href]; | |
debugLog("deleted in-flight request entry for " + href); | |
// process each element in the list | |
for (const element of elements) | |
{ | |
applyLink(element, targetURL); | |
} | |
} | |
else | |
{ | |
debugLog("API lookup for " + href + " returned no results."); | |
// delete the in-flight request because it failed | |
debugLog("deleted in-flight request entry for " + href + " (reason: no result)"); | |
delete inFlightRequests[href]; | |
} | |
}) | |
.catch(e => { | |
debugLog("API call failure for " + href); | |
debugLog(e); | |
// delete the in-flight request because it failed | |
debugLog("deleted in-flight request entry for " + href + " (reason: failed)"); | |
delete inFlightRequests[href]; | |
}); | |
// track the in-flight request! | |
debugLog("sent API request for " + href); | |
inFlightRequests[href].request = request; | |
} | |
} | |
}) | |
}) | |
}).observe(document.body, { childList: true, subtree: true }); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment