Skip to content

Instantly share code, notes, and snippets.

@vdeemann
Created May 24, 2026 17:55
Show Gist options
  • Select an option

  • Save vdeemann/c84c29ceeed3e2f16b11f72bfbb3321c to your computer and use it in GitHub Desktop.

Select an option

Save vdeemann/c84c29ceeed3e2f16b11f72bfbb3321c to your computer and use it in GitHub Desktop.
Tracks posts you scroll past on Tumblr's Explore page. On the next page refresh, those posts are hidden — before they paint, no flash.
// ==UserScript==
// @name Tumblr Explore - Hide Seen Posts
// @namespace https://github.com/vdeemann
// @version 2.2.2
// @description Tracks posts you scroll past on Tumblr's Explore page. On the next page refresh, those posts are hidden — before they paint, no flash.
// @author Dee
// @match https://www.tumblr.com/explore*
// @match https://www.tumblr.com/dashboard/explore*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// -------------------- config & storage keys --------------------
const STORAGE_KEY = 'tumblr_explore_seen_posts';
const PANEL_POS_KEY = 'tumblr_explore_panel_pos';
const PANEL_COLLAPSED_KEY = 'tumblr_explore_panel_collapsed';
const DWELL_KEY = 'tumblr_explore_dwell_ms';
const VISIBILITY_KEY = 'tumblr_explore_visibility_ratio';
const MAX_STORED_IDS = 10000;
const DEFAULT_DWELL_MS = 400;
const DEFAULT_VISIBILITY_RATIO = 0.2;
// Default anchor: bottom-right, clearing Tumblr's footer row
const DEFAULT_RIGHT = 20;
const DEFAULT_BOTTOM = 60;
let VIEW_DWELL_MS = GM_getValue(DWELL_KEY, DEFAULT_DWELL_MS);
let VIEW_VISIBILITY_RATIO = GM_getValue(VISIBILITY_KEY, DEFAULT_VISIBILITY_RATIO);
// -------------------- seen-set (loaded synchronously at document-start) --------------------
function loadSeen() {
try { return new Set(JSON.parse(GM_getValue(STORAGE_KEY, '[]'))); }
catch (e) { return new Set(); }
}
function saveSeen(set) {
let arr = Array.from(set);
if (arr.length > MAX_STORED_IDS) arr = arr.slice(arr.length - MAX_STORED_IDS);
GM_setValue(STORAGE_KEY, JSON.stringify(arr));
}
let seen = loadSeen();
const seenAtLoad = new Set(seen); // snapshot for "only hide what was seen before this tab loaded"
let pendingSave = false;
function queueSave() {
if (pendingSave) return;
pendingSave = true;
setTimeout(() => {
saveSeen(seen);
pendingSave = false;
updatePanelCount();
}, 500);
}
// -------------------- PRE-PAINT HIDING --------------------
// Inject CSS as early as possible — before <body> even exists — so any
// element flagged with [data-tm-hide] is invisible from the first paint.
function injectHideCSS() {
const css = `
[data-tm-hide="1"] { display: none !important; }
`;
if (document.head) {
const style = document.createElement('style');
style.id = 'tm-tumblr-prehide-style';
style.textContent = css;
document.head.appendChild(style);
} else {
// <head> doesn't exist yet — inject as soon as it does
const obs = new MutationObserver(() => {
if (document.head) {
obs.disconnect();
const style = document.createElement('style');
style.id = 'tm-tumblr-prehide-style';
style.textContent = css;
document.head.appendChild(style);
}
});
obs.observe(document.documentElement, { childList: true, subtree: true });
}
}
injectHideCSS();
// Synchronously flag any post that's in seenAtLoad the instant it appears
// in the DOM. Because MutationObserver microtasks run before the next
// paint, the [data-tm-hide] attribute lands before the browser ever
// shows the post — no flash.
function checkAndFlag(node) {
if (!node || node.nodeType !== 1) return;
// The post itself
if (node.hasAttribute && node.hasAttribute('data-id')) {
const id = node.getAttribute('data-id');
if (seenAtLoad.has(id)) {
const cell = node.closest('[data-cell-id]') || node;
cell.setAttribute('data-tm-hide', '1');
}
}
// Or a descendant of the inserted subtree
if (node.querySelectorAll) {
const inner = node.querySelectorAll('[data-id]');
for (const el of inner) {
const id = el.getAttribute('data-id');
if (seenAtLoad.has(id)) {
const cell = el.closest('[data-cell-id]') || el;
cell.setAttribute('data-tm-hide', '1');
}
}
}
}
const prepaintObserver = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes) checkAndFlag(node);
}
});
prepaintObserver.observe(document.documentElement, { childList: true, subtree: true });
// -------------------- post detection --------------------
function findPosts(root = document) {
return root.querySelectorAll('div[data-id]');
}
function getPostId(el) {
if (el.dataset && el.dataset.id) return el.dataset.id;
let node = el.parentElement;
while (node && node !== document.body) {
if (node.dataset && node.dataset.id) return node.dataset.id;
node = node.parentElement;
}
return null;
}
function getPostContainer(el) {
return el.closest('[data-cell-id]') || el.closest('article') || el.closest('li') || el;
}
// -------------------- unhide --------------------
function unhideAll() {
document.querySelectorAll('[data-tm-hide="1"]').forEach(el => {
el.removeAttribute('data-tm-hide');
});
}
// -------------------- IntersectionObserver (silent tracking) --------------------
const dwellTimers = new WeakMap();
let io = null;
function buildObserver() {
if (io) io.disconnect();
io = new IntersectionObserver(entries => {
entries.forEach(entry => {
const container = entry.target;
const id = getPostId(container);
if (!id) return;
if (seen.has(id)) { io.unobserve(container); return; }
if (entry.isIntersecting && entry.intersectionRatio >= VIEW_VISIBILITY_RATIO) {
if (!dwellTimers.has(container)) {
const t = setTimeout(() => {
seen.add(id);
queueSave();
dwellTimers.delete(container);
io.unobserve(container);
}, VIEW_DWELL_MS);
dwellTimers.set(container, t);
}
} else {
const t = dwellTimers.get(container);
if (t) { clearTimeout(t); dwellTimers.delete(container); }
}
});
}, { threshold: [VIEW_VISIBILITY_RATIO] });
}
function observeNewPosts() {
findPosts().forEach(p => {
if (p.dataset.tmObserved === '1') return;
p.dataset.tmObserved = '1';
io.observe(p);
});
}
function reobserveAll() {
document.querySelectorAll('[data-tm-observed="1"]').forEach(el => delete el.dataset.tmObserved);
buildObserver();
observeNewPosts();
}
// -------------------- mutation observer for infinite scroll & panel resilience --------------------
const mo = new MutationObserver(muts => {
let changed = false;
for (const m of muts) {
if (m.addedNodes.length || m.removedNodes.length) { changed = true; break; }
}
if (!changed) return;
if (panelEl && !document.body.contains(panelEl)) {
panelEl = null;
countEl = null;
buildPanel();
}
observeNewPosts();
});
// -------------------- control panel --------------------
let panelEl = null;
let countEl = null;
function updatePanelCount() {
if (countEl) countEl.textContent = seen.size.toLocaleString();
}
function buildPanel() {
if (!document.body) return;
const panel = document.createElement('div');
panel.id = 'tm-tumblr-hide-panel';
panel.innerHTML = `
<div class="tm-panel-header" title="Drag to move">
<span class="tm-panel-title">🙈 Hide Seen</span>
<button class="tm-panel-collapse" title="Collapse">_</button>
</div>
<div class="tm-panel-body">
<div class="tm-panel-stat">
<span class="tm-panel-count">0</span>
<span class="tm-panel-label">posts tracked</span>
</div>
<div class="tm-panel-hint">Hidden on next refresh</div>
<div class="tm-panel-slider-row">
<label>Dwell: <span class="tm-dwell-val">${VIEW_DWELL_MS}</span>ms</label>
<input type="range" class="tm-dwell-slider" min="0" max="3000" step="100" value="${VIEW_DWELL_MS}">
</div>
<div class="tm-panel-slider-row">
<label>Visible: <span class="tm-vis-val">${Math.round(VIEW_VISIBILITY_RATIO * 100)}</span>%</label>
<input type="range" class="tm-vis-slider" min="5" max="90" step="5" value="${Math.round(VIEW_VISIBILITY_RATIO * 100)}">
</div>
<button class="tm-panel-btn" data-action="clear">Clear history</button>
<button class="tm-panel-btn" data-action="export">Export to clipboard</button>
<button class="tm-panel-btn" data-action="import">Import from clipboard</button>
<button class="tm-panel-btn tm-panel-btn-secondary" data-action="unhide">Show all (this session)</button>
</div>
`;
if (!document.getElementById('tm-tumblr-hide-style')) {
const style = document.createElement('style');
style.id = 'tm-tumblr-hide-style';
style.textContent = `
#tm-tumblr-hide-panel {
position: fixed; z-index: 2147483647;
background: rgba(20, 30, 45, 0.95); color: #e6e6e6;
border: 1px solid rgba(255,255,255,0.12); border-radius: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 13px; width: 220px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
user-select: none; backdrop-filter: blur(8px);
/* column-reverse so body renders ABOVE header visually,
making the panel grow upward when expanded. */
display: flex; flex-direction: column-reverse;
}
#tm-tumblr-hide-panel.tm-collapsed .tm-panel-body { display: none; }
#tm-tumblr-hide-panel .tm-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 10px; cursor: move;
border-top: 1px solid rgba(255,255,255,0.08);
}
#tm-tumblr-hide-panel.tm-collapsed .tm-panel-header { border-top: none; }
#tm-tumblr-hide-panel .tm-panel-title { font-weight: 600; }
#tm-tumblr-hide-panel .tm-panel-collapse {
background: transparent; border: none; color: #e6e6e6;
cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px;
}
#tm-tumblr-hide-panel .tm-panel-collapse:hover { color: #00b8ff; }
#tm-tumblr-hide-panel .tm-panel-body {
padding: 10px; display: flex; flex-direction: column; gap: 8px;
}
#tm-tumblr-hide-panel .tm-panel-stat { text-align: center; padding: 4px 0; }
#tm-tumblr-hide-panel .tm-panel-count {
font-size: 22px; font-weight: 700; color: #00b8ff; display: block;
}
#tm-tumblr-hide-panel .tm-panel-label { font-size: 11px; opacity: 0.7; }
#tm-tumblr-hide-panel .tm-panel-hint {
font-size: 10px; opacity: 0.5; text-align: center;
font-style: italic; margin-top: -6px; margin-bottom: 4px;
}
#tm-tumblr-hide-panel .tm-panel-slider-row {
display: flex; flex-direction: column; gap: 2px;
font-size: 11px; opacity: 0.9;
}
#tm-tumblr-hide-panel .tm-panel-slider-row input {
width: 100%; accent-color: #00b8ff;
}
#tm-tumblr-hide-panel .tm-panel-btn {
background: rgba(255,255,255,0.08); color: #e6e6e6;
border: 1px solid rgba(255,255,255,0.1); border-radius: 6px;
padding: 6px 8px; font-size: 12px; cursor: pointer;
font-family: inherit; transition: background 0.15s;
}
#tm-tumblr-hide-panel .tm-panel-btn:hover {
background: rgba(0, 184, 255, 0.2);
border-color: rgba(0, 184, 255, 0.4);
}
#tm-tumblr-hide-panel .tm-panel-btn-secondary {
opacity: 0.7; font-size: 11px;
}
`;
document.head.appendChild(style);
}
document.body.appendChild(panel);
// Anchor by bottom-right so the panel grows upward when expanded.
// We only honor saved positions stored as {right, bottom}; legacy
// {left, top} positions from older versions are discarded so the
// panel re-anchors to the bottom-right by default.
const savedPos = GM_getValue(PANEL_POS_KEY, null);
let usedSaved = false;
if (savedPos) {
try {
const pos = JSON.parse(savedPos);
if (pos && pos.right != null && pos.bottom != null) {
panel.style.right = pos.right + 'px';
panel.style.bottom = pos.bottom + 'px';
usedSaved = true;
}
} catch (e) { /* fall through to defaults */ }
}
if (!usedSaved) {
panel.style.right = DEFAULT_RIGHT + 'px';
panel.style.bottom = DEFAULT_BOTTOM + 'px';
}
if (GM_getValue(PANEL_COLLAPSED_KEY, false)) panel.classList.add('tm-collapsed');
const header = panel.querySelector('.tm-panel-header');
let dragging = false, offsetX = 0, offsetY = 0;
header.addEventListener('mousedown', e => {
if (e.target.classList.contains('tm-panel-collapse')) return;
dragging = true;
const rect = panel.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
// Switch to left/top during drag for free movement
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const left = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - offsetX));
const top = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, e.clientY - offsetY));
panel.style.left = left + 'px';
panel.style.top = top + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
// Convert final left/top back to right/bottom so the panel stays
// anchored to the bottom-right and grows upward on expand.
const rect = panel.getBoundingClientRect();
const right = Math.max(0, window.innerWidth - rect.right);
const bottom = Math.max(0, window.innerHeight - rect.bottom);
panel.style.left = 'auto';
panel.style.top = 'auto';
panel.style.right = right + 'px';
panel.style.bottom = bottom + 'px';
GM_setValue(PANEL_POS_KEY, JSON.stringify({ right, bottom }));
});
panel.querySelector('.tm-panel-collapse').addEventListener('click', () => {
panel.classList.toggle('tm-collapsed');
GM_setValue(PANEL_COLLAPSED_KEY, panel.classList.contains('tm-collapsed'));
});
const dwellSlider = panel.querySelector('.tm-dwell-slider');
const dwellVal = panel.querySelector('.tm-dwell-val');
const visSlider = panel.querySelector('.tm-vis-slider');
const visVal = panel.querySelector('.tm-vis-val');
let sliderDebounce;
dwellSlider.addEventListener('input', () => {
VIEW_DWELL_MS = parseInt(dwellSlider.value, 10);
dwellVal.textContent = VIEW_DWELL_MS;
clearTimeout(sliderDebounce);
sliderDebounce = setTimeout(() => {
GM_setValue(DWELL_KEY, VIEW_DWELL_MS);
reobserveAll();
}, 200);
});
visSlider.addEventListener('input', () => {
VIEW_VISIBILITY_RATIO = parseInt(visSlider.value, 10) / 100;
visVal.textContent = visSlider.value;
clearTimeout(sliderDebounce);
sliderDebounce = setTimeout(() => {
GM_setValue(VISIBILITY_KEY, VIEW_VISIBILITY_RATIO);
reobserveAll();
}, 200);
});
panel.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'clear') {
if (confirm(`Clear all ${seen.size} tracked posts?`)) {
seen = new Set();
seenAtLoad.clear();
saveSeen(seen);
unhideAll();
updatePanelCount();
}
} else if (action === 'export') {
const data = JSON.stringify(Array.from(seen));
navigator.clipboard.writeText(data).then(
() => flashButton(btn, `Copied ${seen.size} IDs!`),
() => prompt('Copy this JSON:', data)
);
} else if (action === 'import') {
navigator.clipboard.readText().then(text => {
try {
const arr = JSON.parse(text);
if (!Array.isArray(arr)) throw new Error('Not an array');
arr.forEach(id => seen.add(String(id)));
saveSeen(seen);
updatePanelCount();
flashButton(btn, `Imported! Total: ${seen.size}`);
} catch (err) {
alert('Invalid clipboard JSON: ' + err.message);
}
}).catch(() => {
const input = prompt('Paste JSON array of post IDs:');
if (!input) return;
try {
const arr = JSON.parse(input);
if (!Array.isArray(arr)) throw new Error('Not an array');
arr.forEach(id => seen.add(String(id)));
saveSeen(seen);
updatePanelCount();
} catch (err) {
alert('Invalid JSON: ' + err.message);
}
});
} else if (action === 'unhide') {
unhideAll();
flashButton(btn, 'Shown until reload');
}
});
countEl = panel.querySelector('.tm-panel-count');
panelEl = panel;
updatePanelCount();
}
function flashButton(btn, msg) {
const original = btn.textContent;
btn.textContent = msg;
btn.style.background = 'rgba(0, 184, 255, 0.35)';
setTimeout(() => {
btn.textContent = original;
btn.style.background = '';
}, 1500);
}
// -------------------- start (deferred until body exists) --------------------
function start() {
buildObserver();
buildPanel();
observeNewPosts();
mo.observe(document.body, { childList: true, subtree: true });
}
function whenReady(fn) {
if (document.body) { fn(); return; }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
// body not there yet but parsing complete? unusual — retry shortly
setTimeout(() => whenReady(fn), 50);
}
}
whenReady(() => setTimeout(start, 100));
GM_registerMenuCommand('Show seen-posts count', () => {
alert(`Tumblr Explore: ${seen.size} posts tracked.`);
});
GM_registerMenuCommand('Clear seen-posts history', () => {
if (confirm(`Clear all ${seen.size} tracked posts?`)) {
seen = new Set();
seenAtLoad.clear();
saveSeen(seen);
unhideAll();
updatePanelCount();
}
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment