Reddit - Hide Posts

Adds a persistent Hide button to Reddit posts and search results, and lets you press h to hide the post under the cursor.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Reddit - Hide Posts
// @namespace    Reddit-hide-posts
// @version      1.9.1
// @description  Adds a persistent Hide button to Reddit posts and search results, and lets you press h to hide the post under the cursor.
// @match        https://*.reddit.com/*
// @icon         https://redditinc.com/hs-fs/hubfs/Reddit%20Inc/Content/Brand%20Page/Reddit_Logo.png?width=200&height=200&name=Reddit_Logo.png
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
  "use strict";

  // ---------------------------------------------------------------------------
  // Selectors
  // ---------------------------------------------------------------------------

  const POST_SELECTOR = "shreddit-post[id]";
  const SEARCH_POST_SELECTOR = 'search-telemetry-tracker[data-testid="search-sdui-post"][data-thingid]';
  const ALL_POST_SELECTOR = `${POST_SELECTOR}, ${SEARCH_POST_SELECTOR}`;
  const ACTION_ROW_SELECTOR = '[data-testid="action-row"]';
  const OVERFLOW_MENU_SELECTOR = "shreddit-post-overflow-menu";
  const SEARCH_POST_UNIT_SELECTOR = '[data-testid="search-post-unit"]';
  const SEARCH_POST_CONTENT_SELECTOR = '[data-testid="sdui-post-unit"]';
  const HIDE_BUTTON_ATTR = "data-codex-hide-button";
  const TOP_BUTTONS_ATTR = "data-codex-top-buttons";
  const BUTTON_CLASS_NAME = "button border-md flex flex-row justify-center items-center h-xl font-semibold relative text-caption-1 button-secondary inline-flex px-sm";

  const hoveredState = { post: null };
  let scheduledEnhance = false;

  // ---------------------------------------------------------------------------
  // Core Post Helpers
  // ---------------------------------------------------------------------------

  function isSearchResultPost(post) {
    return post instanceof HTMLElement && post.matches(SEARCH_POST_SELECTOR);
  }

  function isEditableTarget(target) {
    if (!(target instanceof Element)) return false;
    if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true;
    if (target.isContentEditable) return true;
    return Boolean(target.closest('[contenteditable=""],[contenteditable="true"]'));
  }

  function setHoveredPost(post) {
    if (hoveredState.post !== post) hoveredState.post = post || null;
  }

  function findPostFromEvent(event) {
    const path = typeof event.composedPath === "function" ? event.composedPath() : [];
    for (const item of path) {
      if (item instanceof HTMLElement && item.matches?.(POST_SELECTOR)) return item;
      if (item instanceof HTMLElement && item.matches?.(SEARCH_POST_SELECTOR)) return item;
    }
    const target = event.target;
    return target instanceof Element ? target.closest(ALL_POST_SELECTOR) : null;
  }

  // ---------------------------------------------------------------------------
  // Native Hide
  // ---------------------------------------------------------------------------

  function textHasHide(el) {
    return /\bhide\b/i.test(el.textContent);
  }

  function scanRootForHide(root) {
    if (!root) return null;

    for (const el of root.querySelectorAll('[role="menuitem"]')) {
      if (textHasHide(el)) return el;
    }

    for (const li of root.querySelectorAll("faceplate-menu li, li[rpl]")) {
      if (textHasHide(li)) {
        return li.querySelector("button, a") ?? li;
      }
    }

    for (const el of root.querySelectorAll("button, a")) {
      if (/^\s*hide\s*$/i.test(el.textContent)) return el;
    }

    return null;
  }

  function findHideMenuItem(overflowMenu) {
    const fromOwn = scanRootForHide(overflowMenu.shadowRoot) ?? scanRootForHide(overflowMenu);
    if (fromOwn) return fromOwn;

    for (const dd of document.querySelectorAll("faceplate-dropdown-menu")) {
      const found = scanRootForHide(dd.shadowRoot) ?? scanRootForHide(dd);
      if (found) return found;
    }

    const fromBody = scanRootForHide(document.body);
    if (fromBody) return fromBody;

    for (const om of document.querySelectorAll(OVERFLOW_MENU_SELECTOR)) {
      if (om === overflowMenu) continue;
      const found = scanRootForHide(om.shadowRoot) ?? scanRootForHide(om);
      if (found) return found;
    }

    for (const bs of document.querySelectorAll("rpl-bottom-sheet")) {
      const found = scanRootForHide(bs.shadowRoot) ?? scanRootForHide(bs);
      if (found) return found;
    }

    return null;
  }

  function dismissOpenMenus() {
    document.dispatchEvent(
      new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })
    );
  }

  const VEIL_TARGETS = "faceplate-dropdown-menu, shreddit-post-overflow-menu, rpl-bottom-sheet";
  // *** CHANGED: added display: none to prevent any flicker ***
  const VEIL_CSS = `
    faceplate-dropdown-menu,
    shreddit-post-overflow-menu,
    rpl-bottom-sheet,
    faceplate-dropdown-menu *,
    shreddit-post-overflow-menu *,
    rpl-bottom-sheet * {
      display: none !important;
      opacity: 0 !important;
      visibility: hidden !important;
      pointer-events: none !important;
      transition: none !important;
      animation: none !important;
    }
  `;

  function applyInlineVeil(el) {
    el.style.setProperty("display", "none", "important");
    el.style.setProperty("opacity", "0", "important");
    el.style.setProperty("visibility", "hidden", "important");
    el.style.setProperty("pointer-events", "none", "important");
    el.style.setProperty("transition", "none", "important");
    el.style.setProperty("animation", "none", "important");
  }

  async function hidePost(post) {
    const overflowMenu = post.querySelector(OVERFLOW_MENU_SELECTOR);
    if (!overflowMenu) return;

    // Veil injected first — kills visibility immediately via CSS,
    // plus a MutationObserver as a fallback for dynamically inserted nodes.
    const veil = document.createElement("style");
    veil.textContent = VEIL_CSS;
    document.head.appendChild(veil);

    const hideObserver = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (!(node instanceof HTMLElement)) continue;
          if (node.matches?.(VEIL_TARGETS)) applyInlineVeil(node);
          for (const el of node.querySelectorAll(VEIL_TARGETS)) {
            applyInlineVeil(el);
          }
        }
      }
    });
    hideObserver.observe(document.body, { childList: true, subtree: true });

    const cleanup = () => {
      veil.remove();
      hideObserver.disconnect();
    };

    dismissOpenMenus();
    await new Promise((r) => setTimeout(r, 120));

    const triggerBtn =
      overflowMenu.querySelector('button[aria-label="Open user actions"]') ??
      overflowMenu.shadowRoot?.querySelector('button[aria-label="Open user actions"]') ??
      overflowMenu.querySelector("button") ??
      overflowMenu.shadowRoot?.querySelector("button");

    if (!triggerBtn) { cleanup(); return; }

    triggerBtn.click();
    await new Promise((r) => setTimeout(r, 250));

    const deadline = Date.now() + 3000;
    while (Date.now() < deadline) {
      const item = findHideMenuItem(overflowMenu);
      if (item) {
        item.click();
        cleanup();
        return;
      }
      await new Promise((r) => setTimeout(r, 60));
    }

    cleanup();
    dismissOpenMenus();
  }

  // ---------------------------------------------------------------------------
  // UI — inject the Hide button next to posts
  // ---------------------------------------------------------------------------

  function applyButtonStyles(button) {
    button.className = BUTTON_CLASS_NAME;
    button.style.height = "var(--size-button-sm-h)";
    button.style.font = "var(--font-button-sm)";
    button.style.display = "inline-flex";
    button.style.verticalAlign = "middle";
    button.style.removeProperty("margin-inline-end");
  }

  function createHideButton(post) {
    const button = document.createElement("button");
    button.type = "button";
    button.setAttribute(HIDE_BUTTON_ATTR, "true");
    applyButtonStyles(button);
    button.textContent = "Hide";
    button.title = 'Hide post (hotkey: "h")';
    button.addEventListener("click", async (event) => {
      event.preventDefault();
      event.stopPropagation();
      try {
        await hidePost(post);
      } catch (error) {
        console.error("[Reddit Hide Posts] Failed:", error);
      }
    });
    return button;
  }

  function removeFallbackButtons(post) {
    post.shadowRoot?.querySelectorAll(`[${HIDE_BUTTON_ATTR}]`).forEach((b) => b.remove());
  }

  function ensureTopRightButtons(post) {
    const overflowMenu = post.querySelector(OVERFLOW_MENU_SELECTOR);
    if (!overflowMenu) return false;

    const overflowLoader = overflowMenu.closest("shreddit-async-loader");
    const insertionAnchor = overflowLoader || overflowMenu;
    const rightActions = insertionAnchor.parentElement;
    if (!(rightActions instanceof HTMLElement)) return false;

    [...post.querySelectorAll(`[${TOP_BUTTONS_ATTR}]`)].forEach((row) => {
      if (row.parentElement !== rightActions) row.remove();
    });

    let buttonsRow = [...rightActions.children].find(
      (child) => child instanceof HTMLElement && child.hasAttribute(TOP_BUTTONS_ATTR)
    ) || null;

    if (!(buttonsRow instanceof HTMLElement)) {
      buttonsRow = document.createElement("span");
      buttonsRow.setAttribute(TOP_BUTTONS_ATTR, "true");
      buttonsRow.style.cssText = "display:inline-flex;flex-direction:row;align-items:center;flex-wrap:nowrap;gap:var(--spacer-2xs);margin-inline-end:var(--spacer-2xs);";
    }

    if (buttonsRow.parentElement !== rightActions) {
      insertionAnchor.insertAdjacentElement("beforebegin", buttonsRow);
    }

    if (!buttonsRow.querySelector(`[${HIDE_BUTTON_ATTR}]`)) {
      buttonsRow.append(createHideButton(post));
    }

    [...post.querySelectorAll(`[${HIDE_BUTTON_ATTR}]`)].forEach((b) => {
      if (!buttonsRow.contains(b)) b.remove();
    });

    removeFallbackButtons(post);
    return true;
  }

  function ensureSearchResultButtons(post) {
    if (!isSearchResultPost(post)) return false;

    const card = post.querySelector(SEARCH_POST_UNIT_SELECTOR);
    const content = card?.querySelector(SEARCH_POST_CONTENT_SELECTOR);
    const titleEl = content?.querySelector('a[data-testid="post-title-text"]');
    if (!(card instanceof HTMLElement) || !(content instanceof HTMLElement) || !(titleEl instanceof HTMLElement)) return false;

    let buttonsRow = content.querySelector(`[${TOP_BUTTONS_ATTR}]`);
    if (!(buttonsRow instanceof HTMLElement)) {
      buttonsRow = document.createElement("span");
      buttonsRow.setAttribute(TOP_BUTTONS_ATTR, "true");
      buttonsRow.style.cssText = "display:inline-flex;flex-direction:row;align-items:center;flex-wrap:nowrap;gap:var(--spacer-2xs);position:relative;z-index:1;pointer-events:auto;flex-shrink:0;";
    }

    let titleWrapper = titleEl.parentElement;
    if (titleWrapper.getAttribute("data-codex-title-wrapper") !== "true") {
      titleWrapper = document.createElement("div");
      titleWrapper.setAttribute("data-codex-title-wrapper", "true");
      titleWrapper.style.cssText = "display:flex;flex-direction:row;align-items:flex-start;justify-content:space-between;gap:var(--spacer-md);margin-bottom:var(--spacer-xs);width:100%;";
      titleEl.style.marginBottom = "0";
      titleEl.style.flex = "1 1 auto";
      titleEl.style.minWidth = "0";
      titleEl.insertAdjacentElement("beforebegin", titleWrapper);
      titleWrapper.appendChild(titleEl);
    }

    if (buttonsRow.parentElement !== titleWrapper) titleWrapper.appendChild(buttonsRow);

    if (!buttonsRow.querySelector(`[${HIDE_BUTTON_ATTR}]`)) {
      buttonsRow.append(createHideButton(post));
    }

    [...post.querySelectorAll(`[${HIDE_BUTTON_ATTR}]`)].forEach((b) => {
      if (!buttonsRow.contains(b)) b.remove();
    });

    return true;
  }

  function ensureButtons(post) {
    if (ensureSearchResultButtons(post)) return;
    if (ensureTopRightButtons(post)) return;

    const actionRow = post.shadowRoot?.querySelector(ACTION_ROW_SELECTOR);
    if (actionRow && !actionRow.querySelector(`[${HIDE_BUTTON_ATTR}]`)) {
      actionRow.append(createHideButton(post));
    }
  }

  function enhancePost(post) {
    if (!(post instanceof HTMLElement) || !post.matches(ALL_POST_SELECTOR)) return;
    ensureButtons(post);
  }

  function enhanceAllPosts() {
    document.querySelectorAll(ALL_POST_SELECTOR).forEach(enhancePost);
  }

  function scheduleEnhance() {
    if (scheduledEnhance) return;
    scheduledEnhance = true;
    window.requestAnimationFrame(() => {
      scheduledEnhance = false;
      enhanceAllPosts();
    });
  }

  // ---------------------------------------------------------------------------
  // Event listeners and observers
  // ---------------------------------------------------------------------------

  document.addEventListener("pointermove", (event) => {
    const post = findPostFromEvent(event);
    setHoveredPost(post ?? null);
  }, true);

  document.addEventListener("keydown", (event) => {
    if (event.defaultPrevented || event.repeat || event.ctrlKey || event.altKey || event.metaKey) return;
    if (event.key.toLowerCase() !== "h") return;
    if (isEditableTarget(event.target)) return;
    if (!hoveredState.post) return;
    event.preventDefault();
    hidePost(hoveredState.post);
  }, true);

  new MutationObserver(scheduleEnhance).observe(document.documentElement, {
    childList: true,
    subtree: true,
  });

  enhanceAllPosts();
  window.setInterval(enhanceAllPosts, 2000);
})();