X/Twitter Scroll Brake

Limit visible posts on X/Twitter and reveal more on demand.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         X/Twitter Scroll Brake
// @namespace    https://github.com/local/x-scroll-brake
// @version      1.2.5
// @description  Limit visible posts on X/Twitter and reveal more on demand.
// @license      MIT
// @match        https://x.com/*
// @match        https://twitter.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  const INITIAL_VISIBLE_POSTS = 10;
  const POSTS_PER_CLICK = 5;

  const BRAKE_ID = "x-scroll-brake";
  const STYLE_ID = "x-scroll-brake-styles";
  const STORAGE_KEY = "x-scroll-brake-posts-per-click";
  const SCAN_DELAY_MS = 75;
  const ROUTE_SETTLE_DELAY_MS = 300;
  const BRAKE_PANEL_HEIGHT = 118;
  const TOP_RESET_SCROLL_Y = 120;
  const TOP_RESET_MIN_DISTANCE = 600;
  const MIN_POSTS_PER_CLICK = 1;
  const MAX_POSTS_PER_CLICK = 100;
  const MAX_SAVED_ROUTE_STATES = 12;

  let visibleItemLimit = getInitialVisiblePostLimit();
  let postsPerClick = readSavedPostsPerClick();
  let currentRouteKey = getRouteKey();
  let scanTimer = null;
  let lastTouchY = null;
  let lastScrollY = window.scrollY;
  let routeSettlesAt = 0;
  let statusRootWasSeen = false;
  let isLocked = false;
  let maxScrollY = null;
  const orderedKeys = [];
  const routeStates = new Map();

  function addStyles() {
    if (document.getElementById(STYLE_ID)) {
      return;
    }

    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = `
      #${BRAKE_ID} {
        position: fixed;
        left: 50%;
        bottom: 18px;
        z-index: 2147483647;
        box-sizing: border-box;
        width: min(560px, calc(100vw - 32px));
        transform: translateX(-50%);
        padding: 14px 16px;
        border: 1px solid rgb(207, 217, 222);
        border-radius: 16px;
        background: white;
        color: rgb(15, 20, 25);
        box-shadow: 0 12px 36px rgba(15, 20, 25, 0.22);
      }

      #${BRAKE_ID}[hidden] {
        display: none !important;
      }

      #${BRAKE_ID} .x-scroll-brake-controls {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 10px;
        flex-wrap: wrap;
        font: 15px/1.3 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      #${BRAKE_ID} button {
        border: 0;
        border-radius: 999px;
        padding: 10px 16px;
        background: rgb(29, 155, 240);
        color: white;
        font: 700 15px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        cursor: pointer;
      }

      #${BRAKE_ID} button:hover {
        background: rgb(26, 140, 216);
      }

      #${BRAKE_ID} button:focus-visible,
      #${BRAKE_ID} input:focus-visible {
        outline: 3px solid rgba(29, 155, 240, 0.35);
        outline-offset: 3px;
      }

      #${BRAKE_ID} label {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        font-weight: 600;
      }

      #${BRAKE_ID} input {
        box-sizing: border-box;
        width: 64px;
        border: 1px solid rgb(207, 217, 222);
        border-radius: 8px;
        padding: 8px;
        background: white;
        color: inherit;
        font: 600 15px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      #${BRAKE_ID} .x-scroll-brake-status {
        width: 100%;
        text-align: center;
        color: rgb(83, 100, 113);
        font-size: 13px;
      }

      @media (prefers-color-scheme: dark) {
        #${BRAKE_ID} {
          border-color: rgb(47, 51, 54);
          background: black;
          color: rgb(231, 233, 234);
          box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
        }

        #${BRAKE_ID} input {
          border-color: rgb(83, 100, 113);
          background: black;
        }

        #${BRAKE_ID} .x-scroll-brake-status {
          color: rgb(113, 118, 123);
        }
      }
    `;

    document.head.appendChild(style);
  }

  function clampWholeNumber(value, fallback, min, max) {
    const number = Number(value);

    if (!Number.isFinite(number)) {
      return fallback;
    }

    return Math.min(max, Math.max(min, Math.floor(number)));
  }

  function getInitialVisiblePostLimit() {
    return clampWholeNumber(INITIAL_VISIBLE_POSTS, 10, 1, Number.MAX_SAFE_INTEGER);
  }

  function getRouteKey() {
    return `${location.pathname}${location.search}`;
  }

  function readSavedPostsPerClick() {
    let savedValue = null;

    try {
      savedValue = window.localStorage.getItem(STORAGE_KEY);
    } catch (error) {
      savedValue = null;
    }

    return clampWholeNumber(
      savedValue || POSTS_PER_CLICK,
      POSTS_PER_CLICK,
      MIN_POSTS_PER_CLICK,
      MAX_POSTS_PER_CLICK
    );
  }

  function savePostsPerClick() {
    try {
      window.localStorage.setItem(STORAGE_KEY, String(postsPerClick));
    } catch (error) {
      // The visible control still works for this page if storage is unavailable.
    }
  }

  function getMainColumn() {
    return document.querySelector('main[role="main"]');
  }

  function getTimelineRecords() {
    const main = getMainColumn();

    if (!main) {
      return [];
    }

    return getTimelineRecordsFromMain(main);
  }

  function getTimelineRecordsFromMain(main) {
    const seenItems = new Set();
    const records = [];

    Array.from(main.querySelectorAll('article[data-testid="tweet"]')).forEach((article) => {
      if (article.parentElement && article.parentElement.closest('article[data-testid="tweet"]')) {
        return;
      }

      const item = article.closest('[data-testid="cellInnerDiv"]') || article;

      if (seenItems.has(item)) {
        return;
      }

      const key = getPostKey(article);

      if (!key) {
        return;
      }

      seenItems.add(item);
      records.push({ article, item, key });
    });

    return records;
  }

  function normalizePostHref(href) {
    if (!href) {
      return "";
    }

    try {
      const url = new URL(href, location.origin);
      const statusMatch = url.pathname.match(/^\/([^/]+)\/status\/(\d+)/);

      if (statusMatch) {
        return `${statusMatch[1]}/status/${statusMatch[2]}`;
      }

      return url.pathname;
    } catch (error) {
      return href;
    }
  }

  function getPostKey(article) {
    const timestampLink = Array.from(article.querySelectorAll('a[href*="/status/"] time'))
      .map((time) => time.closest('a[href*="/status/"]'))
      .find((link) => link && link.closest('article[data-testid="tweet"]') === article);

    if (timestampLink) {
      return normalizePostHref(timestampLink.getAttribute("href"));
    }

    const statusLink = Array.from(article.querySelectorAll('a[href*="/status/"]'))
      .find((link) => link.closest('article[data-testid="tweet"]') === article);

    return statusLink ? normalizePostHref(statusLink.getAttribute("href")) : "";
  }

  function isStatusPage() {
    return /^\/[^/]+\/status\/\d+/.test(location.pathname);
  }

  function getStatusIdFromPath() {
    const match = location.pathname.match(/^\/[^/]+\/status\/(\d+)/);
    return match ? match[1] : "";
  }

  function getStatusRootIndex(records) {
    const statusId = getStatusIdFromPath();

    if (!statusId) {
      return -1;
    }

    return records.findIndex((record) => record.key.includes(`/status/${statusId}`));
  }

  function getCountableRecords(records) {
    if (!isStatusPage()) {
      return records;
    }

    const rootIndex = getStatusRootIndex(records);

    if (rootIndex !== -1) {
      statusRootWasSeen = true;
      return records.slice(rootIndex + 1);
    }

    return statusRootWasSeen ? records : [];
  }

  function rememberItemOrder(records) {
    records.forEach((record, index) => {
      if (orderedKeys.includes(record.key)) {
        return;
      }

      const previousKnownKey = findNearbyKnownKey(records, index - 1, -1);
      const nextKnownKey = findNearbyKnownKey(records, index + 1, 1);

      if (previousKnownKey) {
        orderedKeys.splice(orderedKeys.indexOf(previousKnownKey) + 1, 0, record.key);
      } else if (nextKnownKey) {
        orderedKeys.splice(orderedKeys.indexOf(nextKnownKey), 0, record.key);
      } else {
        orderedKeys.push(record.key);
      }
    });
  }

  function findNearbyKnownKey(records, startIndex, direction) {
    for (
      let index = startIndex;
      index >= 0 && index < records.length;
      index += direction
    ) {
      if (orderedKeys.includes(records[index].key)) {
        return records[index].key;
      }
    }

    return "";
  }

  function applyBrake() {
    resetAfterNavigation();

    const settleTimeLeft = routeSettlesAt - Date.now();

    if (settleTimeLeft > 0) {
      scheduleApplyBrake(settleTimeLeft);
      return;
    }

    const main = getMainColumn();

    if (!main) {
      unlockBrake();
      return;
    }

    const records = getTimelineRecordsFromMain(main);
    const countableRecords = getCountableRecords(records);

    rememberItemOrder(countableRecords);

    if (orderedKeys.length < visibleItemLimit) {
      unlockBrake();
      return;
    }

    const anchorRecord = findAnchorRecord(countableRecords);

    if (!anchorRecord) {
      unlockBrake();
      updateBrakeText();
      return;
    }

    lockAtRecord(anchorRecord);
  }

  function findAnchorRecord(records) {
    const anchorKey = orderedKeys[visibleItemLimit - 1];
    const anchorRecord = records.find((record) => record.key === anchorKey);

    if (anchorRecord) {
      return anchorRecord;
    }

    return records.find((record) => orderedKeys.indexOf(record.key) >= visibleItemLimit) || null;
  }

  function lockAtRecord(record) {
    const rect = record.item.getBoundingClientRect();
    const anchorBottom = window.scrollY + rect.bottom;
    const nextMaxScrollY = Math.max(0, anchorBottom - window.innerHeight + BRAKE_PANEL_HEIGHT);

    setMaxScrollY(nextMaxScrollY);
    isLocked = true;
    showBrake();
    updateBrakeText();
    clampScrollToBrake();
    pauseMediaAfterLimit();
  }

  function setMaxScrollY(nextMaxScrollY) {
    if (!isLocked || maxScrollY === null || nextMaxScrollY < maxScrollY) {
      maxScrollY = nextMaxScrollY;
    }
  }

  function unlockBrake() {
    isLocked = false;
    maxScrollY = null;
    hideBrake();
  }

  function ensureBrake() {
    const existingBrake = document.getElementById(BRAKE_ID);

    if (existingBrake) {
      return existingBrake;
    }

    const brake = document.createElement("div");
    brake.id = BRAKE_ID;
    brake.hidden = true;
    brake.innerHTML = `
      <div class="x-scroll-brake-controls">
        <button type="button">Load more posts</button>
        <label>
          <span>Posts</span>
          <input type="number" min="${MIN_POSTS_PER_CLICK}" max="${MAX_POSTS_PER_CLICK}" step="1" inputmode="numeric">
        </label>
        <div class="x-scroll-brake-status" aria-live="polite"></div>
      </div>
    `;

    const input = brake.querySelector("input");
    const button = brake.querySelector("button");

    input.value = String(postsPerClick);
    input.addEventListener("change", syncPostsPerClickFromInput);
    input.addEventListener("blur", syncPostsPerClickFromInput);
    input.addEventListener("input", updateBrakeText);

    button.addEventListener("click", () => {
      resetAfterNavigation();
      clearScheduledScan();
      syncPostsPerClickFromInput();
      visibleItemLimit += postsPerClick;
      unlockBrake();
      scheduleApplyBrake();
    });

    document.body.appendChild(brake);
    return brake;
  }

  function showBrake() {
    ensureBrake().hidden = false;
  }

  function hideBrake() {
    const brake = document.getElementById(BRAKE_ID);

    if (brake) {
      brake.hidden = true;
    }
  }

  function syncPostsPerClickFromInput() {
    const input = document.querySelector(`#${BRAKE_ID} input`);

    if (!input) {
      return;
    }

    postsPerClick = clampWholeNumber(
      input.value,
      postsPerClick,
      MIN_POSTS_PER_CLICK,
      MAX_POSTS_PER_CLICK
    );
    input.value = String(postsPerClick);
    savePostsPerClick();
    updateBrakeText();
  }

  function updateBrakeText() {
    const brake = ensureBrake();
    const input = brake.querySelector("input");
    const button = brake.querySelector("button");
    const status = brake.querySelector(".x-scroll-brake-status");
    const typedCount = input ? input.value : postsPerClick;
    const previewCount = clampWholeNumber(
      typedCount,
      postsPerClick,
      MIN_POSTS_PER_CLICK,
      MAX_POSTS_PER_CLICK
    );
    const itemLabel = isStatusPage() ? "items" : "posts";
    const shownCount = Math.min(visibleItemLimit, orderedKeys.length);
    const buttonText = `Load ${previewCount} more ${itemLabel}`;

    if (button && button.textContent !== buttonText) {
      button.textContent = buttonText;
    }

    if (button) {
      button.disabled = false;
    }

    if (input && document.activeElement !== input) {
      input.value = String(postsPerClick);
    }

    if (status && status.textContent !== `Showing ${shownCount} ${itemLabel}`) {
      status.textContent = `Showing ${shownCount} ${itemLabel}`;
    }
  }

  function pauseMediaAfterLimit() {
    const records = getCountableRecords(getTimelineRecords());

    records.forEach((record) => {
      const keyIndex = orderedKeys.indexOf(record.key);

      if (keyIndex >= visibleItemLimit) {
        pauseMediaInElement(record.item);
      }
    });
  }

  function pauseMediaInElement(element) {
    element.querySelectorAll("audio, video").forEach((media) => {
      media.pause();
      media.autoplay = false;
      media.removeAttribute("autoplay");
    });
  }

  function scheduleApplyBrake(delay) {
    const routeChanged = resetAfterNavigation();

    if (scanTimer !== null) {
      return;
    }

    const wait = routeChanged
      ? ROUTE_SETTLE_DELAY_MS
      : clampWholeNumber(delay, SCAN_DELAY_MS, 0, ROUTE_SETTLE_DELAY_MS);

    scanTimer = window.setTimeout(() => {
      scanTimer = null;
      applyBrake();
    }, wait);
  }

  function clearScheduledScan() {
    if (scanTimer === null) {
      return;
    }

    window.clearTimeout(scanTimer);
    scanTimer = null;
  }

  function resetAfterNavigation() {
    const nextRouteKey = getRouteKey();

    if (nextRouteKey === currentRouteKey) {
      return false;
    }

    saveCurrentRouteState();
    currentRouteKey = nextRouteKey;

    if (!restoreRouteState(nextRouteKey)) {
      resetCurrentTimelineProgress();
    }

    return true;
  }

  function saveCurrentRouteState() {
    routeStates.delete(currentRouteKey);
    routeStates.set(currentRouteKey, {
      visibleItemLimit,
      statusRootWasSeen,
      orderedKeys: orderedKeys.slice(),
    });

    while (routeStates.size > MAX_SAVED_ROUTE_STATES) {
      const oldestKey = routeStates.keys().next().value;
      routeStates.delete(oldestKey);
    }
  }

  function restoreRouteState(routeKey) {
    const state = routeStates.get(routeKey);

    if (!state) {
      return false;
    }

    visibleItemLimit = clampWholeNumber(
      state.visibleItemLimit,
      getInitialVisiblePostLimit(),
      1,
      Number.MAX_SAFE_INTEGER
    );
    statusRootWasSeen = Boolean(state.statusRootWasSeen);
    orderedKeys.length = 0;
    state.orderedKeys.forEach((key) => {
      if (key && !orderedKeys.includes(key)) {
        orderedKeys.push(key);
      }
    });
    routeSettlesAt = Date.now() + ROUTE_SETTLE_DELAY_MS;
    clearScheduledScan();
    unlockBrake();
    return true;
  }

  function resetCurrentTimelineProgress() {
    routeStates.delete(currentRouteKey);
    visibleItemLimit = getInitialVisiblePostLimit();
    routeSettlesAt = Date.now() + ROUTE_SETTLE_DELAY_MS;
    statusRootWasSeen = false;
    orderedKeys.length = 0;
    clearScheduledScan();
    unlockBrake();
  }

  function clampScrollToBrake() {
    const currentScrollY = window.scrollY;

    if (resetAfterNavigation()) {
      lastScrollY = currentScrollY;
      scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
      return;
    }

    if (shouldResetAfterTopJump(currentScrollY)) {
      resetCurrentTimelineProgress();
      lastScrollY = currentScrollY;
      scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
      return;
    }

    if (!isLocked || maxScrollY === null || window.scrollY <= maxScrollY) {
      lastScrollY = window.scrollY;
      return;
    }

    window.scrollTo({
      top: maxScrollY,
      left: window.scrollX,
      behavior: "auto",
    });
    lastScrollY = maxScrollY;
  }

  function shouldResetAfterTopJump(currentScrollY) {
    return (
      isLocked &&
      maxScrollY !== null &&
      currentScrollY <= TOP_RESET_SCROLL_Y &&
      (maxScrollY >= TOP_RESET_MIN_DISTANCE ||
        lastScrollY - currentScrollY >= TOP_RESET_MIN_DISTANCE)
    );
  }

  function preventScrollPastBrake(event) {
    if (resetAfterNavigation()) {
      scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
      return;
    }

    if (!isLocked || maxScrollY === null || isEditableTarget(event.target)) {
      return;
    }

    const deltaY = getScrollDeltaY(event);

    if (deltaY <= 0 || window.scrollY + deltaY <= maxScrollY) {
      return;
    }

    event.preventDefault();

    if (window.scrollY < maxScrollY) {
      window.scrollTo({
        top: maxScrollY,
        left: window.scrollX,
        behavior: "auto",
      });
    }
  }

  function isEditableTarget(target) {
    if (!(target instanceof Element)) {
      return false;
    }

    return Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
  }

  function getScrollDeltaY(event) {
    if (event.type === "wheel") {
      if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
        return event.deltaY * 16;
      }

      if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
        return event.deltaY * window.innerHeight;
      }

      return event.deltaY;
    }

    if (event.type === "touchmove") {
      const touch = event.touches[0];

      if (!touch || lastTouchY === null) {
        return 0;
      }

      return lastTouchY - touch.clientY;
    }

    if (event.type !== "keydown") {
      return 0;
    }

    const keyDeltas = {
      ArrowDown: 40,
      PageDown: window.innerHeight * 0.85,
      End: Number.MAX_SAFE_INTEGER,
      " ": window.innerHeight * 0.85,
    };

    return keyDeltas[event.key] || 0;
  }

  function rememberTouchPosition(event) {
    const touch = event.touches[0];
    lastTouchY = touch ? touch.clientY : null;
  }

  function forgetTouchPosition() {
    lastTouchY = null;
  }

  function handleProgressResetClick(event) {
    if (!shouldResetProgressForClick(event.target)) {
      return;
    }

    resetCurrentTimelineProgress();
    scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
  }

  function shouldResetProgressForClick(target) {
    if (!(target instanceof Element)) {
      return false;
    }

    if (target.closest('a[href="/home"], a[href$="/home"]')) {
      return true;
    }

    const control = target.closest('button, [role="button"], a');

    if (!control) {
      return false;
    }

    const label = `${control.getAttribute("aria-label") || ""} ${control.textContent || ""}`;

    return /\b(back|scroll)\s+to\s+top\b|\bshow\s+\d+\s+(new\s+)?posts?\b/i.test(label);
  }

  function handleTopResetShortcut(event) {
    if (isEditableTarget(event.target)) {
      return;
    }

    if (event.key !== "Home" && !((event.metaKey || event.ctrlKey) && event.key === "ArrowUp")) {
      return;
    }

    resetCurrentTimelineProgress();
    scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
  }

  function installNavigationHooks() {
    const notifyNavigation = () => {
      if (resetAfterNavigation()) {
        scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
      }
    };

    ["pushState", "replaceState"].forEach((methodName) => {
      const originalMethod = history[methodName];

      if (typeof originalMethod !== "function") {
        return;
      }

      history[methodName] = function () {
        const result = originalMethod.apply(this, arguments);
        window.setTimeout(notifyNavigation, 0);
        return result;
      };
    });

    window.addEventListener("popstate", notifyNavigation);
  }

  function boot() {
    addStyles();
    ensureBrake();
    installNavigationHooks();
    applyBrake();

    const observer = new MutationObserver(() => {
      scheduleApplyBrake();
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    window.addEventListener("scroll", clampScrollToBrake, { passive: true });
    window.addEventListener("click", handleProgressResetClick, {
      capture: true,
      passive: true,
    });
    window.addEventListener("wheel", preventScrollPastBrake, {
      capture: true,
      passive: false,
    });
    window.addEventListener("keydown", handleTopResetShortcut, {
      capture: true,
    });
    window.addEventListener("keydown", preventScrollPastBrake, {
      capture: true,
    });
    window.addEventListener("touchstart", rememberTouchPosition, {
      capture: true,
      passive: true,
    });
    window.addEventListener("touchmove", preventScrollPastBrake, {
      capture: true,
      passive: false,
    });
    window.addEventListener("touchmove", rememberTouchPosition, {
      capture: true,
      passive: true,
    });
    window.addEventListener("touchend", forgetTouchPosition, {
      capture: true,
      passive: true,
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot, { once: true });
  } else {
    boot();
  }
})();