Hide or highlight FetLife events matching keywords or categories you choose. Nothing is filtered by default.
// ==UserScript==
// @name FetLife Event Hider
// @namespace https://codeberg.org/SystemDev/fetlife-event-hider
// @version 0.3.1
// @description Hide or highlight FetLife events matching keywords or categories you choose. Nothing is filtered by default.
// @author SystemDev
// @match *://*.fetlife.com/*
// @license Unlicense
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @grant GM.addValueChangeListener
// @grant GM.xmlHttpRequest
// @connect api.github.com
// @connect gist.githubusercontent.com
// @run-at document-idle
// @noframes
// ==/UserScript==
// Userscript equivalent of the FetLife Event Hider browser extension
// (see manifest.json / src/ in this repo). Works in Tampermonkey,
// Violentmonkey, and Greasemonkey 4+. Settings live in this userscript's
// own storage (GM.getValue/GM.setValue), separate from the extension's
// chrome.storage.sync — the two do not share settings, but the exported
// JSON files from either one are interchangeable via Import.
//
// FetLife navigates between pages client-side (no full page load), so
// @match is intentionally broad (whole site) and we detect entry into
// /events ourselves via the Navigation API (with a history.pushState
// fallback) instead of relying on the script manager to re-inject on
// every URL change.
(() => {
"use strict";
const SETTINGS_KEY = "fehSettings";
const HIDDEN_CLASS = "feh-hidden";
const HIGHLIGHT_CLASS = "feh-highlight";
const DEFAULT_MODE = "hide"; // "hide" | "highlight" (highlight is for debugging matches)
const DEFAULT_SETTINGS = { keywords: [], mode: DEFAULT_MODE, blockedCategories: [] };
const KNOWN_CATEGORIES = [
"BDSM Party",
"Educational",
"Social",
"Conference / Festival",
"Sex Party"
];
// FetLife's markup isn't public API and can change without notice.
// If hiding stops working, open DevTools on an events page, inspect an
// event card, and update this selector list to match the current markup.
// Verified 2026-09-14 against fetlife.com/events/near: cards are Tailwind
// divs identified by the `event-card` group name and a clickable controller.
const EVENT_CARD_SELECTORS = [
".group\\/event-card",
"[data-controller~=\"clickable\"]"
];
// The category badge ("Sex Party", "Educational", etc.) rendered at the
// top of each card. Verified 2026-09-14 against fetlife.com/events/near.
const CATEGORY_BADGE_SELECTOR = ".pb-2.text-xs.uppercase span";
// The page's own category filter chips use these slugs as checkbox values.
// Verified 2026-09-14 against fetlife.com/events/near.
const CATEGORY_LABEL_TO_SLUG = {
"bdsm party": "bdsm_party",
"educational": "educational",
"social": "social",
"conference / festival": "conference_festival",
"sex party": "sex_party"
};
// --- Storage: prefer the modern promise-based GM.* API (Tampermonkey,
// Violentmonkey, Greasemonkey 4+); fall back to localStorage if a script
// manager doesn't grant it. ---
const hasGM =
typeof GM !== "undefined" && typeof GM.getValue === "function" && typeof GM.setValue === "function";
const storage = hasGM
? {
get: (key, fallback) => GM.getValue(key, fallback),
set: (key, value) => GM.setValue(key, value),
onChange:
typeof GM.addValueChangeListener === "function"
? (key, cb) => GM.addValueChangeListener(key, (name, oldValue, newValue) => cb(newValue))
: null
}
: {
get: async (key, fallback) => {
const raw = localStorage.getItem(`feh:${key}`);
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch {
return fallback;
}
},
set: async (key, value) => {
localStorage.setItem(`feh:${key}`, JSON.stringify(value));
},
onChange: null
};
// --- Gist import: pulls a settings JSON file out of a public GitHub
// Gist by URL (or bare gist ID), via the Gist API so we don't have to
// guess which file in a multi-file gist holds the settings. Uses
// GM.xmlHttpRequest so the request isn't subject to FetLife's page CSP;
// falls back to plain fetch if a script manager doesn't grant it (may
// fail under a strict CSP, in which case the user sees an error).
const hasGMRequest = typeof GM !== "undefined" && typeof GM.xmlHttpRequest === "function";
function gmRequest(url) {
if (hasGMRequest) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET",
url,
headers: { Accept: "application/vnd.github+json" },
onload: (res) => resolve({ status: res.status, text: res.responseText }),
onerror: () => reject(new Error("network error")),
ontimeout: () => reject(new Error("timeout"))
});
});
}
return fetch(url, { headers: { Accept: "application/vnd.github+json" } }).then(async (res) => ({
status: res.status,
text: await res.text()
}));
}
function extractGistId(input) {
const trimmed = String(input || "").trim();
if (/^[0-9a-f]{6,}$/i.test(trimmed)) return trimmed;
const match = trimmed.match(/gist\.github(?:usercontent)?\.com\/(?:[^/?#]+\/)?([0-9a-f]{6,})/i);
return match ? match[1] : null;
}
async function fetchGistSettings(gistUrl) {
const id = extractGistId(gistUrl);
if (!id) throw new Error("Invalid Gist URL");
const { status, text } = await gmRequest(`https://api.github.com/gists/${id}`);
if (status !== 200) throw new Error(`Couldn't fetch gist (HTTP ${status})`);
let gist;
try {
gist = JSON.parse(text);
} catch {
throw new Error("Gist API returned unexpected data");
}
const files = Object.values(gist.files || {});
if (files.length === 0) throw new Error("Gist has no files");
const file =
files.find((f) => /fetlife-event-hider-settings/i.test(f.filename)) ||
files.find((f) => /\.json$/i.test(f.filename)) ||
files[0];
let content = file.content;
if (file.truncated) {
const raw = await gmRequest(file.raw_url);
if (raw.status !== 200) throw new Error("Couldn't fetch full gist file");
content = raw.text;
}
let data;
try {
data = JSON.parse(content);
} catch {
throw new Error("Gist file isn't valid JSON");
}
return data;
}
let keywords = DEFAULT_SETTINGS.keywords;
let mode = DEFAULT_SETTINGS.mode;
let blockedCategories = new Set();
function normalize(text) {
return text.toLowerCase();
}
function normalizeKeywords(rawKeywords) {
return (rawKeywords || []).map((k) => normalize(String(k).trim())).filter(Boolean);
}
function normalizeMode(rawMode) {
return rawMode === "highlight" ? "highlight" : DEFAULT_MODE;
}
function normalizeCategories(rawCategories) {
return new Set((rawCategories || []).map((c) => normalize(String(c).trim())).filter(Boolean));
}
async function loadSettings() {
const saved = await storage.get(SETTINGS_KEY, DEFAULT_SETTINGS);
keywords = normalizeKeywords(saved.keywords);
mode = normalizeMode(saved.mode);
blockedCategories = normalizeCategories(saved.blockedCategories);
}
async function persistSettings(next) {
await storage.set(SETTINGS_KEY, next);
keywords = normalizeKeywords(next.keywords);
mode = normalizeMode(next.mode);
blockedCategories = normalizeCategories(next.blockedCategories);
applyFilter();
}
function getCategory(card) {
const el = card.querySelector(CATEGORY_BADGE_SELECTOR);
return el ? el.textContent.trim() : "";
}
function findEventCards() {
const seen = new Set();
for (const selector of EVENT_CARD_SELECTORS) {
document.querySelectorAll(selector).forEach((el) => seen.add(el));
}
return Array.from(seen);
}
function applyFilter() {
const cards = findEventCards();
let hiddenCount = 0;
for (const card of cards) {
const text = normalize(card.innerText || card.textContent || "");
const matchedKeyword = keywords.find((keyword) => keyword && text.includes(keyword));
const category = getCategory(card);
const categoryMatched = category !== "" && blockedCategories.has(normalize(category));
const matched = Boolean(matchedKeyword) || categoryMatched;
const hidden = matched && mode === "hide";
card.classList.toggle(HIDDEN_CLASS, hidden);
card.classList.toggle(HIGHLIGHT_CLASS, matched && mode === "highlight");
if (hidden) hiddenCount++;
if (matched) {
card.dataset.fehReason = matchedKeyword ? `keyword: ${matchedKeyword}` : `category: ${category}`;
} else {
delete card.dataset.fehReason;
}
}
updateCategoryFilterChips();
updateToggleLabel(hiddenCount);
}
// --- Toggle button label: shows how many events are currently hidden. ---
let lastHiddenCount = 0;
function updateToggleLabel(count) {
lastHiddenCount = count;
if (!toggleEl) return;
toggleEl.textContent = count > 0 ? `FEH ⚙ (${count})` : "FEH ⚙";
}
// Hides the page's own category filter chips (e.g. "Sex Party") when that
// category is already fully hidden. Only applies in "hide" mode.
function updateCategoryFilterChips() {
const blockedSlugs =
mode === "hide"
? new Set(
Array.from(blockedCategories, (label) => CATEGORY_LABEL_TO_SLUG[label]).filter(Boolean)
)
: new Set();
document.querySelectorAll('input[name="categories[]"]').forEach((input) => {
const label = input.closest("label");
if (!label) return;
label.classList.toggle(HIDDEN_CLASS, blockedSlugs.has(input.value));
});
}
let scheduled = false;
function scheduleApplyFilter() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
applyFilter();
});
}
function observeEventList() {
const observer = new MutationObserver(scheduleApplyFilter);
observer.observe(document.body, { childList: true, subtree: true });
}
// --- Injected page styles: hide/highlight classes + the settings panel. ---
function injectStyles() {
const style = document.createElement("style");
style.textContent = `
.${HIDDEN_CLASS} { display: none !important; }
.${HIGHLIGHT_CLASS} {
outline: 3px solid #ff3b3b !important;
outline-offset: -3px;
box-shadow: 0 0 0 3px rgba(255, 59, 59, 0.35) !important;
position: relative;
}
.${HIGHLIGHT_CLASS}::before {
content: "FEH match: " attr(data-feh-reason);
position: absolute;
top: 0;
right: 0;
z-index: 10;
background: #ff3b3b;
color: #fff;
font: bold 11px/1.4 system-ui, sans-serif;
padding: 1px 6px;
border-radius: 0 0 0 4px;
pointer-events: none;
}
#feh-toggle {
all: initial;
position: fixed;
z-index: 2147483000;
bottom: 16px;
right: 16px;
font: 600 13px/1 system-ui, sans-serif;
padding: 10px 14px;
border-radius: 999px;
background: #5e81ac;
color: #eceff4;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
@media (prefers-color-scheme: dark) {
#feh-toggle { background: #88c0d0; color: #2e3440; }
}
#feh-panel {
all: initial;
position: fixed;
z-index: 2147483000;
bottom: 60px;
right: 16px;
width: 300px;
max-height: 80vh;
overflow-y: auto;
box-sizing: border-box;
padding: 12px;
font: 13px/1.4 system-ui, sans-serif;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
--nord0: #2e3440; --nord4: #d8dee9; --nord5: #e5e9f0; --nord6: #eceff4;
--nord8: #88c0d0; --nord10: #5e81ac; --nord11: #bf616a; --nord14: #a3be8c;
--bg: var(--nord6); --bg-panel: var(--nord5); --fg: var(--nord0);
--fg-muted: #4c566a; --border: var(--nord4); --accent: var(--nord10);
--accent-fg: var(--nord6); --success: #3f6b45; --danger: var(--nord11);
background: var(--bg);
color: var(--fg);
}
@media (prefers-color-scheme: dark) {
#feh-panel {
--bg: var(--nord0); --bg-panel: #3b4252; --fg: var(--nord6);
--fg-muted: var(--nord4); --border: #434c5e; --accent: var(--nord8);
--accent-fg: var(--nord0); --success: var(--nord14);
}
}
#feh-panel * { box-sizing: border-box; font-family: inherit; }
#feh-panel h1 { all: unset; display: block; font-size: 15px; font-weight: 700; margin: 0 0 6px; color: var(--fg); }
#feh-panel .feh-hint { color: var(--fg-muted); margin: 0 0 8px; }
#feh-panel a { color: var(--accent); }
#feh-panel textarea {
width: 100%; font-size: 13px; resize: vertical; background: var(--bg-panel);
color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px;
}
#feh-panel input[type="url"] {
flex: 1; min-width: 0; font-size: 13px; background: var(--bg-panel);
color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px;
}
#feh-panel fieldset {
border: 1px solid var(--border); border-radius: 4px; margin: 8px 0 0; padding: 6px 8px 8px;
}
#feh-panel legend { padding: 0 4px; color: var(--fg-muted); font-size: 12px; }
#feh-panel label { display: block; margin-top: 4px; cursor: pointer; color: var(--fg); }
#feh-panel .feh-actions { display: flex; gap: 8px; margin-top: 8px; }
#feh-panel button {
flex: 1; padding: 6px 10px; cursor: pointer; border: 1px solid var(--accent);
border-radius: 4px; background: var(--accent); color: var(--accent-fg); font-size: 13px;
}
#feh-panel button.feh-secondary { background: none; color: var(--fg); border-color: var(--border); }
#feh-panel .feh-status { min-height: 1.2em; color: var(--success); margin: 8px 0 0; }
#feh-panel .feh-status.feh-error { color: var(--danger); }
#feh-panel .feh-close {
all: unset; position: absolute; top: 8px; right: 10px; cursor: pointer;
color: var(--fg-muted); font-size: 16px; line-height: 1;
}
`;
document.documentElement.appendChild(style);
}
// --- Settings panel UI ---
let toggleEl = null;
let panelEl = null;
function buildPanel() {
const toggle = document.createElement("button");
toggleEl = toggle;
toggle.id = "feh-toggle";
toggle.type = "button";
toggle.textContent = "FEH ⚙";
toggle.title = "FetLife Event Hider settings";
document.body.appendChild(toggle);
updateToggleLabel(lastHiddenCount);
const panel = document.createElement("div");
panelEl = panel;
panel.id = "feh-panel";
panel.hidden = true;
panel.innerHTML = `
<span class="feh-close" id="feh-close" title="Close">✕</span>
<h1>FetLife Event Hider</h1>
<p class="feh-hint">One keyword or phrase per line. Matching is case-insensitive and checks the whole event card text.</p>
<textarea id="feh-keywords" rows="6" spellcheck="false"></textarea>
<fieldset>
<legend>On match</legend>
<label><input type="radio" name="feh-mode" value="hide" id="feh-mode-hide" /> Hide the event</label>
<label><input type="radio" name="feh-mode" value="highlight" id="feh-mode-highlight" /> Highlight it instead (debug)</label>
</fieldset>
<fieldset id="feh-categories">
<legend>Hide whole categories</legend>
</fieldset>
<div class="feh-actions">
<button id="feh-save" type="button">Save</button>
<button id="feh-reset" type="button" class="feh-secondary">Reset to defaults</button>
</div>
<div class="feh-actions">
<button id="feh-export" type="button" class="feh-secondary">Export</button>
<button id="feh-import" type="button" class="feh-secondary">Import</button>
<input type="file" id="feh-import-file" accept="application/json,.json,.txt" hidden />
</div>
<div class="feh-actions">
<input type="url" id="feh-gist-url" placeholder="Gist URL" />
<button id="feh-import-gist" type="button" class="feh-secondary">Import from Gist</button>
</div>
<p class="feh-hint">
Export saves your keywords, mode, and categories as a text file you can
paste into a <a href="https://gist.github.com" target="_blank" rel="noopener">GitHub Gist</a>
to share. Import loads a settings file someone shared with you
(works with files exported from either the userscript or the browser extension),
or paste a public Gist URL above to import directly from it.
</p>
<p id="feh-status" class="feh-status" aria-live="polite"></p>
`;
document.body.appendChild(panel);
const categoriesFieldset = panel.querySelector("#feh-categories");
KNOWN_CATEGORIES.forEach((category, index) => {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.name = "feh-category";
input.value = category;
input.id = `feh-category-${index}`;
label.appendChild(input);
label.append(` ${category}`);
categoriesFieldset.appendChild(label);
});
const textarea = panel.querySelector("#feh-keywords");
const modeInputs = panel.querySelectorAll('input[name="feh-mode"]');
const status = panel.querySelector("#feh-status");
const importFileInput = panel.querySelector("#feh-import-file");
function getSelectedMode() {
const checked = panel.querySelector('input[name="feh-mode"]:checked');
return checked ? checked.value : DEFAULT_MODE;
}
function setSelectedMode(m) {
modeInputs.forEach((input) => {
input.checked = input.value === m;
});
}
function getSelectedCategories() {
return Array.from(panel.querySelectorAll('input[name="feh-category"]:checked')).map(
(input) => input.value
);
}
function setSelectedCategories(categories) {
// `categories` (from the normalized blockedCategories state) is
// lowercased, but checkbox values keep KNOWN_CATEGORIES' original
// casing — compare normalized on both sides.
const selected = new Set((categories || []).map(normalize));
panel.querySelectorAll('input[name="feh-category"]').forEach((input) => {
input.checked = selected.has(normalize(input.value));
});
}
function showStatus(message, isError = false) {
status.textContent = message;
status.classList.toggle("feh-error", isError);
setTimeout(() => {
status.textContent = "";
status.classList.remove("feh-error");
}, 1500);
}
function parseKeywords(text) {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function currentFormSettings() {
return {
keywords: parseKeywords(textarea.value),
mode: getSelectedMode(),
blockedCategories: getSelectedCategories()
};
}
function refreshFormFromState() {
textarea.value = keywords.join("\n");
setSelectedMode(mode);
setSelectedCategories(Array.from(blockedCategories));
}
function isValidSettings(data) {
return (
data &&
typeof data === "object" &&
Array.isArray(data.keywords) &&
data.keywords.every((k) => typeof k === "string") &&
Array.isArray(data.blockedCategories) &&
data.blockedCategories.every((c) => typeof c === "string") &&
(data.mode === "hide" || data.mode === "highlight")
);
}
function exportSettings() {
const text = JSON.stringify(currentFormSettings(), null, 2);
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "fetlife-event-hider-settings.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
showStatus("Exported");
}
async function importSettingsFromFile(file) {
let data;
try {
data = JSON.parse(await file.text());
} catch {
showStatus("Invalid settings file", true);
return;
}
if (!isValidSettings(data)) {
showStatus("Invalid settings file", true);
return;
}
await persistSettings(data);
refreshFormFromState();
showStatus("Imported");
}
panel.querySelector("#feh-save").addEventListener("click", async () => {
await persistSettings(currentFormSettings());
showStatus("Saved");
});
panel.querySelector("#feh-reset").addEventListener("click", async () => {
await persistSettings({ ...DEFAULT_SETTINGS });
refreshFormFromState();
showStatus("Reset to defaults");
});
panel.querySelector("#feh-export").addEventListener("click", exportSettings);
panel.querySelector("#feh-import").addEventListener("click", () => importFileInput.click());
importFileInput.addEventListener("change", () => {
const file = importFileInput.files[0];
importFileInput.value = "";
if (file) importSettingsFromFile(file);
});
const gistUrlInput = panel.querySelector("#feh-gist-url");
panel.querySelector("#feh-import-gist").addEventListener("click", async () => {
const url = gistUrlInput.value.trim();
if (!url) {
showStatus("Enter a Gist URL", true);
return;
}
showStatus("Fetching gist…");
let data;
try {
data = await fetchGistSettings(url);
} catch (err) {
showStatus(err.message || "Couldn't fetch gist", true);
return;
}
if (!isValidSettings(data)) {
showStatus("Gist doesn't contain valid FEH settings", true);
return;
}
await persistSettings(data);
refreshFormFromState();
gistUrlInput.value = "";
showStatus("Imported from Gist");
});
panel.querySelector("#feh-close").addEventListener("click", () => {
panel.hidden = true;
});
toggle.addEventListener("click", () => {
panel.hidden = !panel.hidden;
if (!panel.hidden) refreshFormFromState();
});
if (typeof GM !== "undefined" && typeof GM.registerMenuCommand === "function") {
GM.registerMenuCommand("FetLife Event Hider settings", () => {
panel.hidden = !panel.hidden;
if (!panel.hidden) refreshFormFromState();
});
}
if (storage.onChange) {
storage.onChange(SETTINGS_KEY, (newValue) => {
keywords = normalizeKeywords(newValue.keywords);
mode = normalizeMode(newValue.mode);
blockedCategories = normalizeCategories(newValue.blockedCategories);
applyFilter();
if (!panel.hidden) refreshFormFromState();
});
}
}
// --- Activation: only run the hider/UI on /events pages, and detect
// FetLife's client-side ("fake") navigation into and out of /events so
// the script activates without needing a real page load. ---
function isEventsPage() {
return /^\/events(\/|$|\?)/.test(location.pathname);
}
let initialized = false;
function updateVisibility() {
const onEvents = isEventsPage();
if (toggleEl) toggleEl.hidden = !onEvents;
if (panelEl && !onEvents) panelEl.hidden = true;
}
async function activateIfNeeded() {
if (!isEventsPage()) {
updateVisibility();
return;
}
if (initialized) {
applyFilter();
updateVisibility();
return;
}
initialized = true;
injectStyles();
await loadSettings();
applyFilter();
observeEventList();
buildPanel();
updateVisibility();
}
function watchNavigation() {
if (typeof window.navigation !== "undefined" && typeof window.navigation.addEventListener === "function") {
window.navigation.addEventListener("navigate", () => {
// The URL hasn't updated yet when this fires, so defer a tick.
setTimeout(activateIfNeeded, 0);
});
return;
}
// Fallback for script managers/browsers without the Navigation API:
// patch history.pushState/replaceState (how most SPA routers, FetLife
// included, change the URL) and also listen for back/forward.
const wrap = (fn) =>
function (...args) {
const result = fn.apply(this, args);
activateIfNeeded();
return result;
};
history.pushState = wrap(history.pushState);
history.replaceState = wrap(history.replaceState);
window.addEventListener("popstate", activateIfNeeded);
}
activateIfNeeded();
watchNavigation();
})();