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, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);
})();