FetLife Event Hider

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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