Removes GitLab content attributable to locally blocked users.
// ==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 });
}
})();