Block GitLab Users

Removes GitLab content attributable to locally blocked users.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name                Block GitLab Users
// @namespace           https://gitlab.com/
// @version             1.0.0
// @description         Removes GitLab content attributable to locally blocked users.
// @author              Dam
// @match               https://gitlab.com/*
// @run-at              document-start
// @grant               GM_addStyle
// @noframes
// @icon                https://files.catbox.moe/8baoxn.png
// @license             MIT
// ==/UserScript==

(() => {
  'use strict';

  /**
   * Add as many entries as needed. A username is required and a numeric user ID
   * is optional but STRONGLY RECOMMENDED because it survives display-name
   * changes and makes avatar matching possible.
   *
   * Example blocking two users, one with an ID definition:
   *
   *  const BLOCKED_USERS = [
   *    { username: 'username_here', userId: 'ID here (OPTIONAL)' },
   *    { username: 'another_user' },
   *  ];
   *
   */
  const BLOCKED_USERS = [
    { username: 'some_annoying+useless_pos', userId: '1' },
  ];

  const users = BLOCKED_USERS.map((entry) => {
    const value = typeof entry === 'string' ? { username: entry } : entry;
    return {
      username: String(value.username || '').trim().replace(/^@/, '').toLowerCase(),
      userId: value.userId == null ? '' : String(value.userId).trim(),
    };
  }).filter((entry) => entry.username);

  if (!users.length) return;

  const STYLE_ID = 'gitlab-blocked-users-prehide';
  const REMOVED_ATTRIBUTE = 'data-gitlab-blocked-users-removed';
  const CONTENT_BODY_SELECTOR = [
    '.md',
    '.note-text',
    '.wiki',
    '[data-testid="work-item-note-body"]',
    '[data-testid="note-body"]',
  ].join(',');
  const NOTE_SELECTOR = [
    'li.js-timeline-entry',
    'li[data-testid="noteable-note-container"]',
    'li.note',
    'li.system-note',
    '[data-testid="note-wrapper"]',
  ].join(',');
  const ISSUABLE_ROW_SELECTOR = [
    'li[data-testid="issuable-container"]',
    'li.issue.merge-request',
    'li.issue',
  ].join(',');
  const MEMBER_ROW_SELECTOR = [
    'tr[data-testid^="members-table-row-"]',
    'tr[id*="__row_"]',
    '[data-testid="member-row"]',
  ].join(',');
  const COMMIT_ROW_SELECTOR = [
    'li[id^="commit-"]',
    '[data-testid="commit-row"]',
  ].join(',');
  const ACTIVITY_ROW_SELECTOR = [
    'li.event-item',
    '.event-item',
    '[data-testid="event-item"]',
  ].join(',');

  let cleanupQueued = false;
  let observer;
  let prehideInstalled = false;

  function cssEscape(value) {
    if (globalThis.CSS?.escape) return CSS.escape(value);
    return String(value).replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`);
  }

  function absoluteUrl(value) {
    if (!value) return null;
    try {
      return new URL(value, location.origin);
    } catch {
      return null;
    }
  }

  function normalizedPath(value) {
    const url = absoluteUrl(value);
    if (!url || url.origin !== location.origin) return '';
    const decoded = (() => {
      try {
        return decodeURIComponent(url.pathname);
      } catch {
        return url.pathname;
      }
    })();
    return decoded.replace(/\/+$/, '') || '/';
  }

  function exactProfileUsername(value) {
    const path = normalizedPath(value).toLowerCase();
    if (!path) return '';
    const direct = path.match(/^\/([^/]+)$/);
    if (direct) return direct[1];
    const usersPath = path.match(/^\/users\/([^/]+)$/);
    return usersPath ? usersPath[1] : '';
  }

  function isBlockedProfileHref(value) {
    const username = exactProfileUsername(value);
    return Boolean(username && users.some((user) => user.username === username));
  }

  function isBlockedNamespaceHref(value) {
    const path = normalizedPath(value).toLowerCase();
    if (!path) return false;
    return users.some((user) => (
      path === `/${user.username}` || path.startsWith(`/${user.username}/`)
    ));
  }

  function elementIdentity(element, options = {}) {
    if (!(element instanceof Element)) return null;

    const usernameAttributes = [
      element.getAttribute('data-username'),
      element.dataset?.username,
    ].filter(Boolean).map((value) => String(value).replace(/^@/, '').toLowerCase());
    const idAttributes = [
      element.getAttribute('data-user-id'),
      element.getAttribute('data-author-id'),
      element.dataset?.userId,
      element.dataset?.authorId,
    ].filter(Boolean).map(String);
    const href = element.getAttribute('href');
    const profileUsername = exactProfileUsername(href);
    const mediaValues = [
      element.getAttribute('src'),
      element.getAttribute('data-src'),
      element.getAttribute('srcset'),
    ].filter(Boolean);

    return users.find((user) => {
      if (usernameAttributes.includes(user.username)) return true;
      if (user.userId && idAttributes.includes(user.userId)) return true;
      if (profileUsername === user.username) return true;
      if (user.userId && mediaValues.some((value) => (
        value.includes(`/user/avatar/${user.userId}/`)
      ))) return true;
      return options.allowNamespace && href && isBlockedNamespaceHref(href);
    }) || null;
  }

  function isInsideAuthoredBody(element, container) {
    const body = element.closest(CONTENT_BODY_SELECTOR);
    return Boolean(body && (!container || container.contains(body)));
  }

  function strongIdentityElements(container, options = {}) {
    if (!(container instanceof Element)) return [];

    const selectors = [];
    for (const user of users) {
      const username = cssEscape(user.username);
      selectors.push(
        `[data-username="${username}"]`,
        `[data-username="@${username}"]`,
        `a[href="/${username}"]`,
        `a[href="${location.origin}/${username}"]`,
      );
      if (user.userId) {
        const id = cssEscape(user.userId);
        selectors.push(
          `[data-user-id="${id}"]`,
          `[data-author-id="${id}"]`,
          `img[src*="/user/avatar/${id}/"]`,
          `img[data-src*="/user/avatar/${id}/"]`,
        );
      }
    }

    return [...container.querySelectorAll(selectors.join(','))].filter((element) => {
      if (!elementIdentity(element, options)) return false;
      if (!options.allowBodies && isInsideAuthoredBody(element, container)) return false;
      if (element.matches('.gfm, .gfm-project_member')) return false;
      return true;
    });
  }

  function containerIsAuthoredByBlockedUser(container) {
    return strongIdentityElements(container).length > 0;
  }

  function markAndRemove(element) {
    if (!(element instanceof Element) || !element.isConnected) return false;
    element.setAttribute(REMOVED_ATTRIBUTE, 'true');
    element.remove();
    return true;
  }

  function installPrehideCss() {
    if (prehideInstalled || document.getElementById(STYLE_ID)) return;
    prehideInstalled = true;

    const identitySelectors = users.flatMap((user) => {
      const result = [`[data-username="${cssEscape(user.username)}"]`];
      if (user.userId) {
        const id = cssEscape(user.userId);
        result.push(
          `[data-user-id="${id}"]`,
          `[data-author-id="${id}"]`,
          `img[src*="/user/avatar/${id}/"]`,
          `img[data-src*="/user/avatar/${id}/"]`,
        );
      }
      return result;
    });

    const containers = [
      'li.js-timeline-entry',
      'li[data-testid="issuable-container"]',
      'tr[data-testid^="members-table-row-"]',
      'li[id^="commit-"]',
      'li.event-item',
    ];
    const rules = containers.flatMap((container) => (
      identitySelectors.map((identity) => `${container}:has(${identity})`)
    ));
    rules.push(...users.flatMap((user) => {
      const username = cssEscape(user.username);
      const result = [
        `.participants a[data-username="${username}"]`,
        `.participants a[href="/${username}"]`,
        `.participants a[href="${location.origin}/${username}"]`,
      ];
      if (user.userId) result.push(`.participants a[data-user-id="${cssEscape(user.userId)}"]`);
      return result;
    }));

    const css = `${rules.join(',\n')} { display: none !important; }`;
    if (typeof GM_addStyle === 'function') {
      const style = GM_addStyle(css);
      if (style instanceof Element) style.id = STYLE_ID;
      return;
    }

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;
    (document.head || document.documentElement).append(style);
  }

  function immediateRouteRedirect() {
    const pathname = normalizedPath(location.href).toLowerCase();

    for (const user of users) {
      if (user.userId && pathname.includes(`/uploads/-/system/user/avatar/${user.userId}/`)) {
        location.replace(`${location.origin}/`);
        return true;
      }

      if (
        pathname === `/${user.username}` ||
        pathname.startsWith(`/${user.username}/`) ||
        pathname === `/users/${user.username}` ||
        pathname.startsWith(`/users/${user.username}/`)
      ) {
        location.replace(`${location.origin}/`);
        return true;
      }
    }

    return false;
  }

  function pageAuthorElements() {
    return [...document.querySelectorAll([
      '[data-testid="author-link"]',
      '[data-testid="work-item-author"]',
    ].join(','))];
  }

  function listUrlForDetailPage() {
    const path = normalizedPath(location.href);
    const patterns = [
      { regex: /^(.*\/-\/)merge_requests\/\d+(?:\/.*)?$/i, list: 'merge_requests' },
      { regex: /^(.*\/-\/)work_items\/\d+(?:\/.*)?$/i, list: 'work_items' },
      { regex: /^(.*\/-\/)issues\/\d+(?:\/.*)?$/i, list: 'issues' },
    ];

    for (const pattern of patterns) {
      const match = path.match(pattern.regex);
      if (match) return `${location.origin}${match[1]}${pattern.list}`;
    }
    return '';
  }

  function redirectBlockedAuthoredPage() {
    const blockedAuthor = pageAuthorElements().find((element) => elementIdentity(element));
    if (!blockedAuthor) return false;

    const listUrl = listUrlForDetailPage();
    if (listUrl) {
      location.replace(listUrl);
      return true;
    }

    // A work item opened in the list-side panel keeps the list URL and adds a
    // `show` query parameter. Remove the parameter to close the blocked item.
    const url = new URL(location.href);
    if (url.searchParams.has('show')) {
      url.searchParams.delete('show');
      location.replace(url.href);
      return true;
    }

    return false;
  }

  function removeAuthoredContainers(selector, authorSelector) {
    const seen = new Set();
    for (const candidate of document.querySelectorAll(selector)) {
      const container = candidate.closest(selector) || candidate;
      if (seen.has(container)) continue;
      seen.add(container);

      const authored = authorSelector
        ? [...container.querySelectorAll(authorSelector)].some((element) => elementIdentity(element))
        : containerIsAuthoredByBlockedUser(container);
      if (authored) markAndRemove(container);
    }
  }

  function removeNotes() {
    const seen = new Set();
    for (const candidate of document.querySelectorAll(NOTE_SELECTOR)) {
      const container = candidate.closest('li.js-timeline-entry, li.note, li.system-note') || candidate;
      if (seen.has(container)) continue;
      seen.add(container);
      const authoredByBlockedUser = strongIdentityElements(container).some((identity) => {
        // GitLab discussions can nest reply <li> elements inside a parent
        // timeline entry. Only let an identity remove the nearest note entry
        // that owns it, never the entire surrounding discussion thread.
        const owningEntry = identity.closest('li.js-timeline-entry, li.note, li.system-note')
          || identity.closest('[data-testid="note-wrapper"]');
        return owningEntry === container;
      });
      if (authoredByBlockedUser) markAndRemove(container);
    }
  }

  function removeParticipants() {
    for (const participants of document.querySelectorAll('.participants')) {
      const participantLinks = [...participants.querySelectorAll('a')];
      for (const link of participantLinks) {
        if (elementIdentity(link) || strongIdentityElements(link).length) markAndRemove(link);
      }

      const remaining = participants.querySelectorAll('.gl-flex-wrap a').length;
      const label = `${remaining} Participant${remaining === 1 ? '' : 's'}`;
      const collapsed = participants.querySelector('.sidebar-collapsed-icon');
      const collapsedCount = collapsed?.querySelector('span');
      const expandedLabel = participants.querySelector('.hide-collapsed > div:first-child');
      if (collapsed?.getAttribute('title') !== label) collapsed?.setAttribute('title', label);
      if (collapsedCount && collapsedCount.textContent.trim() !== String(remaining)) {
        collapsedCount.textContent = String(remaining);
      }
      if (expandedLabel && expandedLabel.textContent.trim() !== label) {
        expandedLabel.textContent = label;
      }
    }
  }

  function removeMentionsAndInlineIdentity() {
    const exactProfileLinks = [...document.querySelectorAll('a[href]')]
      .filter((link) => isBlockedProfileHref(link.getAttribute('href')));

    for (const link of exactProfileLinks) {
      if (!link.isConnected) continue;

      // Authored containers have already been removed. Mentions written by
      // somebody else are removed as inline links without deleting that
      // person's entire comment or description.
      if (link.closest(CONTENT_BODY_SELECTOR)) {
        markAndRemove(link);
        continue;
      }

      if (link.closest('.participants')) continue;
      if (link.closest('.issuable-sidebar, [data-testid="issuable-sidebar"]')) {
        markAndRemove(link.closest('li, [role="option"], .user-list-item') || link);
        continue;
      }

      const smallRow = link.closest([
        '[role="option"]',
        '.user-list-item',
        '.autocomplete-row',
        '.gl-dropdown-item',
      ].join(','));
      markAndRemove(smallRow || link);
    }

    const namespaceLinks = [...document.querySelectorAll('a[href]')]
      .filter((link) => isBlockedNamespaceHref(link.getAttribute('href')));
    for (const link of namespaceLinks) {
      if (!link.isConnected || isBlockedProfileHref(link.getAttribute('href'))) continue;
      if (link.closest(CONTENT_BODY_SELECTOR)) markAndRemove(link);
    }

    for (const element of document.querySelectorAll('[data-user-id], [data-username], [data-author-id]')) {
      if (!elementIdentity(element) || !element.isConnected) continue;
      if (element.closest(CONTENT_BODY_SELECTOR)) {
        markAndRemove(element);
        continue;
      }
      const option = element.closest('[role="option"], .user-list-item, .autocomplete-row, .gl-dropdown-item');
      if (option) markAndRemove(option);
    }
  }

  function removeEmptyGroups() {
    for (const group of document.querySelectorAll('li.daily-commit, [data-testid="daily-commits"]')) {
      if (!group.querySelector('li[id^="commit-"], [data-testid="commit-row"]')) markAndRemove(group);
    }
  }

  function cleanPage() {
    if (immediateRouteRedirect()) return;
    installPrehideCss();
    if (redirectBlockedAuthoredPage()) return;

    removeNotes();
    removeAuthoredContainers(
      ISSUABLE_ROW_SELECTOR,
      '[data-testid="issuable-author"], .author-link[data-user-id], .author-link[data-username]',
    );
    removeAuthoredContainers(
      MEMBER_ROW_SELECTOR,
      '[data-user-id], [data-username], img[src*="/user/avatar/"]',
    );
    removeAuthoredContainers(
      COMMIT_ROW_SELECTOR,
      '[data-testid="commit-author-link"], [data-testid="commit-user-popover"], [data-user-id], [data-username], img[src*="/user/avatar/"]',
    );
    removeAuthoredContainers(ACTIVITY_ROW_SELECTOR);
    removeParticipants();
    removeMentionsAndInlineIdentity();
    removeEmptyGroups();
  }

  function scheduleCleanup() {
    if (cleanupQueued) return;
    cleanupQueued = true;
    queueMicrotask(() => {
      cleanupQueued = false;
      cleanPage();
    });
  }

  function startObserver() {
    if (observer || immediateRouteRedirect()) return;
    installPrehideCss();
    cleanPage();

    observer = new MutationObserver(scheduleCleanup);
    observer.observe(document.documentElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: [
        'data-user-id',
        'data-author-id',
        'data-username',
        'href',
        'src',
        'data-src',
      ],
    });
    addEventListener('popstate', scheduleCleanup);
    addEventListener('hashchange', scheduleCleanup);
    document.addEventListener('turbo:load', scheduleCleanup);
  }

  if (document.documentElement) {
    startObserver();
  } else {
    const bootstrapObserver = new MutationObserver(() => {
      if (!document.documentElement) return;
      bootstrapObserver.disconnect();
      startObserver();
    });
    bootstrapObserver.observe(document, { childList: true });
  }
})();