Bluesky Profile Gallery Button
Something went wrong. Try again.
bsky-profile-gallery-button.js
· Created 4mo ago ·9.6 kB · 261 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261// ==UserScript==// @name Bluesky Profile Gallery Button (Persistent)// @namespace https://bsky.app/// @version 0.18// @description Adds a Gallery link to the Bluesky sidebar; supports profiles and feeds// @author Yernemm// @match https://bsky.app/*// @match https://witchsky.app/*// @grant none// @run-at document-start// @updateURL https://tangled.org/strings/yernemm.xyz/3mlmenu4lse22/raw// @downloadURL https://tangled.org/strings/yernemm.xyz/3mlmenu4lse22/raw// ==/UserScript==
(function() { 'use strict';
const SIDEBAR_SELECTOR = 'nav[role="navigation"]'; const ANCHOR_LINK_SELECTOR = 'a[href="/settings"]'; const BUTTON_ID = 'yernemm-gallery-nav'; const DEBUG = true;
let currentContext = null; // Last feed seen on a getFeed XHR — represents the currently-active feed in view let lastActiveFeed = null; // { did, rkey, atUri }
const log = (...args) => { if (DEBUG) console.log('[Gallery]', ...args); };
// ─── Network interception ─────────────────────────────────────────────── // Bluesky fires app.bsky.feed.getFeed with the AT-URI in the query string // every time you switch feed tabs. We grab the most recent one.
function parseFeedAtUri(atUri) { // at://did:plc:xxx/app.bsky.feed.generator/rkey const match = atUri.match(/at:\/\/(did:[^/]+)\/app\.bsky\.feed\.generator\/([^/?&]+)/); if (!match) return null; return { did: match[1], rkey: match[2], atUri }; }
function extractFeedFromUrl(url) { try { // The "feed" query param may be URL-encoded const u = new URL(url, location.origin); const feedParam = u.searchParams.get('feed'); if (!feedParam) return null; if (!u.pathname.endsWith('/app.bsky.feed.getFeed')) return null; return parseFeedAtUri(decodeURIComponent(feedParam)); } catch (e) { return null; } }
function recordFeedRequest(url) { const parsed = extractFeedFromUrl(url); if (!parsed) return; if (!lastActiveFeed || lastActiveFeed.atUri !== parsed.atUri) { lastActiveFeed = parsed; log(`Active feed updated → did=${parsed.did}, rkey=${parsed.rkey}`); updateButton(); } }
function installNetworkInterceptor() { // Intercept fetch (modern API used by Bluesky) const origFetch = window.fetch; window.fetch = function(input, init) { const url = typeof input === 'string' ? input : (input?.url || ''); if (url.includes('app.bsky.feed.getFeed')) recordFeedRequest(url); return origFetch.apply(this, arguments); };
// Also intercept XHR in case some calls go through that const origOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { if (typeof url === 'string' && url.includes('app.bsky.feed.getFeed')) { recordFeedRequest(url); } return origOpen.apply(this, arguments); };
log('Network interceptor installed'); }
// ─── Context detection ──────────────────────────────────────────────────
function getContextFromUrl() { // /profile/<handle> // /profile/<handle>/feed/<rkey> (feed authored by that handle) // /profile/<handle>/post/<id> (treat as profile context) const match = location.pathname.match(/^\/profile\/([^/]+)(?:\/(feed)\/([^/]+))?/); if (!match) return null;
const handle = match[1]; const isFeed = match[2] === 'feed'; const feedRkey = match[3];
if (isFeed && feedRkey) { return { type: 'feed-url', handle, feed: feedRkey }; } return { type: 'profile', handle }; }
function getContext() { const urlCtx = getContextFromUrl(); if (urlCtx) return urlCtx;
// Not on a profile URL — check if we're on the home page and have an active feed if (location.pathname === '/' && lastActiveFeed) { return { type: 'feed-home', did: lastActiveFeed.did, feed: lastActiveFeed.rkey, }; }
return null; }
function buildGalleryUrl(ctx) { const params = new URLSearchParams(); if (ctx.handle) { params.set('user', ctx.handle); } else if (ctx.did) { params.set('user', ctx.did); } if (ctx.feed) params.set('feed', ctx.feed); return `https://pics.yernemm.xyz/?${params.toString()}`; }
function contextLabel(ctx) { if (!ctx) return null; const who = ctx.handle || ctx.did || '?'; return ctx.feed ? `${who}/feed/${ctx.feed}` : who; }
// ─── Sidebar nav item ───────────────────────────────────────────────────
function createNavItem(templateLink) { const item = templateLink.cloneNode(true); item.id = BUTTON_ID; item.setAttribute('aria-label', 'View Gallery'); item.setAttribute('href', '#'); item.target = '_blank'; item.rel = 'noopener noreferrer';
const svg = item.querySelector('svg'); if (svg) { const path = svg.querySelector('path'); if (path) { path.setAttribute('d', 'M9 2a1 1 0 0 0-.894.553L7.382 4H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3.382l-.724-1.447A1 1 0 0 0 15 2H9Zm3 5a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z'); path.setAttribute('fill', '#FFFFFF'); } }
const label = item.querySelector('div[dir="auto"]'); if (label) { label.textContent = 'Gallery'; label.style.fontWeight = '400'; }
return item; }
function updateButton() { const ctx = getContext(); const btn = document.getElementById(BUTTON_ID); if (!btn) return;
if (ctx) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; btn.href = buildGalleryUrl(ctx); btn.setAttribute('aria-label', `View Gallery for ${contextLabel(ctx)}`); log(`Updated gallery link → ${contextLabel(ctx)} (${btn.href})`); } else { btn.style.opacity = '0.4'; btn.style.pointerEvents = 'none'; btn.href = '#'; btn.setAttribute('aria-label', 'View Gallery (open a profile or feed first)'); log(`No context — disabling gallery link`); }
currentContext = ctx; }
function tryInject() { if (document.getElementById(BUTTON_ID)) { updateButton(); return true; }
const sidebar = document.querySelector(SIDEBAR_SELECTOR); if (!sidebar) return false;
const template = sidebar.querySelector(ANCHOR_LINK_SELECTOR); if (!template) return false;
const item = createNavItem(template); template.parentNode.insertBefore(item, template); log(`✅ Gallery nav item injected into sidebar`); updateButton(); return true; }
// ─── Init ───────────────────────────────────────────────────────────────
// Install network interceptor immediately at document-start installNetworkInterceptor();
function startObservers() { // Watch DOM for sidebar appearing const bodyObserver = new MutationObserver(() => { if (!document.getElementById(BUTTON_ID)) { tryInject(); } }); bodyObserver.observe(document.body, { childList: true, subtree: true });
// SPA navigation hooks const _pushState = history.pushState.bind(history); const _replaceState = history.replaceState.bind(history);
history.pushState = function(...args) { _pushState(...args); updateButton(); }; history.replaceState = function(...args) { _replaceState(...args); updateButton(); }; window.addEventListener('popstate', updateButton);
tryInject(); }
if (document.body) { startObservers(); } else { document.addEventListener('DOMContentLoaded', startObservers); }
// Debug helpers window.__galleryDebug = { status: () => ({ currentContext, lastActiveFeed, pathname: location.pathname, urlContext: getContextFromUrl(), resolvedContext: getContext(), sidebarFound: !!document.querySelector(SIDEBAR_SELECTOR), buttonPresent: !!document.getElementById(BUTTON_ID), buttonHref: document.getElementById(BUTTON_ID)?.href, }), reinject: () => { document.getElementById(BUTTON_ID)?.remove(); return tryInject(); }, };
log('Script initialised v0.17. Debug: __galleryDebug.status() / .reinject()');})();