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와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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 });
  }
})();