After the success last night with Tampermonkey getting Gander Social’s the “For You” page removed, I thought I’d tackle another usability problem with the still new site – blocking/muting/hiding hashtags.
Installing this script will allow you to block hashtags from display on the site, both in the feed, and in the search results.
To maintain the blocklist, you can either modify the BLOCKED_HASHTAGSarray in the script, or use the console API:
gandermonkey.hideHashtag("nsfw");
You can also reverse that block by using:
gandermonkey.showHashtag("nsfw");
The hashtags added using the gandermonkey console API are stored in the browser’s localStorage.
Here’s the script:
// ==UserScript==
// @name Gander Social - Hide Hash Tags
// @namespace https://shawnhooper.ca
// @version 1.3
// @description Hides posts containing any of a list of specified hashtag
// @author Shawn M. Hooper
// @match https://*.gander.social/*
// @icon https://gander.social/favicon.png
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
'use strict';
// --- Config ---------------------------------------------------------------
const BLOCKED_HASHTAGS = [
'#hashtagwars',
'#HashTagWars',
];
const STORAGE_KEY = 'gandermonkey.hashtags';
const POST_SELECTOR = 'article.post';
const DEBUG = true;
// --- Blocklist ------------------------------------------------------------
const normalize = (t) => String(t).trim().replace(/^#/, '').toLowerCase();
const clean = (arr) => [...new Set((arr || []).map(normalize))].filter(Boolean);
let blocked = new Set();
let state = null;
function readStored() {
if (state) return state;
state = { hidden: [], shown: [] };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return state;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) state = { hidden: clean(parsed), shown: [] };
else state = { hidden: clean(parsed.hidden), shown: clean(parsed.shown) };
} catch (e) {
console.warn('[gandermonkey] could not read ' + STORAGE_KEY + ', using defaults', e);
}
return state;
}
function writeStored(next) {
state = next;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch (e) {
console.warn('[gandermonkey] could not persist hashtag list (session only)', e);
}
}
function rebuild() {
const { hidden, shown } = readStored();
const off = new Set(shown);
blocked = new Set(
clean([...BLOCKED_HASHTAGS, ...hidden]).filter((t) => !off.has(t))
);
if (DEBUG) console.debug('[gandermonkey] blocking', [...blocked]);
}
// --- Matching -------------------------------------------------------------
// Unicode-aware hashtag matcher: #word, letters/digits/underscore only.
const HASHTAG_RE = /#([\p{L}\p{N}_]+)/gu;
let removedCount = 0;
function findBlockedTags(post) {
// textContent covers all children, including hashtag <span>/<a> elements.
const text = post.textContent;
if (!text || !text.includes('#') || !blocked.size) return [];
const hits = new Set();
HASHTAG_RE.lastIndex = 0;
let m;
while ((m = HASHTAG_RE.exec(text)) !== null) {
const tag = m[1].toLowerCase();
if (blocked.has(tag)) hits.add(tag);
}
return [...hits];
}
// Stateless: safe to run repeatedly. No "already processed" cache, because
// Gander mounts the post shell before patching in the body, so any verdict
// cached before the hashtags render goes stale.
function evaluate(post) {
const tags = findBlockedTags(post);
if (!tags.length) return;
try {
post.remove();
removedCount++;
if (DEBUG) console.debug('[gandermonkey] removed post for #' + tags.sort().join(', #'));
} catch (e) {
console.warn('[gandermonkey] could not remove post', e);
}
}
let queued = false;
function scheduleScan() {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
document.querySelectorAll(POST_SELECTOR).forEach(evaluate);
});
}
function apply(mutate) {
const before = new Set(blocked);
const next = { ...readStored() };
mutate(next);
next.hidden = clean(next.hidden);
next.shown = clean(next.shown);
writeStored(next);
rebuild();
scheduleScan();
if ([...before].some((t) => !blocked.has(t))) {
console.info('[gandermonkey] posts already removed from the DOM cannot be ' +
'restored — reload to see them.');
}
return [...blocked].sort();
}
// --- Console API ----------------------------------------------------------
const gandermonkey = {
/** gandermonkey.hideHashtag('#foo', 'bar') */
hideHashtag(...tags) {
const add = clean(tags);
if (!add.length) throw new Error('hideHashtag() needs at least one hashtag');
return apply((s) => {
s.hidden = [...s.hidden, ...add];
s.shown = s.shown.filter((t) => !add.includes(t));
});
},
/** gandermonkey.showHashtag('#foo') — also overrides baked-in tags. */
showHashtag(...tags) {
const drop = clean(tags);
if (!drop.length) throw new Error('showHashtag() needs at least one hashtag');
return apply((s) => {
s.hidden = s.hidden.filter((t) => !drop.includes(t));
const baked = clean(BLOCKED_HASHTAGS);
s.shown = [...s.shown, ...drop.filter((t) => baked.includes(t))];
});
},
/** Everything currently being blocked. */
hiddenHashtags: () => [...blocked].sort(),
/** Posts removed this session. */
removedCount: () => removedCount,
/** Drop all runtime overrides, back to the script defaults. */
resetHashtags: () => apply((s) => {
s.hidden = [];
s.shown = [];
}),
/** Debug helper: hashtags the script sees on each rendered post. */
dumpPosts: () => [...document.querySelectorAll(POST_SELECTOR)].map((p) => ({
el: p,
tags: [...p.textContent.matchAll(HASHTAG_RE)].map((m) => m[1]),
})),
};
window.gandermonkey = Object.assign(window.gandermonkey || {}, gandermonkey);
// Keep tabs in sync.
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY && e.key !== null) return;
state = null; // force a re-read
rebuild();
scheduleScan();
});
new MutationObserver(scheduleScan).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
rebuild();
scheduleScan();
})();
