Created
December 19, 2025 09:12
-
-
Save 8ullyMaguire/4a1971165dc81904f190597850d81d06 to your computer and use it in GitHub Desktop.
Bluesky Reposts-to-Likes Percentage
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ```javascript | |
| // ==UserScript== | |
| // @name Bluesky Reposts-to-Likes Percentage | |
| // @namespace https://example.com/bsky-repost-like-percentage | |
| // @version 1.0.0 | |
| // @description Show reposts as a percentage of likes on Bluesky (bsky.app) by appending a parenthesized percentage next to the repost count. | |
| // @match https://bsky.app/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| /* ----- Utilities ----- */ | |
| // Parse a human-friendly count like "1.2K", "1,234", "1.2M" into a Number. | |
| function parseCount(text) { | |
| if (!text) return 0; | |
| // remove surrounding whitespace and commas, handle NBSPs | |
| const s = String(text).trim().replace(/\u00A0/g, '').replace(/,/g, ''); | |
| const m = /^([\d,.]*\d)([KkMmBb])?$/.exec(s); | |
| if (!m) { | |
| // fallback: try to parse float directly | |
| const v = parseFloat(s); | |
| return Number.isFinite(v) ? v : 0; | |
| } | |
| const num = parseFloat(m[1]); | |
| const suffix = (m[2] || '').toUpperCase(); | |
| switch (suffix) { | |
| case 'K': return num * 1e3; | |
| case 'M': return num * 1e6; | |
| case 'B': return num * 1e9; | |
| default: return num; | |
| } | |
| } | |
| // Format percentage: integer for >=10%, one decimal for <10% (e.g. "9.8%"), clamp, no weird values | |
| function formatPercent(raw) { | |
| if (!isFinite(raw) || raw < 0) return '—'; | |
| if (raw >= 100) return Math.round(raw) + '%'; | |
| if (raw >= 10) return Math.round(raw) + '%'; | |
| // show one decimal for small percentages | |
| return (Math.round(raw * 10) / 10).toFixed(1) + '%'; | |
| } | |
| // Find an ancestor that contains both repostCount and likeCount nodes | |
| function findSharedContainer(repostEl) { | |
| let el = repostEl; | |
| while (el && el !== document.documentElement) { | |
| if (el.querySelector && el.querySelector('[data-testid="repostCount"]') && el.querySelector('[data-testid="likeCount"]')) { | |
| return el; | |
| } | |
| el = el.parentElement; | |
| } | |
| return null; | |
| } | |
| /* ----- Main logic ----- */ | |
| // Process a single repost count element: compute percent and append/update UI node | |
| function processRepostElement(repostEl) { | |
| try { | |
| if (!repostEl || !(repostEl instanceof Element)) return; | |
| const container = findSharedContainer(repostEl); | |
| if (!container) return; | |
| const likeEl = container.querySelector('[data-testid="likeCount"]'); | |
| if (!likeEl) return; | |
| const repostText = repostEl.textContent || ''; | |
| const likeText = likeEl.textContent || ''; | |
| const repostNum = parseCount(repostText); | |
| const likeNum = parseCount(likeText); | |
| // compute percent (reposts / likes * 100) | |
| let percentText; | |
| if (likeNum === 0) { | |
| percentText = '—'; | |
| } else { | |
| const raw = (repostNum / likeNum) * 100; | |
| percentText = formatPercent(raw); | |
| } | |
| // create or update appended span | |
| // we append the span as a sibling inside the same parent as repostEl when possible. | |
| // use a stable class and data attribute so we can update it without duplicating. | |
| const APP_CLASS = 'bsky-repost-like-percentage'; | |
| let span = repostEl.parentElement && repostEl.parentElement.querySelector(`.${APP_CLASS}[data-source-id="${getElementUniqueId(repostEl)}"]`); | |
| if (!span) { | |
| span = document.createElement('span'); | |
| span.className = APP_CLASS; | |
| span.setAttribute('data-source-id', getElementUniqueId(repostEl)); | |
| // visual styling: small, muted, and ensuring separation from the number | |
| span.style.marginLeft = '6px'; | |
| span.style.fontSize = '12px'; | |
| span.style.fontWeight = '500'; | |
| span.style.opacity = '0.85'; | |
| span.style.userSelect = 'none'; | |
| // ensure consistent color with existing UI by inheriting current color | |
| span.style.color = 'inherit'; | |
| // put the text in parentheses per request | |
| span.textContent = `(${percentText})`; | |
| // append after repostEl within the same parent if possible | |
| if (repostEl.nextSibling) { | |
| repostEl.parentElement.insertBefore(span, repostEl.nextSibling); | |
| } else { | |
| repostEl.parentElement.appendChild(span); | |
| } | |
| } else { | |
| span.textContent = `(${percentText})`; | |
| } | |
| } catch (err) { | |
| // defensive: don't break the page if anything goes wrong | |
| console.error('Repost->Like percentage worker error:', err); | |
| } | |
| } | |
| // Create a stable unique id for an element to identify appended span. | |
| // We prefer to cache on the element using a Symbol to avoid collisions. | |
| const UID_SYMBOL = Symbol('bsky-repost-uid'); | |
| let uidCounter = 1; | |
| function getElementUniqueId(el) { | |
| if (!el) return 'unknown'; | |
| if (!el[UID_SYMBOL]) { | |
| el[UID_SYMBOL] = String(uidCounter++); | |
| } | |
| return el[UID_SYMBOL]; | |
| } | |
| // Find all repostCount elements on the page and process them. | |
| function processAllRepostElements() { | |
| const repostEls = document.querySelectorAll('[data-testid="repostCount"]'); | |
| repostEls.forEach(processRepostElement); | |
| } | |
| // Debounced mutation processing to avoid thrashing. | |
| let scheduled = null; | |
| function scheduleProcessAll(delay = 120) { | |
| if (scheduled) clearTimeout(scheduled); | |
| scheduled = setTimeout(() => { | |
| scheduled = null; | |
| processAllRepostElements(); | |
| }, delay); | |
| } | |
| /* ----- Observe DOM changes ----- */ | |
| const observer = new MutationObserver((mutations) => { | |
| // If any mutation touches attributes or adds/removes nodes, we rescan (debounced) | |
| let relevant = false; | |
| for (const m of mutations) { | |
| if (m.type === 'childList' && (m.addedNodes.length || m.removedNodes.length)) { | |
| relevant = true; | |
| break; | |
| } | |
| if (m.type === 'characterData' || m.type === 'attributes') { | |
| relevant = true; | |
| break; | |
| } | |
| } | |
| if (relevant) { | |
| scheduleProcessAll(); | |
| } | |
| }); | |
| // Start observing a sensible root (document.body). If body not ready, wait until it is. | |
| function startObserver() { | |
| if (!document.body) { | |
| window.addEventListener('DOMContentLoaded', startObserver, { once: true }); | |
| return; | |
| } | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| attributes: true, | |
| attributeFilter: ['data-testid', 'class', 'aria-pressed'] // monitor likely-changing attributes | |
| }); | |
| // initial pass | |
| scheduleProcessAll(50); | |
| } | |
| // Kick off | |
| startObserver(); | |
| // Also re-run when window gains focus (useful if counts updated while tab was backgrounded) | |
| window.addEventListener('focus', () => scheduleProcessAll(50)); | |
| })(); | |
| ``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment