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.5.0
// @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        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(() => {
  "use strict";

  // Configuration
  const HIDDEN_STORAGE_KEY = "reddit-hidden-post-ids:v1";
  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 HIDDEN_ATTR = "data-codex-hidden-post";
  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;

  // ---------------------------------------------------------------------------
  // Storage
  // ---------------------------------------------------------------------------

  function loadStoredIds(storageKey) {
    try {
      let raw = typeof GM_getValue === "function" ? GM_getValue(storageKey) : null;
      if (!raw) {
        raw = window.localStorage.getItem(storageKey);
        if (raw && typeof GM_setValue === "function") {
          GM_setValue(storageKey, raw);
        }
      }
      const parsed = raw ? JSON.parse(raw) : [];
      return new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string" && value) : []);
    } catch {
      return new Set();
    }
  }

  const hiddenPostIds = loadStoredIds(HIDDEN_STORAGE_KEY);

  function persistStoredIds(storageKey, values) {
    try {
      const stringified = JSON.stringify([...values]);
      if (typeof GM_setValue === "function") {
        GM_setValue(storageKey, stringified);
      }
      window.localStorage.setItem(storageKey, stringified);
    } catch {
      // Ignore storage failures
    }
  }

  function persistHiddenPostIds() {
    persistStoredIds(HIDDEN_STORAGE_KEY, hiddenPostIds);
  }

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

  function getPostId(post) {
    return post?.getAttribute("id") || post?.getAttribute("data-thingid") || post?.getAttribute("permalink") || null;
  }

  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) {
      return;
    }
    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;
  }

  // ---------------------------------------------------------------------------
  // Hide Functionality
  // ---------------------------------------------------------------------------

  function applyHiddenState(post) {
    const postId = getPostId(post);
    const shouldHide = Boolean(postId && hiddenPostIds.has(postId));

    if (shouldHide) {
      post.setAttribute(HIDDEN_ATTR, "true");
      post.style.display = "none";
      return;
    }

    post.removeAttribute(HIDDEN_ATTR);
    post.style.removeProperty("display");
  }

  function showUndoToast(post, postId) {
    const existingToast = document.getElementById("codex-undo-toast");
    if (existingToast) {
      existingToast.remove();
    }

    const toast = document.createElement("div");
    toast.id = "codex-undo-toast";
    toast.style.position = "fixed";
    toast.style.bottom = "24px";
    toast.style.right = "24px";
    toast.style.backgroundColor = "var(--color-neutral-background-inverted, #1A1A1B)";
    toast.style.color = "var(--color-neutral-content-inverted, #FFFFFF)";
    toast.style.padding = "12px 24px";
    toast.style.borderRadius = "8px";
    toast.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)";
    toast.style.display = "flex";
    toast.style.alignItems = "center";
    toast.style.gap = "16px";
    toast.style.zIndex = "999999";
    toast.style.fontFamily = "inherit";
    toast.style.fontSize = "14px";
    toast.style.pointerEvents = "auto";

    const text = document.createElement("span");
    text.textContent = "Post hidden.";

    const undoBtn = document.createElement("button");
    undoBtn.textContent = "Undo";
    undoBtn.style.backgroundColor = "transparent";
    undoBtn.style.color = "var(--color-primary-background-default, #0079D3)";
    undoBtn.style.border = "none";
    undoBtn.style.cursor = "pointer";
    undoBtn.style.fontWeight = "bold";
    undoBtn.style.padding = "0";
    undoBtn.style.fontFamily = "inherit";
    undoBtn.style.fontSize = "14px";

    let timeoutId;

    const removeToast = () => {
      if (toast.parentNode) {
        toast.remove();
      }
    };

    undoBtn.addEventListener("click", () => {
      hiddenPostIds.delete(postId);
      persistHiddenPostIds();
      applyHiddenState(post);
      removeToast();
      clearTimeout(timeoutId);
    });

    toast.appendChild(text);
    toast.appendChild(undoBtn);
    document.body.appendChild(toast);

    timeoutId = window.setTimeout(() => {
      removeToast();
    }, 4000);
  }

  function hidePost(post) {
    const postId = getPostId(post);

    if (!post || !postId) {
      return;
    }

    hiddenPostIds.add(postId);
    persistHiddenPostIds();
    applyHiddenState(post);

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

    showUndoToast(post, postId);
  }

  // ---------------------------------------------------------------------------
  // UI Integration
  // ---------------------------------------------------------------------------

  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 createActionButton({ attr, label, title, onClick }) {
    const button = document.createElement("button");
    button.type = "button";
    button.setAttribute(attr, "true");
    applyButtonStyles(button);
    button.textContent = label;
    button.title = title;
    button.addEventListener("click", async (event) => {
      event.preventDefault();
      event.stopPropagation();
      try {
        await onClick(button);
      } catch (error) {
        console.error("[Reddit Utilities] Button action failed:", error);
      }
    });

    return button;
  }

  function createHideButton(post) {
    return createActionButton({
      attr: HIDE_BUTTON_ATTR,
      label: "Hide",
      title: 'Hide post (hotkey: "h")',
      onClick: async () => {
        hidePost(post);
      },
    });
  }

  function removeFallbackButtons(post) {
    const root = post.shadowRoot;
    if (!root) {
      return;
    }
    root.querySelectorAll(`[${HIDE_BUTTON_ATTR}]`).forEach((button) => button.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.display = "inline-flex";
      buttonsRow.style.flexDirection = "row";
      buttonsRow.style.alignItems = "center";
      buttonsRow.style.flexWrap = "nowrap";
      buttonsRow.style.gap = "var(--spacer-2xs)";
      buttonsRow.style.marginInlineEnd = "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((button) => {
      if (!buttonsRow.contains(button)) {
        button.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.display = "inline-flex";
      buttonsRow.style.flexDirection = "row";
      buttonsRow.style.alignItems = "center";
      buttonsRow.style.flexWrap = "nowrap";
      buttonsRow.style.gap = "var(--spacer-2xs)";
      buttonsRow.style.position = "relative";
      buttonsRow.style.zIndex = "1";
      buttonsRow.style.pointerEvents = "auto";
      buttonsRow.style.flexShrink = "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.display = "flex";
      titleWrapper.style.flexDirection = "row";
      titleWrapper.style.alignItems = "flex-start";
      titleWrapper.style.justifyContent = "space-between";
      titleWrapper.style.gap = "var(--spacer-md)";
      titleWrapper.style.marginBottom = "var(--spacer-xs)";
      titleWrapper.style.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((button) => {
      if (!buttonsRow.contains(button)) {
        button.remove();
      }
    });

    return true;
  }

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

    if (ensureTopRightButtons(post)) {
      return;
    }

    const root = post.shadowRoot;

    if (!root) {
      return;
    }

    const actionRow = root.querySelector(ACTION_ROW_SELECTOR);

    if (!actionRow) {
      return;
    }

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

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

    applyHiddenState(post);

    if (!post.hasAttribute(HIDDEN_ATTR)) {
      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?.hasAttribute(HIDDEN_ATTR) ? null : post);
  }, 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);

  const observer = new MutationObserver(() => {
    scheduleEnhance();
  });

  function start() {
    enhanceAllPosts();

    observer.observe(document.documentElement, {
      childList: true,
      subtree: true,
    });

    window.setInterval(enhanceAllPosts, 2000);
  }

  start();
})();