Twitter (X) 片思い表示

Twitter (X) で片思いフォロー中のアカウントのみを表示できます。また、両思いフォロー中のアカウントは強調表示できます。

// ==UserScript==
// @name         Twitter (X) 片思い表示
// @namespace    https://kuds.win/
// @version      1.3
// @description  Twitter (X) で片思いフォロー中のアカウントのみを表示できます。また、両思いフォロー中のアカウントは強調表示できます。
// @author       KUDs
// @match        https://twitter.com/*
// @match        https://x.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @grant        none
// @license      GPL-3.0-or-later
// ==/UserScript==

(function () {
  "use strict";

  // デフォルト状態(非表示・ハイライト無し)
  let isHiding = false;
  let isHighlighting = false;

  // 更新処理のスケジューリングフラグ
  let updateScheduled = false;

  // 共通スタイルのボタン作成
  function createStyledButton(text, clickHandler) {
    const button = document.createElement("button");
    button.textContent = text;
    Object.assign(button.style, {
      border: "none",
      borderRadius: "20px",
      padding: "10px 20px",
      marginTop: "10px",
      cursor: "pointer",
      transition: "filter 0.3s, transform 0.3s",
    });
    button.addEventListener("mouseover", () => {
      button.style.filter = "brightness(1.25)";
      button.style.transform = "scale(1.05)";
    });
    button.addEventListener("mouseout", () => {
      button.style.filter = "brightness(1)";
      button.style.transform = "scale(1)";
    });
    button.addEventListener("click", clickHandler);
    return button;
  }

  // 片思い表示ボタンのクリックハンドラ
  function handleHideButtonClick() {
    isHiding = !isHiding;
    hideButton.textContent = isHiding ? "片思い ONLY: ON" : "片思い ONLY: OFF";
    hideButton.style.backgroundColor = isHiding ? "skyblue" : "gray";
    scheduleDOMUpdate();
  }

  // 両思いハイライトボタンのクリックハンドラ
  function handleHighlightButtonClick() {
    isHighlighting = !isHighlighting;
    highlightButton.textContent = isHighlighting
      ? "両思い HIGHLIGHT: ON"
      : "両思い HIGHLIGHT: OFF";
    highlightButton.style.backgroundColor = isHighlighting ? "pink" : "gray";
    scheduleDOMUpdate();
  }

  const hideButton = createStyledButton(
    "片思い ONLY: OFF",
    handleHideButtonClick
  );
  hideButton.style.backgroundColor = "gray";

  const highlightButton = createStyledButton(
    "両思い HIGHLIGHT: OFF",
    handleHighlightButtonClick
  );
  highlightButton.style.backgroundColor = "gray";
  highlightButton.style.display = "none";

  // ボタン配置の初期化
  function initializeButtons() {
    const targetNavElement = document.querySelector(
      '[data-testid="SideNav_NewTweet_Button"]'
    );
    if (targetNavElement) {
      const parentNavElement = targetNavElement.parentNode;
      if (parentNavElement && !hideButton.parentNode) {
        parentNavElement.appendChild(hideButton);
        parentNavElement.appendChild(highlightButton);
      }
    }
  }

  // cellInnerDiv の表示更新
  function updateCellInnerDivVisibility(node) {
    if (
      node.textContent.includes("フォローされています") ||
      node.textContent.includes("両思いです♡")
    ) {
      node.style.display = isHiding ? "none" : "";
    }
  }

  // userFollowIndicator の更新
  function updateFollowIndicator(node) {
    if (
      node.textContent.trim() === "フォローされています" ||
      node.textContent.trim() === "両思いです♡"
    ) {
      const spanElement = node.querySelector("span");
      if (isHighlighting) {
        node.style.backgroundColor = "pink";
        if (spanElement) spanElement.textContent = "両思いです♡";
      } else {
        node.style.backgroundColor = "";
        if (spanElement) spanElement.textContent = "フォローされています";
      }
    }
  }

  // 一括して DOM を更新する処理
  function performDOMUpdate() {
    // 各 cellInnerDiv の更新
    document
      .querySelectorAll('div[data-testid="cellInnerDiv"]')
      .forEach(updateCellInnerDivVisibility);
    // 各 userFollowIndicator の更新
    document
      .querySelectorAll('[data-testid="userFollowIndicator"]')
      .forEach(updateFollowIndicator);

    // ボタンの表示切替(片思い表示時はハイライトボタンを非表示)
    highlightButton.style.display = isHiding ? "none" : "";
    initializeButtons();
    updateScheduled = false;
  }

  // 更新をスケジュールする(複数回のミューテーションがあっても1フレーム内にまとめて実行)
  function scheduleDOMUpdate() {
    if (!updateScheduled) {
      updateScheduled = true;
      requestAnimationFrame(performDOMUpdate);
    }
  }

  // MutationObserver で DOM の変化を監視
  const observer = new MutationObserver((mutationsList) => {
    mutationsList.forEach((mutation) => {
      if (mutation.addedNodes && mutation.addedNodes.length > 0) {
        mutation.addedNodes.forEach((node) => {
          // 新規追加ノードがボタンの配置対象の場合
          if (
            node.matches &&
            node.matches('[data-testid="SideNav_NewTweet_Button"]')
          ) {
            initializeButtons();
          }
          // cellInnerDiv の処理
          if (node.matches && node.matches('div[data-testid="cellInnerDiv"]')) {
            updateCellInnerDivVisibility(node);
          }
          // userFollowIndicator の処理
          if (
            node.matches &&
            node.matches('[data-testid="userFollowIndicator"]')
          ) {
            updateFollowIndicator(node);
          }
        });
      }
    });
    scheduleDOMUpdate();
  });

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

  // 初期化
  initializeButtons();
})();