Adds a download button to Bluesky images and videos.
// ==UserScript==
// @name Bluesky Image/Video Download Button
// @namespace KanashiiWolf
// @match https://bsky.app/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_info
// @grant GM_addStyle
// @grant GM_addValueChangeListener
// @version 2.5.3
// @author KanashiiWolf, the-nelsonator, coredumperror
// @description Adds a download button to Bluesky images and videos.
// @icon data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+DQogIDxkZWZzPg0KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZDEiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMCUiIHkyPSIxMDAlIj4NCiAgICAgIDxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMwMDg1ZmY7c3RvcC1vcGFjaXR5OjEiIC8+DQogICAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMxMTg1ZmU7c3RvcC1vcGFjaXR5OjEiIC8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8ZmlsdGVyIGlkPSJkcm9wU2hhZG93IiBoZWlnaHQ9IjEzMCUiPg0KICAgICAgPGZlR2F1c3NpYW5CbHVyIGluPSJTb3VyY2VBbHBoYSIgc3RkRGV2aWF0aW9uPSIzIi8+DQogICAgICA8ZmVPZmZzZXQgZHg9IjIiIGR5PSI0IiByZXN1bHQ9Im9mZnNldGJsdXIiLz4NCiAgICAgIDxmZUNvbXBvbmVudFRyYW5zZmVyPg0KICAgICAgICA8ZmVGdW5jQSB0eXBlPSJsaW5lYXIiIHNsb3BlPSIwLjMiLz4NCiAgICAgIDwvZmVDb21wb25lbnRUcmFuc2Zlcj4NCiAgICAgIDxmZU1lcmdlPg0KICAgICAgICA8ZmVNZXJnZU5vZGUvPg0KICAgICAgICA8ZmVNZXJnZU5vZGUgaW49IlNvdXJjZUdyYXBoaWMiLz4NCiAgICAgIDwvZmVNZXJnZT4NCiAgICA8L2ZpbHRlcj4NCiAgPC9kZWZzPg0KDQogIDwhLS0gQnV0dGVyZmx5IEJvZHkvV2luZ3MgLS0+DQogIDwhLS0gQSBzdHlsaXplZCBidXR0ZXJmbHkgc2hhcGUgYXBwcm94aW1hdGluZyB0aGUgQmx1ZXNreSBsb2dvIHZpYmUgLS0+DQogIDxwYXRoIGQ9Ik0yNTYsMjE4IGMwLDAgLTQyLC0xMjAgLTEzOCwtMTIwIGMtNTgsMCAtODgsNDAgLTg4LDkwIGMwLDYwIDUwLDkwIDEyOCwxMDAgYy02MCwxMCAtMTA4LDUwIC0xMDgsMTEwIGMwLDUwIDMwLDkwIDk4LDkwIGM2OCwwIDEwOCwtMTAwIDEwOCwtMTAwIHM0MCwxMDAgMTA4LDEwMCBjNjgsMCA5OCwtNDAgOTgsLTkwIGMwLC02MCAtNDgsLTEwMCAtMTA4LC0xMTAgYzc4LC0xMCAxMjgsLTQwIDEyOCwtMTAwIGMwLC01MCAtMzAsLTkwIC04OCwtOTAgYy05NiwwIC0xMzgsMTIwIC0xMzgsMTIwIHoiIGZpbGw9InVybCgjZ3JhZDEpIiAvPg0KDQogIDwhLS0gRG93bmxvYWQgQXJyb3cgLS0+DQogIDwhLS0gQ2VudGVyZWQgYW5kIG92ZXJsYWlkIC0tPg0KICA8cGF0aCBkPSJNMjU2LDQxMCBsLTExMC0xMTAgaDcwIFYxNTAgaDgwIHYxNTAgaDcwIEwyNTYsNDEwIHoiIGZpbGw9IiNmZmZmZmYiIGZpbHRlcj0idXJsKCNkcm9wU2hhZG93KSIvPg0KPC9zdmc+DQo=
// @license MIT
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// ==/UserScript==
(function () {
"use strict";
// ========================================================================
// 1. CONFIGURATION
// ========================================================================
const CONFIG = {
// Template for naming downloaded files
defaultTemplate: "@<%username>-bsky-<%post_id>-<%img_num>",
// Regex to extract the post ID from a standard Bluesky URL
postUrlRegex: /\/profile\/[^\/]+\/post\/[A-Za-z0-9]+/,
// DOM Selectors used to find specific elements on the page
selectors: {
images: 'img[src^="https://cdn.bsky.app/img/feed_thumbnail"]', // Target feed images
videos: 'video[poster^="https://video.bsky.app/watch"]', // Target feed videos
settings: '[href="/settings/account"]', // Target settings menu for injection
bookmark: '[data-testid="postBookmarkBtn"]', // Target bookmark button to place "Download All" next to
// Containers that hold a post link (Feed, Thread, Search results)
// Feed items are div[role="link"] with data-testid="feedItem-by-*" — covered by the role selector.
// Thread items use data-testid without role="link", so they need a separate selector.
postItem:
'[data-testid^="postThreadItem-by-"], div[role="link"]',
// Container for quoted posts (requires specific handling)
quotePost: '[aria-label^="Post by"]',
},
// SVG paths for UI icons
iconPath:
"M925.248 356.928l-258.176-258.176a64 64 0 0 0-45.248-18.752H144a64 64 0 0 0-64 64v736a64 64 0 0 0 64 64h736a64 64 0 0 0 64-64V402.176a64 64 0 0 0-18.752-45.248zM288 144h192V256H288V144z m448 736H288V736h448v144z m144 0H800V704a32 32 0 0 0-32-32H256a32 32 0 0 0-32 32v176H144v-736H224V288a32 32 0 0 0 32 32h256a32 32 0 0 0 32-32V144h77.824l258.176 258.176V880z",
checkPath: "M433 817L133 517l90-90 210 210L821 249l90 90z", // Simple checkmark for success state
// MIME type to file extension map
mimeToExt: {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
"video/mp4": "mp4",
"video/webm": "webm",
"video/ogg": "ogv",
},
};
// Pre-computed combined selector for images and videos
const MEDIA_SELECTOR = `${CONFIG.selectors.images}, ${CONFIG.selectors.videos}`;
// Shared regex for sanitizing filenames (used in multiple functions)
const SANITIZE_REGEX = /[/\\?%*:|"<>]/g;
// Pre-computed SVG icon markup (avoids string interpolation per button)
const SVG_ICON = `<svg viewBox="0 0 1024 1024"><path d="${CONFIG.iconPath}"></path></svg>`;
const SVG_CHECK = `<svg viewBox="0 0 1024 1024"><path d="${CONFIG.checkPath}"></path></svg>`;
const SVG_FAIL = '<svg viewBox="0 0 1024 1024"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 0 1-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"></path></svg>';
// In-memory tracking for processed elements (avoids DOM attribute reads/writes)
const processedMedia = new WeakSet();
const processedBookmarks = new WeakSet();
// Track whether settings UI has been injected (re-checks DOM in case of SPA navigation)
let settingsInjected = false;
let settingsButtonRef = null; // Cached DOM reference for fast contains() check
// In-memory cache for API responses (avoids redundant network requests per session)
const apiCache = new Map();
// Download HUD notifier — shows active/completed/failed counts
const notifier = (() => {
let el, activeEl, doneEl, failEl, hideTimer;
let active = 0, done = 0, failed = 0;
const SVG_SPINNER = '<svg viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>';
const SVG_OK = '<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7" stroke="currentColor" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SVG_X = '<svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" fill="none" stroke-width="2.5" stroke-linecap="round"/></svg>';
function ensure() {
if (el) return;
el = document.createElement("div");
el.className = "bsky-dl-notifier";
el.innerHTML = `<span class="bsky-n-active">${SVG_SPINNER}<b>0</b></span>` +
"<span class=\"bsky-n-sep\">|</span>" +
`<span class="bsky-n-done">${SVG_OK}<b>0</b></span>`;
activeEl = el.children[0].querySelector("b");
doneEl = el.children[2].querySelector("b");
document.body.appendChild(el);
}
function render() {
ensure();
activeEl.textContent = active;
doneEl.textContent = done;
if (failEl) failEl.textContent = failed;
if (failed > 0 && !failEl) {
const sep = document.createElement("span");
sep.className = "bsky-n-sep";
sep.textContent = "|";
const span = document.createElement("span");
span.className = "bsky-n-fail";
span.innerHTML = `${SVG_X}<b>${failed}</b>`;
span.title = "Click to dismiss";
span.onclick = () => { failed = 0; failEl = null; span.remove(); sep.remove(); render(); };
el.appendChild(sep);
el.appendChild(span);
failEl = span.querySelector("b");
}
const isActive = active > 0 || done > 0 || failed > 0;
el.classList.toggle("visible", isActive);
clearTimeout(hideTimer);
if (active === 0 && failed === 0 && done > 0) {
hideTimer = setTimeout(() => { done = 0; render(); }, 3000);
}
}
return {
start() { active++; render(); },
finish() { active = Math.max(0, active - 1); done++; render(); },
fail() { active = Math.max(0, active - 1); failed++; render(); },
};
})();
const WHATS_NEW = [
"Performance: Optimized DOM selectors and reduced redundant queries during page mutations.",
];
// ========================================================================
// 2. STATE & STYLES
// ========================================================================
// Retrieve user preferences and history from Tampermonkey storage
let filenameTemplate = GM_getValue("filename", CONFIG.defaultTemplate);
let downloadHistory = GM_getValue("dl_history", {});
// Inject Custom CSS for the buttons and settings UI
const css = `
/* Single Image Button - Top Left overlay */
.bsky-dl-btn {
cursor: pointer; z-index: 999; display: flex; align-items: center; justify-content: center;
position: absolute; left: 5px; top: 5px;
background: rgba(0, 0, 0, 0.5); color: white;
height: 30px; width: 30px; border-radius: 50%;
transition: background 0.2s, color 0.2s;
}
.bsky-dl-btn:hover { background: rgba(0, 0, 0, 0.8); }
.bsky-dl-btn svg { width: 16px; height: 16px; fill: currentColor; }
/* Download All Button - Placed in the post footer actions */
.bsky-dl-all-btn {
display: flex; align-items: center; justify-content: center;
cursor: pointer; padding: 5px; border-radius: 999px;
transition: background 0.2s, color 0.2s;
color: rgb(111, 131, 159); /* Matches native Bluesky action icon color */
margin-right: -4px;
}
.bsky-dl-all-btn:hover { background-color: rgba(0, 0, 0, 0.05); }
.bsky-dl-all-btn svg { width: 18px; height: 18px; fill: currentColor; }
/* Success State (Green Checkmark) */
.bsky-dl-btn.downloaded, .bsky-dl-all-btn.downloaded { color: #4caf50; }
.bsky-dl-btn.downloaded { background: rgba(0, 0, 0, 0.7); }
.bsky-dl-btn.downloaded svg, .bsky-dl-all-btn.downloaded svg { width: 20px; height: 20px; }
/* Loading State (Spinning) */
.bsky-dl-btn.loading, .bsky-dl-all-btn.loading { color: #208bfe; pointer-events: none; }
.bsky-dl-btn.loading { background: rgba(0, 0, 0, 0.7); }
.bsky-dl-btn.loading svg, .bsky-dl-all-btn.loading svg { animation: bsky-dl-spin 0.8s linear infinite; }
@keyframes bsky-dl-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Failed State (Red) */
.bsky-dl-btn.failed, .bsky-dl-all-btn.failed { color: #f44336; }
.bsky-dl-btn.failed { background: rgba(0, 0, 0, 0.7); }
/* Download HUD Notifier */
.bsky-dl-notifier {
display: none; position: fixed; left: 20px; bottom: 20px; z-index: 9999;
background: rgba(0, 0, 0, 0.85); color: #fff; border-radius: 10px;
padding: 8px 14px; font-size: 13px; font-weight: 600;
backdrop-filter: blur(10px); box-shadow: 0 2px 12px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.bsky-dl-notifier.visible { display: flex; align-items: center; gap: 10px; animation: bsky-dl-slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.bsky-dl-notifier span { display: flex; align-items: center; gap: 4px; }
.bsky-dl-notifier span svg { width: 14px; height: 14px; fill: currentColor; }
.bsky-dl-notifier .bsky-n-active { color: #208bfe; }
.bsky-dl-notifier .bsky-n-active svg { animation: bsky-dl-spin 0.8s linear infinite; }
.bsky-dl-notifier .bsky-n-done { color: #4caf50; }
.bsky-dl-notifier .bsky-n-fail { color: #f44336; cursor: pointer; }
.bsky-dl-notifier .bsky-n-sep { opacity: 0.3; }
@media (prefers-color-scheme: light) {
.bsky-dl-notifier { background: rgba(255, 255, 255, 0.95); color: #000; border: 1px solid #ddd; }
}
/* Settings UI - Config button */
.bsky-dl-settings-btn {
display: flex; align-items: center; justify-content: center;
margin-top: 10px; border: 2px solid; cursor: pointer; padding: 5px; font-weight: bold;
transition: all 0.2s;
border-radius: 4px;
}
.bsky-dl-settings-btn:hover { opacity: 0.8; }
/* Animations */
@keyframes bsky-dl-fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes bsky-dl-modalIn {
from { opacity: 0; transform: scale(0.96) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes bsky-dl-slideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* MODAL STYLES */
.bsky-dl-overlay {
position: fixed; left: 0; top: 0; width: 100%; height: 100%;
background-color: rgba(0,0,0,0.5); z-index: 2147483647;
backdrop-filter: blur(4px);
display: flex; justify-content: center; align-items: center;
animation: bsky-dl-fadeIn 0.15s ease-out;
}
.bsky-dl-modal {
background: #fff; color: #000;
border-radius: 8px; padding: 24px;
width: 400px; max-width: 90vw;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex; flex-direction: column;
transition: width 0.3s, max-width 0.3s;
animation: bsky-dl-modalIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-color-scheme: dark) {
.bsky-dl-modal { background: #161e27; color: #fff; }
}
.bsky-dl-modal.expanded { width: 900px; max-width: 95vw; }
.bsky-dl-modal h3 {
margin: 0 0 20px 0; text-align: center;
font-size: 20px; font-weight: 700;
}
/* Settings Form Elements */
.bsky-dl-option-group {
border: 1px solid rgba(128,128,128,0.2);
border-radius: 4px;
padding: 12px; margin-bottom: 12px;
background: rgba(128,128,128,0.05);
}
.bsky-dl-label {
display: block; margin-bottom: 8px;
font-size: 14px; font-weight: 600;
color: inherit;
}
.bsky-dl-textarea {
width: 100%; min-height: 80px;
background: rgba(255,255,255,0.1); color: inherit;
border: 1px solid rgba(128,128,128,0.3);
border-radius: 4px; padding: 8px;
font-family: monospace; font-size: 12px;
margin-top: 8px; box-sizing: border-box;
}
.bsky-dl-textarea:focus { outline: none; border-color: #208bfe; box-shadow: 0 0 0 2px rgba(32, 139, 254, 0.2); }
.bsky-dl-select {
margin-left: 8px; padding: 4px 8px;
border-radius: 4px; border: 1px solid rgba(128, 128, 128, 0.3);
background: var(--background, #fff); color: var(--text, #000);
cursor: pointer; font-size: 13px;
}
.bsky-dl-select option {
background: var(--background, #fff); color: var(--text, #000);
}
.bsky-dl-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.bsky-dl-tag {
background-color: transparent;
color: #208bfe;
border: 1px solid #208bfe;
padding: 4px 8px; border-radius: 4px;
font-size: 11px; font-weight: 700; cursor: pointer;
transition: 0.2s; font-family: monospace;
}
.bsky-dl-tag:hover { background-color: #208bfe; color: white; }
.bsky-dl-btn-primary {
background: #208bfe; color: white;
border: none; border-radius: 4px;
padding: 12px 24px; font-size: 15px; font-weight: 700;
cursor: pointer; transition: 0.2s;
width: 100%; text-align: center;
margin-top: 10px;
}
.bsky-dl-btn-primary:hover { opacity: 0.9; }
/* History Layout */
.bsky-dl-layout { display: flex; gap: 0; height: 400px; transition: gap 0.3s; }
.bsky-dl-modal.expanded .bsky-dl-layout { gap: 16px; }
.bsky-dl-list {
flex: 1; overflow-y: auto;
border: 1px solid rgba(128,128,128,0.2);
border-radius: 4px; padding: 4px;
}
.bsky-dl-row {
display: flex; justify-content: space-between; align-items: center;
padding: 8px; border-bottom: 1px solid rgba(128,128,128,0.1);
transition: background 0.2s;
}
.bsky-dl-row:hover:not(.active) { background: rgba(128,128,128,0.06); }
.bsky-dl-row.active { background: rgba(32, 139, 254, 0.1); border-left: 3px solid #208bfe; }
.bsky-dl-row:last-child { border-bottom: none; }
.bsky-dl-link {
color: inherit; text-decoration: none; font-size: 13px;
font-family: monospace; cursor: pointer;
}
.bsky-dl-link:hover { text-decoration: underline; color: #0085ff; }
.bsky-dl-row-actions { display: flex; gap: 5px; align-items: center; }
.bsky-dl-btn-sm {
background: transparent; color: #e11d48;
border: 1px solid #e11d48; border-radius: 4px;
font-size: 10px; padding: 2px 6px; cursor: pointer;
text-decoration: none;
}
.bsky-dl-btn-sm:hover { background: #e11d48; color: white; }
.bsky-dl-btn-sm.open { color: #208bfe; border-color: #208bfe; }
.bsky-dl-btn-sm.open:hover { background: #208bfe; color: white; }
/* Preview Pane */
.bsky-dl-preview {
flex: 0; width: 0; overflow: hidden;
border: 0; opacity: 0; transition: all 0.3s;
display: flex; justify-content: center; align-items: flex-start;
background: rgba(128,128,128,0.05); border-radius: 4px;
position: relative;
}
.bsky-dl-modal.expanded .bsky-dl-preview {
flex: 1.5; width: auto; opacity: 1;
border: 1px solid rgba(128,128,128,0.2);
padding: 10px; overflow-y: auto;
}
.bsky-dl-close-preview {
position: absolute; top: 5px; right: 10px;
font-size: 24px; cursor: pointer; opacity: 0.6; z-index: 10;
}
.bsky-dl-close-preview:hover { opacity: 1; color: #e11d48; }
/* Preview Card */
.bsky-card { width: 100%; height: 100%; overflow-y: auto; }
.bsky-card-header { display: flex; align-items: center; margin-bottom: 10px; }
.bsky-card-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; object-fit: cover; }
.bsky-card-user { display: flex; flex-direction: column; }
.bsky-card-name { font-weight: bold; font-size: 15px; }
.bsky-card-handle { font-size: 13px; opacity: 0.7; }
.bsky-card-text { font-size: 15px; margin-bottom: 10px; white-space: pre-wrap; line-height: 1.4; }
.bsky-card-media {
display: grid; gap: 4px; margin-bottom: 10px; border-radius: 8px; overflow: hidden;
width: 100%;
}
.bsky-media-1 { grid-template-columns: 1fr; }
.bsky-media-2, .bsky-media-3, .bsky-media-4 { grid-template-columns: 1fr 1fr; }
.bsky-card-img {
width: 100%; height: auto; max-height: 300px;
object-fit: contain; background: #000;
display: block; margin: 0 auto;
}
.bsky-card-date { font-size: 12px; opacity: 0.6; }
.bsky-card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }
.bsky-dl-modal-footer { margin-top: 15px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.bsky-dl-footer-group { display: flex; gap: 10px; }
.bsky-dl-modal-close {
background: transparent; color: inherit; border: 1px solid currentColor; padding: 8px 16px;
border-radius: 4px; cursor: pointer; font-weight: bold; opacity: 0.7;
}
.bsky-dl-modal-close:hover { opacity: 1; }
.bsky-dl-modal-close.danger { color: #e11d48; border-color: #e11d48; }
.bsky-dl-modal-close.danger:hover { background: #e11d48; color: white; }
/* Settings Preview Box */
.bsky-dl-preview-box {
margin-bottom: 10px; padding: 8px;
background: rgba(32, 139, 254, 0.08);
border: 1px solid rgba(32, 139, 254, 0.25);
border-radius: 4px;
font-size: 12px; font-family: monospace;
word-break: break-all;
}
.bsky-dl-preview-box .bsky-dl-preview-label { font-weight: bold; margin-bottom: 4px; opacity: 0.8; }
/* Secondary button variant */
.bsky-dl-btn-primary.secondary { background: transparent; color: #208bfe; border: 2px solid #208bfe; }
.bsky-dl-btn-primary.secondary:hover { background: rgba(32, 139, 254, 0.1); }
/* Cancel/text button variant */
.bsky-dl-btn-text {
background: transparent; color: inherit; border: none;
padding: 8px; cursor: pointer; font-size: 13px; opacity: 0.7;
}
.bsky-dl-btn-text:hover { opacity: 1; text-decoration: underline; }
/* Video Play Overlay */
.bsky-card-play {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0,0,0,0.6); border-radius: 50%;
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
color: white; pointer-events: none;
}
/* What's New Modal */
.bsky-wn-modal {
position: fixed; top: 20px; right: 20px; width: 300px;
background: #fff; color: #000;
border-radius: 8px; padding: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 2147483647; border: 1px solid rgba(128,128,128,0.2);
animation: slideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-color-scheme: dark) {
.bsky-wn-modal { background: #161e27; color: #fff; border-color: #2e4052; }
}
@keyframes slideIn { from { transform: translateX(120%); } to { transform: translateX(0); } }
.bsky-wn-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.bsky-wn-title { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; }
.bsky-wn-tag { background: #208bfe; color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.bsky-wn-close { cursor: pointer; font-size: 20px; opacity: 0.5; line-height: 1; }
.bsky-wn-close:hover { opacity: 1; }
.bsky-wn-list { list-style: none; padding: 0; margin: 0; font-size: 13px; line-height: 1.5; opacity: 0.9; }
.bsky-wn-list li { margin-bottom: 8px; display: flex; gap: 8px; }
.bsky-wn-list li:before { content: "•"; color: #208bfe; font-weight: bold; }
`;
// Add styles to the document
if (typeof GM_addStyle !== "undefined") {
GM_addStyle(css);
} else {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
// Register Menu Commands
GM_registerMenuCommand("Settings", openSettings);
GM_registerMenuCommand("View History", viewHistory);
GM_registerMenuCommand("Export History (JSON)", exportHistory);
// Sync history across tabs when another tab writes
GM_addValueChangeListener("dl_history", (_key, _old, newVal, remote) => {
if (!remote) return;
downloadHistory = newVal;
// Refresh individual media button states
document.querySelectorAll(".bsky-dl-btn").forEach(btn => {
if (btn.classList.contains("loading")) return;
const id = btn.dataset.historyId;
if (!id) return;
if (newVal[id]) {
markButtonAsDownloaded(btn);
} else {
revertButton(btn);
}
});
// Refresh "Download All" button states
document.querySelectorAll(".bsky-dl-all-btn").forEach(btn => {
if (btn.classList.contains("loading")) return;
const post = btn.closest(CONFIG.selectors.postItem);
if (post) updatePostButtonState(post, btn);
});
});
// ========================================================================
// 3. OBSERVER LOGIC
// ========================================================================
// Helper to process a single media item (image or video)
const processMediaItem = (item, isVideo) => {
if (!processedMedia.has(item)) {
processedMedia.add(item);
const post = item.closest(CONFIG.selectors.postItem);
injectDownloadButton(item, isVideo, post);
// Try to inject "Download All" if we found media but missed the bookmark button
if (post) {
const bookmark = post.querySelector(CONFIG.selectors.bookmark);
if (bookmark) injectDownloadAllButton(bookmark);
}
}
};
// Scan a specific node (and its children) for actionable elements
const scanNode = (node) => {
// A. SETTINGS: Check if the Settings header was loaded to inject our config UI
// Re-check DOM presence in case SPA navigation destroyed and recreated the page
// Uses cached element reference + contains() instead of querySelector (O(1) vs O(n))
if (settingsInjected && settingsButtonRef && !document.body.contains(settingsButtonRef)) {
settingsInjected = false;
settingsButtonRef = null;
}
if (!settingsInjected) {
const settingsHeader = node.querySelector(CONFIG.selectors.settings);
if (settingsHeader) injectSettingsUI(settingsHeader);
}
// B. DOWNLOAD ALL: Check for Bookmark buttons to inject "Download All" next to them
if (node.matches(CONFIG.selectors.bookmark)) {
injectDownloadAllButton(node);
} else {
node
.querySelectorAll(CONFIG.selectors.bookmark)
.forEach((btn) => injectDownloadAllButton(btn));
}
// C. MEDIA (images + videos in a single query)
if (node.matches(MEDIA_SELECTOR)) {
processMediaItem(node, node.tagName === "VIDEO");
} else {
node
.querySelectorAll(MEDIA_SELECTOR)
.forEach((el) => processMediaItem(el, el.tagName === "VIDEO"));
}
};
// Batch observer callback with requestAnimationFrame to reduce layout thrashing
let pendingNodes = [];
let rafScheduled = false;
const processPendingNodes = () => {
const nodes = pendingNodes;
pendingNodes = [];
rafScheduled = false;
for (const node of nodes) {
scanNode(node);
}
};
const handleMutations = (mutationList) => {
for (const mutation of mutationList) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
pendingNodes.push(node);
}
}
}
if (pendingNodes.length > 0 && !rafScheduled) {
rafScheduled = true;
requestAnimationFrame(processPendingNodes);
}
};
// Initialize the observer on the entire document body
new MutationObserver(handleMutations).observe(document.body, { childList: true, subtree: true });
// ========================================================================
// 4. UI INJECTION & EVENT HANDLING
// ========================================================================
// Settings Modal
function openSettings() {
const { overlay, removeOverlay } = createModalOverlay();
const modal = createElement("div", { class: "bsky-dl-modal" });
const title = createElement("h3", {}, {}, "Download Settings");
// Option Groups using helper
const historyGroup = createCheckboxOption("Remember download history", "save_history", true);
const packagingGroup = createCheckboxOption("Package multiple files into a ZIP", "enable_packaging", false);
// Option Group: Image Format
const formatGroup = createElement("div", { class: "bsky-dl-option-group" });
const formatLabel = createElement(
"label",
{ class: "bsky-dl-label" },
{ display: "flex", alignItems: "center" },
);
formatLabel.appendChild(document.createTextNode("Image format: "));
const formatSelect = createElement("select", { class: "bsky-dl-select" });
const formats = [
{ value: "original", label: "Original (as uploaded)" },
{ value: "jpeg", label: "JPEG" },
{ value: "png", label: "PNG" },
];
formats.forEach(f => {
const opt = createElement("option", { value: f.value }, {}, f.label);
formatSelect.appendChild(opt);
});
formatSelect.value = GM_getValue("image_format", "original");
formatSelect.onchange = () => GM_setValue("image_format", formatSelect.value);
formatLabel.appendChild(formatSelect);
formatGroup.appendChild(formatLabel);
// Option Group: Filename
const fileGroup = createElement("div", { class: "bsky-dl-option-group" });
// Preview Box
const previewContainer = createElement(
"div",
{ class: "bsky-dl-preview-box" },
);
const previewLabel = createElement(
"div",
{ class: "bsky-dl-preview-label" },
{},
"Preview:",
);
const previewText = createElement("div", {}, {}, "");
previewContainer.appendChild(previewLabel);
previewContainer.appendChild(previewText);
const label = createElement(
"label",
{ class: "bsky-dl-label" },
{},
"File Name Pattern",
);
const textarea = createElement("textarea", {
class: "bsky-dl-textarea",
spellcheck: "false",
});
textarea.value = GM_getValue("filename", CONFIG.defaultTemplate);
// Mock Data for Preview (keys match template tag names)
const mockData = {
uname: "oh8",
username: "oh8.bsky.social",
handle: "oh8",
display_name: "Oh Eight",
post_id: "3krmccyl4722w",
post_time: 1715347800000,
timestamp: Date.now(),
img_num: 0,
title: "A cute cat",
width: 1920,
height: 1080,
original_ext: "png",
};
const updatePreview = () => {
previewText.textContent = replaceTemplate(textarea.value, mockData) + ".jpg";
};
textarea.addEventListener("input", updatePreview);
// Tags Helper
const tagsContainer = createElement("div", { class: "bsky-dl-tags" });
const tags = [
{ tag: "<%uname>", desc: "Short username (e.g. oh8)" },
{ tag: "<%username>", desc: "Full username (e.g. oh8.bsky.social)" },
{ tag: "<%handle>", desc: "Short for .bsky.social, full for custom domains" },
{ tag: "<%display_name>", desc: "Display name (e.g. Oh Eight)" },
{ tag: "<%post_id>", desc: "Post ID (e.g. 3krmccyl4722w)" },
{ tag: "<%post_time>", desc: "Post Timestamp (e.g. 1715347800000)" },
{ tag: "<%timestamp>", desc: "Download Timestamp (e.g. 1550557810891)" },
{ tag: "<%img_num>", desc: "Image Number (e.g. 0, 1, 2)" },
{ tag: "<%title>", desc: "Alt Text (from image description)" },
{ tag: "<%width>", desc: "Original width in pixels (e.g. 1920)" },
{ tag: "<%height>", desc: "Original height in pixels (e.g. 1080)" },
{ tag: "<%original_ext>", desc: "Original file format (e.g. png, jpg)" },
];
tags.forEach((t) => {
const tagEl = createElement(
"span",
{ class: "bsky-dl-tag", title: t.desc },
{},
t.tag,
);
tagEl.onclick = () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value =
textarea.value.substring(0, start) +
t.tag +
textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + t.tag.length;
textarea.focus();
updatePreview();
};
tagsContainer.appendChild(tagEl);
});
fileGroup.appendChild(previewContainer);
fileGroup.appendChild(label);
fileGroup.appendChild(textarea);
fileGroup.appendChild(tagsContainer);
// Initialize Preview
updatePreview();
const saveBtn = createElement(
"button",
{ class: "bsky-dl-btn-primary" },
{},
"Save",
);
saveBtn.onclick = () => {
GM_setValue("filename", textarea.value);
filenameTemplate = textarea.value;
removeOverlay();
};
const footer = createElement("div", { class: "bsky-dl-modal-footer" }, { justifyContent: "flex-end" });
const closeBtn = createElement(
"button",
{ class: "bsky-dl-modal-close" },
{},
"Cancel",
);
closeBtn.onclick = () => removeOverlay();
footer.appendChild(closeBtn);
modal.appendChild(title);
modal.appendChild(historyGroup);
modal.appendChild(packagingGroup);
modal.appendChild(formatGroup);
modal.appendChild(fileGroup);
modal.appendChild(saveBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
// Injects the "Download All" button into the post footer
function injectDownloadAllButton(bookmarkBtn) {
if (processedBookmarks.has(bookmarkBtn)) return;
const postContainer = bookmarkBtn.closest(CONFIG.selectors.postItem);
if (!postContainer) return;
// Fast path: skip full querySelectorAll + filtering when no media exists
if (!postContainer.querySelector(MEDIA_SELECTOR)) return;
const mainMedia = getValidMedia(postContainer);
const quotedMedia = getQuotedMedia(postContainer);
if (mainMedia.length === 0 && quotedMedia.length === 0) return;
const container = bookmarkBtn.parentNode;
if (!container) return;
processedBookmarks.add(bookmarkBtn);
// Create the button element
const downloadAllBtn = document.createElement("div");
downloadAllBtn.className = "bsky-dl-all-btn";
downloadAllBtn.title = "Download All Media";
downloadAllBtn.innerHTML = SVG_ICON;
// Insert before the bookmark button
container.insertBefore(downloadAllBtn, bookmarkBtn);
// Initial state check (turn green if already downloaded)
if (mainMedia.length > 0) {
updatePostButtonState(postContainer, downloadAllBtn, mainMedia);
}
// Click Handler for Download All
downloadAllBtn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
// Re-query at click time (React may reuse DOM nodes)
const currentMain = getValidMedia(postContainer);
const currentQuoted = getQuotedMedia(postContainer);
let items;
let postInfo = null;
if (currentMain.length > 0 && currentQuoted.length > 0) {
// Both original and quoted post have media — ask the user
const mainHandle = parsePostPath(currentMain[0]).handle;
const quoteContainer = currentQuoted[0].closest(CONFIG.selectors.quotePost);
const ariaLabel = quoteContainer?.getAttribute("aria-label") ?? "";
const quotedHandle = ariaLabel.replace(/^Post by\s+/i, "") || "quoted post";
const choice = await selectPostDialog(mainHandle, quotedHandle);
if (!choice) return;
if (choice === "quoted") {
const resolved = await resolveQuotedPostInfo(quoteContainer);
if (!resolved) return;
items = currentQuoted;
postInfo = resolved;
} else {
items = currentMain;
}
} else if (currentQuoted.length > 0) {
// Only quoted post has media
const quoteContainer = currentQuoted[0].closest(CONFIG.selectors.quotePost);
const resolved = await resolveQuotedPostInfo(quoteContainer);
if (!resolved) return;
items = currentQuoted;
postInfo = resolved;
} else {
items = currentMain;
}
if (items.length === 0) return;
markButtonAsLoading(downloadAllBtn);
const enablePackaging = GM_getValue("enable_packaging", false);
if (enablePackaging && items.length > 1) {
await downloadAllZip(items, postContainer, downloadAllBtn, postInfo);
} else {
const anyFailed = await downloadAllSequential(items, postContainer, postInfo);
downloadAllBtn.classList.remove("loading");
if (anyFailed) {
markButtonAsFailed(downloadAllBtn);
}
}
});
}
// Injects individual download buttons onto images/videos
// Elements are pre-validated by the MEDIA_SELECTOR (feed_thumbnail images or video.bsky.app videos)
function injectDownloadButton(element, isVideo = false, postItem = null) {
// Button attaches to grandparent (the image/video wrapper)
const downloadBtnParent = element.parentElement?.parentElement;
if (!downloadBtnParent) return;
// Avoid injecting on very small thumbnails (likely avatars or quote previews)
if (downloadBtnParent.parentElement?.style.maxWidth === "100px") return;
// Avoid injecting on external link embed previews (e.g. URL card thumbnails)
const externalAnchor = element.closest('a[href^="http"]');
if (externalAnchor) return;
// Calculate a unique ID for history tracking
const postContainer =
postItem ?? element.closest(CONFIG.selectors.quotePost);
const validMedia = postContainer ? getValidMedia(postContainer) : [];
const index = validMedia.indexOf(element);
const safeIndex =
index >= 0 ? index : isVideo ? 0 : getImageNumberDOM(element);
const uniqueId = getBlobId(element, safeIndex);
const isDownloaded = uniqueId && !!downloadHistory[uniqueId];
// Create the button
const downloadBtn = document.createElement("div");
downloadBtn.className = isDownloaded
? "bsky-dl-btn downloaded"
: "bsky-dl-btn";
downloadBtn.innerHTML = isDownloaded ? SVG_CHECK : SVG_ICON;
if (uniqueId) downloadBtn.dataset.historyId = uniqueId;
// Ensure parent is relative so absolute positioning works
downloadBtnParent.style.position = "relative";
downloadBtnParent.appendChild(downloadBtn);
// Async history check for quoted post media (getBlobId fails since
// quoted posts no longer have <a> tags in the DOM)
if (!uniqueId) {
const quoteContainer = element.closest(CONFIG.selectors.quotePost);
if (quoteContainer) {
resolveQuotedPostInfo(quoteContainer).then(info => {
if (info) {
const resolvedId = `${info.postId}_${safeIndex}`;
downloadBtn.dataset.historyId = resolvedId;
if (downloadHistory[resolvedId]) {
markButtonAsDownloaded(downloadBtn);
}
}
}).catch(() => {});
}
}
// Click Handler for Single Download
// Re-derive values at click time to handle React DOM node reuse
downloadBtn.addEventListener("click", async (e) => {
e.stopPropagation();
e.preventDefault();
markButtonAsLoading(downloadBtn);
notifier.start();
const currentUrl = getMediaUrl(element, isVideo);
const currentContainer =
element.closest(CONFIG.selectors.postItem) ??
element.closest(CONFIG.selectors.quotePost);
const currentMedia = currentContainer ? getValidMedia(currentContainer) : [];
const currentIndex = currentMedia.indexOf(element);
const currentSafeIndex =
currentIndex >= 0 ? currentIndex : isVideo ? 0 : getImageNumberDOM(element);
const data = await prepareDownloadData(element, isVideo, currentSafeIndex);
if (!data) {
markButtonAsFailed(downloadBtn);
notifier.fail();
return;
}
data.btnElement = downloadBtn;
const blob = await fetchBlueskyBlob(currentUrl, data.isVideo, data.blobInfo);
if (blob) {
sendFile(data, blob);
notifier.finish();
} else {
markButtonAsFailed(downloadBtn);
notifier.fail();
}
});
// Prevent dragging the button
downloadBtn.addEventListener("mousedown", (e) => e.preventDefault());
}
// ========================================================================
// 5. DATA PREPARATION & LOGIC
// ========================================================================
// Parses the post URL from DOM to extract user info and post ID
function parsePostPath(element) {
try {
const postPath = getPostLink(element).split("/");
const username = postPath[2] ?? "unknown";
const uname = username.split(".")[0] ?? "unknown";
const handle = username.endsWith(".bsky.social") ? uname : username;
const postId = postPath[4] ?? "00000";
return { username, uname, handle, postId };
} catch (err) {
console.error("BSKY-DL: Error parsing URL", err);
return { username: "unknown", uname: "unknown", handle: "unknown", postId: "00000" };
}
}
// Extracts alt text from the nearest aria-label ancestor
function getAltText(element) {
try {
const ariaElem = element.closest("[aria-label]");
if (ariaElem) {
return ariaElem.getAttribute("aria-label").replace(SANITIZE_REGEX, "-");
}
} catch {}
return "";
}
// Fetches the post thread data from the API (cached per session)
async function fetchPostThread(username, postId) {
const cacheKey = `${username}/${postId}`;
if (apiCache.has(cacheKey)) return apiCache.get(cacheKey);
try {
const response = await fetch(buildPostThreadUrl(username, postId));
if (response.ok) {
const data = await response.json();
apiCache.set(cacheKey, data);
return data;
}
} catch (err) {
console.warn("BSKY-DL: API fetch failed, using fallbacks.", err);
}
return null;
}
// Extracts per-item metadata from already-fetched API data
function extractMediaMetadata(apiData, isVideo, imageNumber) {
if (!apiData) return { postTime: 0, altText: "", displayName: "", width: 0, height: 0 };
const post = apiData.thread.post;
const record = post.record;
const postTime = Date.parse(record.createdAt);
const displayName = (post.author?.displayName ?? "").replace(SANITIZE_REGEX, "-");
let embed = record.embed;
if (embed?.["$type"] === "app.bsky.embed.recordWithMedia") {
embed = embed.media;
}
let altText = "";
let width = 0;
let height = 0;
if (isVideo) {
const ratio = embed?.aspectRatio;
if (ratio) { width = ratio.width; height = ratio.height; }
} else if (embed?.images) {
const img = embed.images[imageNumber];
const apiAlt = img?.alt;
if (apiAlt?.trim()) {
altText = apiAlt.replace(SANITIZE_REGEX, "-");
}
const ratio = img?.aspectRatio;
if (ratio) { width = ratio.width; height = ratio.height; }
}
return { postTime, altText, displayName, width, height };
}
// Extracts blob DID, CID, and mimeType from the API response for a specific media item.
// More reliable than parsing CDN/poster URLs, which may change format or encode DIDs.
// Uses post.embed (view) for video CID, post.record.embed for image CIDs and mimeType.
function extractBlobInfo(apiData, isVideo, imageNumber) {
if (!apiData?.thread?.post) return null;
const post = apiData.thread.post;
const did = post.author?.did;
if (!did) return null;
// Record embed holds blob refs and mimeType for both images and videos
let recordEmbed = post.record?.embed;
if (recordEmbed?.["$type"] === "app.bsky.embed.recordWithMedia") {
recordEmbed = recordEmbed.media;
}
// Video: CID from view embed (most direct), mimeType from record embed
if (isVideo) {
let viewEmbed = post.embed;
if (viewEmbed?.["$type"] === "app.bsky.embed.recordWithMedia#view") {
viewEmbed = viewEmbed.media;
}
if (viewEmbed?.["$type"] === "app.bsky.embed.video#view" && viewEmbed.cid) {
const mimeType = recordEmbed?.video?.mimeType ?? "video/mp4";
return { did, cid: viewEmbed.cid, mimeType, playlist: viewEmbed.playlist ?? null };
}
return null;
}
// Image: CID and mimeType from record embed blob ref
if (recordEmbed?.images) {
const imageBlob = recordEmbed.images[imageNumber]?.image;
const cid = imageBlob?.ref?.["$link"];
if (cid) return { did, cid, mimeType: imageBlob.mimeType ?? "image/jpeg" };
}
return null;
}
// Extracts the quoted/embedded record from a parent post's API response
function extractQuotedRecord(apiData) {
if (!apiData?.thread?.post?.embed) return null;
const embed = apiData.thread.post.embed;
// Direct quote: app.bsky.embed.record#view
if (embed["$type"] === "app.bsky.embed.record#view" && embed.record) {
return embed.record;
}
// Quote with media: app.bsky.embed.recordWithMedia#view
if (embed["$type"] === "app.bsky.embed.recordWithMedia#view" && embed.record?.record) {
return embed.record.record;
}
return null;
}
// Resolves quoted post info via the parent post's API data.
// Returns { username, uname, handle, postId } or null if resolution fails.
// Also pre-caches the quoted post's API data from the parent response,
// so the subsequent fetchPostThread call avoids a second network request.
async function resolveQuotedPostInfo(quoteContainer) {
// Find the parent post container (go up past the quote)
const parentPost = quoteContainer.parentElement?.closest(CONFIG.selectors.postItem);
if (!parentPost) return null;
// Find a link in the parent post that is NOT inside the quoted post
const parentLinks = parentPost.querySelectorAll('a[href*="/post/"]');
let parentHref = null;
for (const link of parentLinks) {
if (!quoteContainer.contains(link)) {
const match = link.getAttribute("href").match(CONFIG.postUrlRegex);
if (match) { parentHref = match[0]; break; }
}
}
if (!parentHref) return null;
const parentPath = parentHref.split("/");
const parentUsername = parentPath[2];
const parentPostId = parentPath[4];
if (!parentUsername || !parentPostId) return null;
// Fetch parent post API data and extract the quoted record
const parentApiData = await fetchPostThread(parentUsername, parentPostId);
const quotedRecord = extractQuotedRecord(parentApiData);
if (!quotedRecord?.uri) return null;
// Parse AT URI: at://did:plc:xxx/app.bsky.feed.post/rkey
const uriParts = quotedRecord.uri.split("/");
const postId = uriParts[4];
const username = quotedRecord.author?.handle ?? uriParts[2];
const uname = username.split(".")[0] ?? "unknown";
const handle = username.endsWith(".bsky.social") ? uname : username;
// Pre-cache the quoted post's data from the parent response.
// The parent's quoted record contains author, record embed (value),
// and view embed (embeds[0]) — enough for extractBlobInfo/extractMediaMetadata.
const cacheKey = `${username}/${postId}`;
if (!apiCache.has(cacheKey) && quotedRecord.author && quotedRecord.value) {
apiCache.set(cacheKey, {
thread: {
post: {
author: quotedRecord.author,
embed: quotedRecord.embeds?.[0] ?? null,
record: quotedRecord.value,
},
},
});
}
return { username, uname, handle, postId };
}
// Coordinator: assembles download data from sub-helpers
async function prepareDownloadData(element, isVideo, index) {
let username, uname, handle, postId;
// Check if element is inside a quoted post embed
const quoteContainer = element.closest(CONFIG.selectors.quotePost);
const isInsideQuote = quoteContainer &&
quoteContainer.parentElement?.closest(CONFIG.selectors.postItem);
if (isInsideQuote) {
const resolved = await resolveQuotedPostInfo(quoteContainer);
if (resolved) ({ username, uname, handle, postId } = resolved);
}
// Fallback to DOM-based resolution
if (!postId) {
({ username, uname, handle, postId } = parsePostPath(element));
}
const imageNumber = index !== undefined ? index : isVideo ? 0 : getImageNumberDOM(element);
const domTitle = getAltText(element);
const apiData = await fetchPostThread(username, postId);
const { postTime, altText, displayName, width, height } =
extractMediaMetadata(apiData, isVideo, imageNumber);
return {
uname,
username,
handle,
displayName,
postId,
postTime: postTime || Date.now(),
imageNumber,
isVideo,
width,
height,
title: altText || domTitle || "Image",
uniqueId: `${postId}_${imageNumber}`,
blobInfo: extractBlobInfo(apiData, isVideo, imageNumber),
btnElement: null,
postContainer: null,
};
}
// Helper to find all valid media items in a container
// Filters out items belonging to Quoted Posts inside the current post
function getValidMedia(postContainer) {
if (!postContainer) return [];
const candidates = postContainer.querySelectorAll(MEDIA_SELECTOR);
const result = [];
for (let i = 0; i < candidates.length; i++) {
const el = candidates[i];
// Filter: Ensure media belongs to THIS post, not a nested quoted post
const quoteParent = el.closest(CONFIG.selectors.quotePost);
if (
quoteParent &&
quoteParent !== postContainer &&
postContainer.contains(quoteParent)
) {
continue;
}
// Filter: Ignore tiny thumbnails
const wrapper = el.parentElement?.parentElement?.parentElement;
if (wrapper && wrapper.style.maxWidth === "100px") {
continue;
}
// Filter: Ignore external link embed previews
if (el.closest('a[href^="http"]')) {
continue;
}
result.push(el);
}
return result;
}
// Finds media items that belong to a quoted post inside the container
function getQuotedMedia(postContainer) {
if (!postContainer) return [];
const candidates = postContainer.querySelectorAll(MEDIA_SELECTOR);
const result = [];
for (let i = 0; i < candidates.length; i++) {
const el = candidates[i];
const quoteParent = el.closest(CONFIG.selectors.quotePost);
if (
!quoteParent ||
quoteParent === postContainer ||
!postContainer.contains(quoteParent)
) {
continue;
}
const wrapper = el.parentElement?.parentElement?.parentElement;
if (wrapper && wrapper.style.maxWidth === "100px") continue;
if (el.closest('a[href^="http"]')) continue;
result.push(el);
}
return result;
}
// Updates the visual state of the "Download All" button
function updatePostButtonState(postContainer, specificBtn = null, precomputedMedia = null) {
if (!postContainer) return;
const btn = specificBtn || postContainer.querySelector(".bsky-dl-all-btn");
if (!btn) return;
const mediaItems = precomputedMedia || getValidMedia(postContainer);
if (mediaItems.length === 0) return;
// Extract postId once instead of calling getPostLink per media item
let postId;
try {
const link = getPostLink(mediaItems[0]);
const pathParts = link.split("/");
postId = pathParts.length >= 5 ? pathParts[4] : null;
} catch {
return;
}
if (!postId) return;
// Check if every item in this post is in our history
const allDownloaded = mediaItems.every((_, i) => downloadHistory[`${postId}_${i}`]);
if (allDownloaded) {
markButtonAsDownloaded(btn);
} else {
revertButton(btn);
}
}
// ========================================================================
// 6. NETWORK & DOWNLOAD
// ========================================================================
// Downloads a video by fetching HLS playlist segments from video.bsky.app.
// Used as fallback when getBlob fails (CORS redirect to user's PDS).
async function fetchVideoViaHLS(playlistUrl) {
// Fetch master playlist
const masterResp = await fetch(playlistUrl);
if (!masterResp.ok) return null;
const masterText = await masterResp.text();
// Parse: find the highest bandwidth stream
const lines = masterText.split("\n");
let bestUrl = null;
let bestBw = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("#EXT-X-STREAM-INF:")) {
const bwMatch = lines[i].match(/BANDWIDTH=(\d+)/);
const bw = bwMatch ? parseInt(bwMatch[1]) : 0;
if (bw > bestBw && i + 1 < lines.length) {
bestBw = bw;
bestUrl = lines[i + 1].trim();
}
}
}
if (!bestUrl) return null;
// Resolve relative URL for variant playlist
const baseUrl = playlistUrl.substring(0, playlistUrl.lastIndexOf("/") + 1);
const variantUrl = bestUrl.startsWith("http") ? bestUrl : baseUrl + bestUrl;
// Fetch variant playlist
const variantResp = await fetch(variantUrl);
if (!variantResp.ok) return null;
const variantText = await variantResp.text();
// Parse: collect all .ts segment URLs
const segLines = variantText.split("\n");
const segments = [];
const variantBase = variantUrl.substring(0, variantUrl.lastIndexOf("/") + 1);
for (const line of segLines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
segments.push(trimmed.startsWith("http") ? trimmed : variantBase + trimmed);
}
}
if (segments.length === 0) return null;
// Download all segments in parallel
const parts = await Promise.all(segments.map(async (segUrl) => {
const resp = await fetch(segUrl);
return resp.ok ? resp.arrayBuffer() : null;
}));
if (parts.some(p => p === null)) return null;
// Concatenate into a single video blob
return new Blob(parts, { type: "video/mp4" });
}
// Fetch the raw blob from Bluesky's CDN
// blobInfo: optional { did, cid, playlist } from API (preferred over URL parsing)
async function fetchBlueskyBlob(url, isVideo, blobInfo = null) {
let did, cid;
if (blobInfo) {
// Use API-sourced DID/CID (more reliable than URL parsing)
did = blobInfo.did;
cid = blobInfo.cid;
} else {
// Parse DID/CID from the CDN URL
// Video poster URLs may have percent-encoded colons (did%3Aplc%3A)
const urlArray = url.split("/");
const rawDid = isVideo ? urlArray[4] : urlArray[6];
const rawCid = isVideo ? urlArray[5] : urlArray[7]?.split("@")[0];
did = rawDid ? decodeURIComponent(rawDid) : "";
cid = rawCid ? decodeURIComponent(rawCid) : "";
}
// For images: check if user has a format preference
const imageFormat = !isVideo ? GM_getValue("image_format", "original") : null;
try {
if (!did || !cid) throw new Error("Could not parse DID/CID");
// If a specific image format is requested, use the CDN with format suffix
if (imageFormat && imageFormat !== "original") {
const cdnUrl = `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@${imageFormat}`;
const cdnResponse = await fetch(cdnUrl);
if (cdnResponse.ok) return await cdnResponse.blob();
console.warn("BSKY-DL: CDN format fetch failed, falling back to original.");
}
// Fetch via Bluesky Sync API (original quality)
// Note: bsky.social may redirect to the user's PDS which can fail due to CORS
const response = await fetch(
`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`,
);
if (!response.ok) throw new Error(`API Error: ${response.status}`);
return await response.blob();
} catch (err) {
console.warn(
"BSKY-DL: High-quality fetch failed, falling back.",
err,
);
try {
if (isVideo) {
// Video fallback: download via HLS segments from video.bsky.app
// (getBlob fails for videos due to CORS redirect to user's PDS)
if (blobInfo?.playlist) {
const hlsBlob = await fetchVideoViaHLS(blobInfo.playlist);
if (hlsBlob) return hlsBlob;
}
return null;
}
// Image fallback: CDN fullsize URL (better than DOM's feed_thumbnail)
const fallbackUrl = (did && cid)
? `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}`
: url;
const fallbackResponse = await fetch(fallbackUrl);
if (!fallbackResponse.ok)
throw new Error(`CDN Error: ${fallbackResponse.status}`);
return await fallbackResponse.blob();
} catch (fatalErr) {
console.error("BSKY-DL: All fetch attempts failed.", fatalErr);
return null;
}
}
}
// Builds a media data object for a single item in a batch download
function buildMediaItemData(base, item, i, apiData) {
const isVideo = item.tagName === "VIDEO";
const domTitle = getAltText(item);
const { postTime, altText, displayName, width, height } =
extractMediaMetadata(apiData, isVideo, i);
return {
...base,
displayName: displayName || base.displayName,
postTime: postTime || Date.now(),
imageNumber: i,
isVideo,
width,
height,
title: altText || domTitle || "Image",
uniqueId: `${base.postId}_${i}`,
blobInfo: extractBlobInfo(apiData, isVideo, i),
btnElement: null,
postContainer: null,
};
}
// Downloads all media items as a ZIP package
// postInfo: optional { username, uname, handle, postId } override (for quoted posts)
async function downloadAllZip(mediaItems, postContainer, downloadAllBtn, postInfo = null) {
if (typeof JSZip === "undefined") {
alert("BSKY-DL: JSZip library not loaded. Cannot create ZIP.");
return;
}
console.log("BSKY-DL: Starting ZIP packaging for", mediaItems.length, "items");
notifier.start();
const zip = new JSZip();
let zipName = "bluesky_media";
// Fetch post data once for all items in this post
const { username, uname, handle, postId } = postInfo || parsePostPath(mediaItems[0]);
const apiData = await fetchPostThread(username, postId);
const displayName = (apiData?.thread?.post?.author?.displayName ?? "").replace(SANITIZE_REGEX, "-");
const base = { uname, username, handle, displayName, postId };
const promises = mediaItems.map(async (item, i) => {
try {
const data = buildMediaItemData(base, item, i, apiData);
if (i === 0) {
const zipInfo = { ...data, imageNumber: mediaItems.length };
zipName = convertFilename(zipInfo);
}
const dlUrl = getMediaUrl(item, data.isVideo);
const blob = await fetchBlueskyBlob(dlUrl, data.isVideo, data.blobInfo);
if (blob) {
const ext = getExtensionFromBlob(blob);
const filename = `${convertFilename(data)}.${ext}`;
zip.file(filename, blob);
if (!data.btnElement) {
data.btnElement = item.parentElement?.parentElement?.querySelector(".bsky-dl-btn");
}
updateHistory(data);
if (data.btnElement) markButtonAsDownloaded(data.btnElement);
}
} catch (err) {
console.error("BSKY-DL: Error processing item", i, err);
}
});
await Promise.all(promises);
if (Object.keys(zip.files).length > 0) {
try {
const content = await zip.generateAsync({ type: "blob" });
const blobUrl = URL.createObjectURL(content);
fallbackDownload(blobUrl, `${zipName}.zip`);
updatePostButtonState(postContainer, downloadAllBtn, mediaItems);
notifier.finish();
} catch (err) {
console.error("BSKY-DL: ZIP Generation failed", err);
notifier.fail();
}
} else {
markButtonAsFailed(downloadAllBtn);
notifier.fail();
}
downloadAllBtn.classList.remove("loading");
}
// Downloads all media items sequentially (one at a time)
// postInfo: optional { username, uname, handle, postId } override (for quoted posts)
async function downloadAllSequential(mediaItems, postContainer, postInfo = null) {
if (mediaItems.length === 0) return;
// Fetch post data once for all items in this post
const { username, uname, handle, postId } = postInfo || parsePostPath(mediaItems[0]);
const apiData = await fetchPostThread(username, postId);
const displayName = (apiData?.thread?.post?.author?.displayName ?? "").replace(SANITIZE_REGEX, "-");
const base = { uname, username, handle, displayName, postId };
let anyFailed = false;
const itemDataList = mediaItems.map((item, i) => {
const data = buildMediaItemData(base, item, i, apiData);
data.btnElement = item.parentElement?.parentElement?.querySelector(".bsky-dl-btn");
data.postContainer = postContainer;
markButtonAsLoading(data.btnElement);
notifier.start();
return data;
});
for (let i = 0; i < itemDataList.length; i++) {
const data = itemDataList[i];
const item = mediaItems[i];
const dlUrl = getMediaUrl(item, data.isVideo);
const blob = await fetchBlueskyBlob(dlUrl, data.isVideo, data.blobInfo);
if (blob) {
sendFile(data, blob);
notifier.finish();
} else {
anyFailed = true;
markButtonAsFailed(data.btnElement);
notifier.fail();
}
}
return anyFailed;
}
// Triggers the browser download behavior
function sendFile(data, blob) {
// Construct filename and trigger download
const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`;
fallbackDownload(URL.createObjectURL(blob), filename);
// Update History and UI
updateHistory(data);
if (data.btnElement) {
markButtonAsDownloaded(data.btnElement);
}
// Update "Download All" status
const post = data.btnElement
? data.btnElement.closest(CONFIG.selectors.postItem)
: data.postContainer;
if (post) {
updatePostButtonState(post);
}
}
// Persist download history
function updateHistory(data) {
if (!GM_getValue("save_history", true)) return;
const uniqueId = data.uniqueId;
if (!uniqueId) return;
// Store rich data for the history viewer
downloadHistory[uniqueId] = {
username: data.username,
handle: data.handle,
postId: data.postId,
timestamp: Date.now(),
};
GM_setValue("dl_history", downloadHistory);
}
// Visual helper to turn button green
function markButtonAsDownloaded(btn) {
if (!btn) return;
btn.classList.remove("loading", "failed");
if (btn.classList.contains("downloaded")) return;
btn.classList.add("downloaded");
btn.innerHTML = SVG_CHECK;
}
// Visual helper to reset button to default download state
function revertButton(btn) {
if (!btn) return;
btn.classList.remove("downloaded", "loading");
btn.innerHTML = SVG_ICON;
}
// Show loading spinner on button
function markButtonAsLoading(btn) {
if (!btn) return;
btn.classList.add("loading");
}
// Show red error state, then revert to previous state after a delay
function markButtonAsFailed(btn) {
if (!btn) return;
const wasDownloaded = btn.classList.contains("downloaded");
btn.classList.remove("loading", "downloaded");
btn.classList.add("failed");
btn.innerHTML = SVG_FAIL;
setTimeout(() => {
if (btn.classList.contains("failed")) {
btn.classList.remove("failed");
if (wasDownloaded) {
btn.classList.add("downloaded");
btn.innerHTML = SVG_CHECK;
} else {
btn.innerHTML = SVG_ICON;
}
}
}, 3000);
}
// Reverts downloaded buttons for a specific post after history deletion
function revertButtonsForPost(postId) {
document.querySelectorAll(".bsky-dl-btn.downloaded, .bsky-dl-btn.loading, .bsky-dl-all-btn.downloaded, .bsky-dl-all-btn.loading").forEach((btn) => {
try {
const container = btn.closest(CONFIG.selectors.postItem) ||
btn.closest(CONFIG.selectors.quotePost);
if (!container) return;
const link = getPostLink(container);
const parts = link.split("/");
if (parts[4] === postId) {
revertButton(btn);
}
} catch {}
});
}
// ========================================================================
// 7. UTILITY HELPERS
// ========================================================================
// Creates a modal overlay with escape-to-close and click-outside-to-close
function createModalOverlay() {
const overlay = createElement("div", { class: "bsky-dl-overlay" });
const onEscape = (e) => {
if (e.key === "Escape") removeOverlay();
};
const removeOverlay = () => {
overlay.remove();
document.removeEventListener("keydown", onEscape);
};
overlay.addEventListener("click", (e) => {
if (e.target === overlay) removeOverlay();
});
document.addEventListener("keydown", onEscape);
return { overlay, removeOverlay };
}
// Promise-based dialog for choosing between original and quoted post media
function selectPostDialog(originalLabel, quotedLabel) {
return new Promise((resolve) => {
const overlay = createElement("div", { class: "bsky-dl-overlay" });
const close = (value) => {
resolve(value);
overlay.remove();
document.removeEventListener("keydown", onEscape);
};
const onEscape = (e) => {
if (e.key === "Escape") close(null);
};
document.addEventListener("keydown", onEscape);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
const modal = createElement("div", { class: "bsky-dl-modal" });
const title = createElement("h3", {}, {}, "Which post to download?");
const container = createElement("div", {}, {
display: "flex", flexDirection: "column", gap: "12px",
});
const originalBtn = createElement(
"button", { class: "bsky-dl-btn-primary" }, {},
`Original post (by ${originalLabel})`,
);
originalBtn.onclick = () => close("original");
const quotedBtn = createElement(
"button", { class: "bsky-dl-btn-primary secondary" }, {},
`Quoted post (by ${quotedLabel})`,
);
quotedBtn.onclick = () => close("quoted");
const cancelBtn = createElement(
"button", { class: "bsky-dl-btn-text" }, {},
"Cancel",
);
cancelBtn.onclick = () => close(null);
container.append(originalBtn, quotedBtn, cancelBtn);
modal.append(title, container);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
// Single-pass template tag replacement
function replaceTemplate(template, data) {
return template.replace(/<%(\w+)>/g, (match, key) => data[key] ?? match);
}
// Constructs Bluesky post thread API URL
function buildPostThreadUrl(username, postId) {
return `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://${username}/app.bsky.feed.post/${postId}&depth=0&parentHeight=0`;
}
// Creates a checkbox option group for settings modals
function createCheckboxOption(label, key, defaultVal) {
const group = createElement("div", { class: "bsky-dl-option-group" });
const labelEl = createElement(
"label",
{ class: "bsky-dl-label" },
{ display: "flex", alignItems: "center", cursor: "pointer" },
);
const input = createElement(
"input",
{ type: "checkbox" },
{ marginRight: "8px" },
);
input.checked = GM_getValue(key, defaultVal);
input.onchange = () => GM_setValue(key, input.checked);
labelEl.appendChild(input);
labelEl.appendChild(document.createTextNode(label));
group.appendChild(labelEl);
return group;
}
// Extracts the appropriate media URL from an element
function getMediaUrl(element, isVideo) {
return isVideo ? element.poster : element.src ?? element.poster;
}
// Helper to create elements with attributes and styles
function createElement(tag, attributes = {}, styles = {}, text = "") {
const el = document.createElement(tag);
for (const key in attributes) el.setAttribute(key, attributes[key]);
Object.assign(el.style, styles);
if (text) el.textContent = text;
return el;
}
// Clear all history
function clearHistory() {
if (
!confirm(
"Are you sure you want to clear all download history? This cannot be undone.",
)
)
return;
downloadHistory = {};
GM_setValue("dl_history", {});
// Reset UI buttons on the page
document.querySelectorAll(".bsky-dl-btn.downloaded, .bsky-dl-all-btn.downloaded").forEach((btn) => {
revertButton(btn);
});
// Update Modal if open
const list = document.querySelector(".bsky-dl-list");
if (list) {
list.innerHTML =
'<div style="padding:10px; text-align:center; opacity:0.7">No history found.</div>';
}
// Clear Preview
const preview = document.querySelector(".bsky-dl-preview");
if (preview) {
preview.innerHTML = "";
const placeholder = createElement(
"div",
{},
{ opacity: "0.6" },
"Select an item to view details.",
);
preview.appendChild(placeholder);
}
}
// Export history as JSON
function exportHistory() {
const entries = Object.entries(downloadHistory);
if (entries.length === 0) {
alert("No history to export.");
return;
}
const exportData = entries.map(([id, entry]) => {
if (entry === true) {
return { id, legacy: true };
}
return {
id,
...entry,
url: `https://bsky.app/profile/${entry.username}/post/${entry.postId}`,
};
});
const blob = new Blob([JSON.stringify(exportData, null, 4)], {
type: "application/json",
});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `bsky_download_history_${Date.now()}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 10000);
}
// Injects the Settings UI into the Bluesky settings page
function injectSettingsUI(node) {
settingsInjected = true;
const container = node.parentNode;
const settingsBtn = settingsButtonRef = createElement(
"div",
{ class: "bsky-dl-settings-btn" },
{},
`DL Settings v${GM_info.script.version}`,
);
settingsBtn.addEventListener("click", (e) => {
e.preventDefault();
openSettings();
});
const viewHistoryBtn = createElement(
"div",
{ class: "bsky-dl-settings-btn" },
{ color: "#208bfe", borderColor: "#208bfe", marginTop: "10px" },
"View Download History",
);
viewHistoryBtn.addEventListener("click", (e) => {
e.preventDefault();
viewHistory();
});
container.insertBefore(settingsBtn, node);
container.insertBefore(viewHistoryBtn, node);
}
// View History Modal
function viewHistory() {
const { overlay, removeOverlay } = createModalOverlay();
const modal = createElement("div", { class: "bsky-dl-modal" });
const title = createElement(
"h3",
{},
{ textAlign: "center", margin: "0 0 15px 0" },
"Download History",
);
const layout = createElement("div", { class: "bsky-dl-layout" });
const list = createElement("div", { class: "bsky-dl-list" });
const preview = createElement("div", { class: "bsky-dl-preview" });
const previewPlaceholder = createElement(
"div",
{},
{ opacity: "0.6" },
"Select an item to view details.",
);
preview.appendChild(previewPlaceholder);
const groupedHistory = {};
for (const key in downloadHistory) {
const entry = downloadHistory[key];
// Handle legacy boolean entries
if (entry === true) continue;
if (!groupedHistory[entry.postId]) {
groupedHistory[entry.postId] = {
...entry,
// Keep track of all keys associated with this post for deletion
keys: [],
};
}
groupedHistory[entry.postId].keys.push(key);
// Update to the most recent entry's metadata
if (entry.timestamp > groupedHistory[entry.postId].timestamp) {
groupedHistory[entry.postId].timestamp = entry.timestamp;
groupedHistory[entry.postId].username = entry.username;
groupedHistory[entry.postId].handle = entry.handle;
}
}
const sortedPostIds = Object.keys(groupedHistory).sort((a, b) => {
return groupedHistory[b].timestamp - groupedHistory[a].timestamp;
});
if (sortedPostIds.length === 0) {
list.innerHTML =
'<div style="padding:10px; text-align:center; opacity:0.7">No history found.</div>';
} else {
sortedPostIds.forEach((postId) => {
const entry = groupedHistory[postId];
const row = createHistoryRow(
entry,
postId,
modal,
list,
preview,
previewPlaceholder,
);
list.appendChild(row);
});
}
const footer = createElement("div", { class: "bsky-dl-modal-footer" });
const leftGroup = createElement("div", { class: "bsky-dl-footer-group" });
const clearBtn = createElement(
"button",
{ class: "bsky-dl-modal-close danger" },
{},
"Clear History",
);
clearBtn.onclick = () => clearHistory();
const exportBtn = createElement(
"button",
{ class: "bsky-dl-modal-close" },
{},
"Export JSON",
);
exportBtn.onclick = () => exportHistory();
const closeBtn = createElement(
"button",
{ class: "bsky-dl-modal-close" },
{},
"Close",
);
closeBtn.onclick = () => removeOverlay();
leftGroup.appendChild(clearBtn);
leftGroup.appendChild(exportBtn);
footer.appendChild(leftGroup);
footer.appendChild(closeBtn);
layout.appendChild(list);
layout.appendChild(preview);
modal.appendChild(title);
modal.appendChild(layout);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function createHistoryRow(
entry,
postId,
modal,
list,
preview,
previewPlaceholder,
) {
const row = createElement("div", { class: "bsky-dl-row" });
const idLink = createElement("div", { class: "bsky-dl-link" }, {}, postId);
idLink.addEventListener("click", async () => {
modal.classList.add("expanded");
preview.innerHTML = "";
list
.querySelectorAll(".bsky-dl-row")
.forEach((r) => r.classList.remove("active"));
row.classList.add("active");
const closePrev = createElement(
"div",
{ class: "bsky-dl-close-preview" },
{},
"×",
);
closePrev.onclick = (e) => {
e.stopPropagation();
modal.classList.remove("expanded");
list
.querySelectorAll(".bsky-dl-row")
.forEach((r) => r.classList.remove("active"));
preview.innerHTML = "";
preview.appendChild(previewPlaceholder);
};
preview.appendChild(closePrev);
const loading = createElement(
"div",
{},
{ opacity: "0.6" },
"Loading preview...",
);
preview.appendChild(loading);
try {
const json = await fetchPostThread(entry.username, entry.postId);
if (!json) throw new Error("Failed to fetch post");
const post = json.thread.post;
const record = post.record;
const author = post.author;
loading.remove();
const card = createPreviewCard(author, record, post);
preview.appendChild(card);
} catch (e) {
console.error(e);
loading.textContent = "Error loading preview. Post may be deleted.";
}
});
const actions = createElement(
"div",
{ class: "bsky-dl-row-actions" },
);
const openBtn = createElement(
"a",
{
href: `https://bsky.app/profile/${entry.username}/post/${entry.postId}`,
target: "_blank",
class: "bsky-dl-btn-sm open",
},
{},
"OPEN ↗",
);
actions.appendChild(openBtn);
const delBtn = createElement(
"div",
{ class: "bsky-dl-btn-sm" },
{},
"DELETE",
);
delBtn.onclick = () => {
if (confirm(`Remove post ${postId} and all its media from history?`)) {
entry.keys.forEach((k) => delete downloadHistory[k]);
GM_setValue("dl_history", downloadHistory);
revertButtonsForPost(postId);
row.remove();
}
};
actions.appendChild(delBtn);
row.appendChild(idLink);
row.appendChild(actions);
return row;
}
function createPreviewCard(author, record, post) {
const card = createElement("div", { class: "bsky-card" });
const header = createElement("div", { class: "bsky-card-header" });
const avatar = createElement("img", {
class: "bsky-card-avatar",
src: author.avatar || "",
});
const user = createElement("div", { class: "bsky-card-user" });
const name = createElement(
"div",
{ class: "bsky-card-name" },
{},
author.displayName || author.handle,
);
const handle = createElement(
"div",
{ class: "bsky-card-handle" },
{},
`@${author.handle}`,
);
user.appendChild(name);
user.appendChild(handle);
header.appendChild(avatar);
header.appendChild(user);
const text = createElement(
"div",
{ class: "bsky-card-text" },
{},
record.text || "",
);
const mediaContainer = createElement("div", { class: "bsky-card-media" });
let images = [];
let videoThumbnail = null;
// Helper to extract media from embed
const extractMedia = (embed) => {
if (!embed) return;
if (embed.images) {
images = embed.images;
} else if (embed.thumbnail) {
videoThumbnail = embed.thumbnail; // External media often has a thumbnail
}
if (embed.media) {
extractMedia(embed.media);
}
};
if (post.embed) {
extractMedia(post.embed);
if (
post.embed["$type"] === "app.bsky.embed.video#view" &&
post.embed.thumbnail
) {
videoThumbnail = post.embed.thumbnail;
}
}
if (images.length > 0) {
mediaContainer.classList.add(`bsky-media-${Math.min(images.length, 4)}`);
images.forEach((img) => {
const imgEl = createElement("img", {
class: "bsky-card-img",
src: img.fullsize || img.thumb,
});
mediaContainer.appendChild(imgEl);
});
} else if (videoThumbnail) {
mediaContainer.classList.add("bsky-media-1");
const imgEl = createElement("img", {
class: "bsky-card-img",
src: videoThumbnail,
});
const playOverlay = createElement(
"div",
{ class: "bsky-card-play" },
{},
"▶",
);
const wrapper = createElement("div", {}, { position: "relative" });
wrapper.appendChild(imgEl);
wrapper.appendChild(playOverlay);
mediaContainer.appendChild(wrapper);
}
card.appendChild(header);
card.appendChild(text);
if (mediaContainer.hasChildNodes()) card.appendChild(mediaContainer);
const footerRow = createElement(
"div",
{ class: "bsky-card-footer" },
{},
);
const date = createElement(
"div",
{ class: "bsky-card-date" },
{},
new Date(record.createdAt).toLocaleString(),
);
footerRow.appendChild(date);
card.appendChild(footerRow);
return card;
}
// Generates the Unique ID for history checking
function getBlobId(element, index) {
try {
const link = getPostLink(element);
const pathParts = link.split("/");
// Expecting format: /profile/{user}/post/{id}
if (pathParts.length >= 5) {
const postId = pathParts[4];
return `${postId}_${index}`;
}
} catch {
return null;
}
return null;
}
// Gets the index of an image within its post (filters out quote post media)
function getImageNumberDOM(image) {
try {
const ancestor =
image.closest(CONFIG.selectors.postItem) ?? document.body;
const validMedia = getValidMedia(ancestor);
const index = validMedia.indexOf(image);
return index >= 0 ? index : 0;
} catch {
return 0;
}
}
// Robust function to find the Link associated with the Post
// This is critical for determining the Post ID
function getPostLink(element) {
// STRATEGY 1: Traverse UP to find a parent anchor with a post URL
let currentEl = element;
while (currentEl) {
// Stop if we hit a thread item wrapper to avoid grabbing wrong parent link
if (currentEl.dataset?.testid?.startsWith("postThreadItem")) {
break;
}
// Check if current element is an anchor with a post href
if (currentEl.tagName === "A") {
const href = currentEl.getAttribute("href");
const match = href && href.match(CONFIG.postUrlRegex);
if (match) return match[0];
}
// Check direct child anchors by iterating children (avoids selector engine per level)
for (const child of currentEl.children) {
if (child.tagName !== "A") continue;
const href = child.getAttribute("href");
if (href && href.includes("/post/")) {
const match = href.match(CONFIG.postUrlRegex);
if (match) return match[0];
}
}
currentEl = currentEl.parentElement;
}
// STRATEGY 2: Fallback - Search DOWN into the container
// Useful for Expanded Views where the media isn't wrapped in the link anchor
const container =
element.closest(CONFIG.selectors.postItem) ||
element.closest(CONFIG.selectors.quotePost);
if (container) {
// Iterate all potential links to avoid grabbing one from a NESTED quote
const links = container.querySelectorAll('a[href*="/post/"]');
for (const link of links) {
const linkQuoteParent = link.closest(CONFIG.selectors.quotePost);
// Skip links belonging to nested quotes (child of current container)
if (
linkQuoteParent &&
linkQuoteParent !== container &&
container.contains(linkQuoteParent)
) {
continue;
}
const match = link.getAttribute("href").match(CONFIG.postUrlRegex);
if (match) return match[0];
}
}
return window.location.pathname;
}
// Helper to map MIME types to file extensions
function getExtensionFromBlob(blob) {
if (CONFIG.mimeToExt[blob.type]) return CONFIG.mimeToExt[blob.type];
if (blob.type && blob.type.startsWith("video/")) return "mp4";
return "jpg";
}
// Fallback download method using anchor tag
function fallbackDownload(blobUrl, filename) {
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
}
// Replaces placeholders in the filename template and sanitizes
function convertFilename(data) {
const ext = data.blobInfo?.mimeType ? (CONFIG.mimeToExt[data.blobInfo.mimeType] ?? "") : "";
return replaceTemplate(filenameTemplate, {
uname: data.uname,
username: data.username,
handle: data.handle,
display_name: data.displayName ?? "",
post_id: data.postId,
post_time: data.postTime,
timestamp: Date.now(),
img_num: data.imageNumber,
title: data.title,
width: data.width ?? 0,
height: data.height ?? 0,
original_ext: ext,
}).replace(SANITIZE_REGEX, "-");
}
// ========================================================================
// 8. WHAT'S NEW MODAL
// ========================================================================
function showWhatsNew() {
const modal = createElement("div", { class: "bsky-wn-modal" });
// Header
const header = createElement("div", { class: "bsky-wn-header" });
const title = createElement(
"div",
{ class: "bsky-wn-title" },
{},
"What's New",
);
const versionTag = createElement(
"span",
{ class: "bsky-wn-tag" },
{},
`v${GM_info.script.version}`,
);
title.appendChild(versionTag);
const closeBtn = createElement("div", { class: "bsky-wn-close" }, {}, "×");
closeBtn.onclick = () => {
GM_setValue("last_version", GM_info.script.version);
modal.style.transform = "translateX(120%)";
setTimeout(() => modal.remove(), 500);
};
header.appendChild(title);
header.appendChild(closeBtn);
const list = createElement("ul", { class: "bsky-wn-list" });
WHATS_NEW.forEach((item) => {
const li = createElement("li", {}, {}, item);
list.appendChild(li);
});
modal.appendChild(header);
modal.appendChild(list);
document.body.appendChild(modal);
}
function checkVersion() {
const lastVersion = GM_getValue("last_version", "0.0.0");
if (lastVersion !== GM_info.script.version) {
// Delay slightly to ensure page is interactive
setTimeout(showWhatsNew, 1500);
}
}
checkVersion();
})();