Skip to content

Instantly share code, notes, and snippets.

@TheAMM
Last active December 13, 2024 04:21
Show Gist options
  • Save TheAMM/51b0a192520c3a4ea290621373a511b0 to your computer and use it in GitHub Desktop.
Save TheAMM/51b0a192520c3a4ea290621373a511b0 to your computer and use it in GitHub Desktop.
Bluesky Media Source userscript (#postid)
// ==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