Last active
December 13, 2024 04:21
-
-
Save TheAMM/51b0a192520c3a4ea290621373a511b0 to your computer and use it in GitHub Desktop.
Bluesky Media Source userscript (#postid)
This file contains hidden or 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 Bluesky Media Source | |
// @namespace https://gist.github.com/TheAMM | |
// @downloadURL https://gist.github.com/TheAMM/51b0a192520c3a4ea290621373a511b0/raw/twitter_media_source.user.js | |
// @updateURL https://gist.github.com/TheAMM/51b0a192520c3a4ea290621373a511b0/raw/twitter_media_source.user.js | |
// @version 1.0.0 | |
// @description Allows copying direct full-size image links on Bluesky (with a #postid source suffix), downloading media, and navigating back to a post from a media URL. | |
// @author AMM | |
// @match https://bsky.app/* | |
// @match https://cdn.bsky.app/* | |
// @grant none | |
// @inject-into auto | |
// ==/UserScript== | |
/* | |
== INFORMATION == | |
This userscript adds click listeners to media previews on Bluesky. | |
You can SHIFT-CLICK and ALT-CLICK the image thumbnails visible on media posts. | |
SHIFT-CLICK to copy a direct media URL to your clipboard, such as: | |
https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:zoqnfxyjq57a4ambfllizw4s/bafkreifk4toewpfmg3lghynibsx4mp2fu77ufeufj3y63mkfzpaqgderfm@jpeg#3lbbrzm5msc2i | |
https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:dpy6k3n5r7zfqut3xz5g3zv2/bafkreic3hji7ot2mdfyofofp5p2brgca6uu6ktnfldibnaukmlmrq2laiu@jpeg#3kukrsl7fv32x | |
ALT-CLICK to download the media with a tidy filename, such as: | |
vinetsu - bafkreifk4toewpfmg3lghynibsx4mp2fu77ufeufj3y63mkfzpaqgderfm [Bluesky-3lbbrzm5msc2i].jpg | |
starbinds - bafkreic3hji7ot2mdfyofofp5p2brgca6uu6ktnfldibnaukmlmrq2laiu [Bluesky-3kukrsl7fv32x].jpg | |
When viewing a directly opened media file with a #postid suffix (for example, from the links above), | |
you may CTRL-CLICK the image or video to navigate back to the post, and ALT-CLICK to download the file. | |
You may also press D to download or S to navigate to the source post, as for whatever reason Firefox doesn't | |
permit click listeners on the media elements. | |
== CHANGELOG == | |
2024-12-13: 1.0.0 | |
Initial release | |
*/ | |
(function() { | |
'use strict'; | |
const log_label = '[BMS]'; | |
const log_info = (...data) => console.log(log_label, ...data); | |
const log_warn = (...data) => console.warn(log_label, ...data); | |
const log_error = (...data) => console.error(log_label, ...data); | |
const log_debug = (...data) => console.debug(log_label, ...data); | |
function killEvent(e) { | |
e.stopImmediatePropagation(); | |
e.stopPropagation(); | |
e.preventDefault(); | |
return false; | |
} | |
const DID_PDS_MAPPING = new Map(); | |
const get_did_pds = async (did) => { | |
let pds_host = DID_PDS_MAPPING.get(did); | |
if (!pds_host) { | |
// Fetch the DID document and then download the blob | |
// XXX we assume it's a plc did yolo | |
let resp = await fetch(`https://plc.directory/${did}`); | |
if (!resp.ok) { | |
throw resp; | |
} | |
let json = await resp.json(); | |
let pds_service = json.service.find(s => s.id == "#atproto_pds"); | |
pds_host = pds_service.serviceEndpoint; | |
DID_PDS_MAPPING.set(did, pds_host); | |
} | |
return pds_host; | |
} | |
const media_click_handler = (event) => { | |
let media_elem = event.currentTarget; | |
let media_url = media_elem.dataset.mediaUrl; | |
let post_tid = media_elem.dataset.postTid; | |
let media_cid = media_elem.dataset.mediaCid; | |
let author_did = media_elem.dataset.authorDid; | |
let media_filename = media_elem.dataset.filename; | |
if (event.shiftKey && !event.altKey && !event.ctrlKey) { | |
let media_url_post_tid = `${media_url}#${post_tid}`; | |
log_info("Copying", media_url_post_tid); | |
navigator.clipboard.writeText(media_url_post_tid); | |
show_notification(`Copied media link`, 1000); | |
return killEvent(event); | |
} else if (!event.shiftKey && event.altKey && !event.ctrlKey) { | |
show_notification(`Downloading<br/><kbd>${media_filename}</kbd>`, 2000); | |
// The CDN does not have CORS for the app site, so we have to fetch the PDS from the directory, and then the blob from it directly. | |
// TODO: try using GM_xmlhttpRequest/GM_download with all the necessary fallbacks? | |
get_did_pds(author_did).then(pds_host => { | |
let download_url = `${pds_host}/xrpc/com.atproto.sync.getBlob?did=${author_did}&cid=${media_cid}`; | |
download_file_from_url(media_filename, download_url); | |
}); | |
return killEvent(event); | |
} | |
} | |
let REACT_PROPS_KEY = null; | |
const find_post = (element) => { | |
// Given an element, find a parent that seems to have the post props in a child | |
if (!REACT_PROPS_KEY) { | |
REACT_PROPS_KEY = '__reactProps$' + ( | |
Object.keys(document.getElementById('root')) // yolo | |
.find(k => k.startsWith('__react')) | |
.split('$')[1] | |
); | |
} | |
let post_root = element.parentElement; | |
let child_with_post; | |
while (true) { | |
let props = post_root[REACT_PROPS_KEY]; | |
if (!props) { | |
// We're out of react parents! | |
return {}; | |
} | |
child_with_post = Array.from(props.children ?? []).find(c => c?.props?.post); | |
if (child_with_post) { | |
break; | |
} | |
post_root = post_root.parentElement; | |
} | |
return { | |
root: post_root, | |
post: child_with_post.props.post, | |
} | |
} | |
function bluesky_media_shenanigans() { | |
// Find images/thumbnails directly; we have *some* testids to target, but none on search and other feeds | |
let images = document.querySelectorAll('img[src^="https://cdn.bsky.app/img/feed"]'); | |
for (let img of images) { | |
if (img._bms_done) { continue; } | |
let image_url = new URL(img.src); | |
let image_match = image_url.pathname.match(/img\/feed_\w+\/plain\/(did:[^\/]+)\/(baf[0-9a-z]+)(@\w*)/); | |
if (!image_match) { | |
log_warn("Caught bad image?", img); | |
continue; | |
} | |
let media_cid = image_match[2]; | |
img._bms_done = true; | |
let {root, post} = find_post(img); | |
if (!root) { continue; } // Might be the image viewer | |
// Check if our image belongs to a quoted post | |
let embed_type = post.embed['$type']; | |
if (embed_type.startsWith('app.bsky.embed.recordWithMedia')) { | |
// Image can be in parent post or embed - check if the parent images contain the cid | |
if (!(post.embed.media.images ?? []).some(i => i.thumb.includes(media_cid))) { | |
post = post.embed.record.record; | |
} | |
} else if (embed_type.startsWith('app.bsky.embed.record')) { | |
// The parent has no media, so we should be in the embed? | |
post = post.embed.record; | |
} | |
let author = post.author; | |
let used_handle = author.handle.replace(/\.bsky\.social$/, ''); | |
let post_tid = post.uri.split('/app.bsky.feed.post/')[1]; | |
// TODO identify PNGs and others when they're actually supported, using post.record | |
img.dataset.mediaUrl = `https://${image_url.host}/img/feed_fullsize/plain/${author.did}/${media_cid}@jpeg`; | |
img.dataset.authorDid = author.did; | |
img.dataset.postTid = post_tid; | |
img.dataset.mediaCid = media_cid; | |
img.dataset.filename = `${used_handle} - ${media_cid} [Bluesky-${post_tid}].jpg`; | |
img._bms_post = post; | |
img.addEventListener('click', media_click_handler, {capture:true}); | |
} | |
} | |
function download_file_from_url(filename, media_url) { | |
log_info(`Downloading ${media_url} as "${filename}"`); | |
fetch(media_url).then(resp => { | |
if (resp.ok) { return resp.blob(); } | |
return Promise.reject(resp); | |
}).then(blob => download_blob(blob, filename)); | |
} | |
function download_blob(blob, filename) { | |
if (typeof window.navigator.msSaveBlob !== 'undefined') { | |
window.navigator.msSaveBlob(blob, filename); | |
return; | |
} | |
const blobURL = window.URL.createObjectURL(blob); | |
const tempLink = document.createElement('a'); | |
tempLink.style.display = 'none'; | |
tempLink.href = blobURL; | |
tempLink.setAttribute('download', filename); | |
if (typeof tempLink.download === 'undefined') { | |
tempLink.setAttribute('target', '_blank'); | |
} | |
document.body.appendChild(tempLink); | |
tempLink.click(); | |
document.body.removeChild(tempLink); | |
setTimeout(() => { window.URL.revokeObjectURL(blobURL); }, 100); | |
} | |
function setup_cdn_listeners() { | |
let mediaElement = document.querySelector('img, video'); | |
if (!mediaElement) { return; } | |
// Use post id from fragment | |
let fragment_match = document.location.hash && document.location.hash.match(/^#([0-9a-z]+)$/); | |
let post_tid = fragment_match?.[1] || null; | |
// https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:zoqnfxyjq57a4ambfllizw4s/bafkreifk4toewpfmg3lghynibsx4mp2fu77ufeufj3y63mkfzpaqgderfm@jpeg | |
let image_match = document.location.pathname.match(/img\/feed_\w+\/plain\/(did:[^\/]+)\/(baf[0-9a-z]+)(@\w*)?/); | |
async function get_profile(profile_did) { | |
// TODO Reconsider this, script storage? | |
// expiry? Or just rely on sessionStorage clearing... | |
let cached_profiles = JSON.parse(sessionStorage.getItem('bms-profile-cache') || '{}'); | |
let profile = cached_profiles[profile_did]; | |
if (!profile) { | |
// Fetch profile info | |
log_info(`Fetching profile for ${profile_did}...`); | |
let resp = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${profile_did}`); | |
let profile = await resp.json(); | |
log_info('Profile:', profile); | |
cached_profiles[profile_did] = profile; | |
sessionStorage.setItem('bms-profile-cache', JSON.stringify(cached_profiles)); | |
return profile; | |
} | |
return profile; | |
} | |
let goto_post = () => { | |
if (!image_match) { | |
show_notification(`Media URL not recognized`, 2000); | |
return; | |
} | |
let {1:profile_did, 2:media_cid} = image_match; | |
if (post_tid) { | |
// https://bsky.app/profile/vinetsu.bsky.social/post/3lbbrzm5msc2i | |
document.location = `https://bsky.app/profile/${profile_did}/post/${post_tid}`; | |
show_notification(`Navigating to post`, 1000); | |
} else { | |
document.location = `https://bsky.app/profile/${profile_did}`; | |
show_notification(`Unknown post, navigating to user`, 1000); | |
} | |
} | |
let download_current_media = () => { | |
if (!image_match) { | |
show_notification(`Media URL not recognized`, 2000); | |
return; | |
} | |
let {1:profile_did, 2:media_cid} = image_match; | |
// Fetch the profile for naming | |
get_profile(profile_did).then(profile => { | |
let post_source = post_tid ? `Bluesky-${post_tid}` : 'Bluesky'; | |
let media_url = `https://${document.location.host}/img/feed_fullsize/plain/${profile_did}/${media_cid}`; | |
let filename = `${profile.handle.replace('.bsky.social', '')} - ${media_cid} [${post_source}].jpg`; | |
download_file_from_url(filename, media_url); | |
show_notification(`Downloading<br/><kbd>${filename}</kbd>`, 2000); | |
}); | |
} | |
// Add mouse listeners to media element | |
mediaElement.addEventListener('click', e => { | |
if (e.ctrlKey && !e.altKey && !e.shiftKey) { | |
goto_post(); | |
return killEvent(e); | |
} else if (!e.ctrlKey && e.altKey && !e.shiftKey) { | |
download_current_media(); | |
return killEvent(e); | |
} | |
}, true); | |
// Firefox apparently doesn't do mouse events on video documents, so fallback keybinds it is | |
// d: download media | |
// s: go to source post | |
document.body.addEventListener('keypress', e => { | |
if (!e.ctrlKey && !e.shiftKey && !e.altKey) { | |
if (e.key == "s") { | |
goto_post(); | |
return killEvent(e); | |
} else if (e.key == "d") { | |
download_current_media(); | |
return killEvent(e); | |
} | |
} | |
}) | |
} | |
const style_elem = document.createElement('style'); | |
style_elem.innerText = ` | |
.bms-notification-holder { | |
z-index:9001; | |
position: fixed; | |
left: 50%; | |
bottom: 5px; | |
transform: translate(-50%, 0); | |
margin: 0 auto; | |
} | |
.bms-notification { | |
width: fit-content; | |
padding: 4px 6px; | |
margin: 5px auto auto auto; | |
border-radius: 8px; | |
text-align: center; | |
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
font-size: 1.2em; | |
color: rgb(255,255,255); | |
background:rgb(20,70,100); | |
border: 1px solid rgb(90,90,90); | |
} | |
`; | |
document.body.appendChild(style_elem); | |
const notification_holder = document.createElement('div'); | |
notification_holder.className = 'bms-notification-holder'; | |
document.body.appendChild(notification_holder); | |
const show_notification = (content, timeout, confirm) => { | |
let elem = document.createElement('div'); | |
elem.className = 'bms-notification'; | |
elem.innerHTML = content; | |
while (notification_holder.childElementCount >= 3) { | |
notification_holder.removeChild(notification_holder.lastElementChild); | |
} | |
notification_holder.insertBefore(elem, notification_holder.firstElementChild); | |
if (timeout) { | |
setTimeout(() => { | |
try { elem.remove(); } catch(e) {}; | |
}, timeout); | |
} | |
} | |
let last_failed = 0; | |
const safe_shenanigans = () => { | |
try { | |
bluesky_media_shenanigans(); | |
} catch (e) { | |
log_error("MutationObserver handler failed!"); | |
console.error(e); | |
let now = Date.now(); | |
if (now - last_failed > 10000) { | |
show_notification("MutationObserver handler failed, see console!", 5000); | |
last_failed = now; | |
} | |
} | |
} | |
// Check whether we're on Bluesky, or on a CDN file | |
if (document.location.host.match(/^bsky\.app$/)) { | |
const observer = new MutationObserver(mutations => { | |
observer.disconnect() | |
observer.takeRecords() | |
safe_shenanigans(); | |
observer.observe(document.body, { childList: true, subtree: true }); | |
}); | |
safe_shenanigans(); | |
observer.observe(document.body, { childList: true, subtree: true }); | |
} else if (document.location.host.match(/^cdn\.bsky\.app$/)) { | |
setup_cdn_listeners(); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment