FetLife Event Hider

Hide or highlight FetLife events matching keywords or categories you choose. Nothing is filtered by default.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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();
})();