// ==UserScript==
// @name Refined GitHub Comments (TJ)
// @license MIT
// @homepageURL https://github.com/tjx666/user-scripts
// @supportURL https://github.com/tjx666/user-scripts/issues
// @namespace https://github.com/tjx666/user-scripts
// @version 0.4.3
// @description Remove clutter in the comments view
// @author YuTengjing
// @match https://github.com/*/issues/*
// @match https://github.com/*/pull/*
// @match https://github.com/*/discussions/*
// @match https://github.com/*/commits/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant none
// ==/UserScript==
// common bots that i already know what they do
const authorsToMinimize = [
'changeset-bot',
'codeflowapp',
'netlify',
// 'vercel',
'pkg-pr-new',
'codecov',
'astrobot-houston',
'codspeed-hq',
'lobehubbot',
];
// common comments that don't really add value
const commentMatchToMinimize = [
/^![a-z]/, // commands that start with !
/^\/[a-z]/, // commands that start with /
/^> root@0.0.0/, // astro preview release bot
/^👍[\s\S]*Thank you for raising your pull request/, // lobehubbot PR thanks
/^👀[\s\S]*Thank you for raising an issue/, // lobehubbot issue thanks
/^✅[\s\S]*This issue is closed/, // lobehubbot issue closed
/^❤️[\s\S]*Great PR/, // lobehubbot PR merged thanks
/Bot detected the issue body's language is not English/, // lobehubbot translation
];
// DOM selectors
const SELECTORS = {
TIMELINE_ELEMENT: '.LayoutHelpers-module__timelineElement--IsjVR, [data-wrapper-timeline-id]',
COMMENT_BODY:
'[data-testid="markdown-body"] .markdown-body, .IssueCommentViewer-module__IssueCommentBody--xvkt3 .markdown-body',
COMMENT_CONTENT:
'.IssueCommentViewer-module__IssueCommentBody--xvkt3, [data-testid="markdown-body"]',
COMMENT_HEADER: '[data-testid="comment-header"]',
AUTHOR_LINK: '.ActivityHeader-module__AuthorLink--D7Ojk, [data-testid="avatar-link"]',
COMMENT_ACTIONS:
'[data-testid="comment-header-hamburger"], .CommentActions-module__CommentActionsIconButton--EOXv7',
TITLE_CONTAINER: '.ActivityHeader-module__TitleContainer--pa99A',
FOOTER_CONTAINER:
'.ActivityHeader-module__footer--ssKOW, .ActivityHeader-module__FooterContainer--FHEpM',
ACTIONS_CONTAINER: '.ActivityHeader-module__ActionsButtonsContainer--L7GUK',
};
// Used by `minimizeDiscussionThread`
let expandedThread = false;
const maxParentThreadHeight = 185;
// Used by `minimizeReactBlockquote` to track seen comments
const seenReactComments = [];
(function () {
'use strict';
run();
// listen to github page loaded event
document.addEventListener('pjax:end', () => run());
document.addEventListener('turbo:render', () => run());
})();
function run() {
injectCSS();
setTimeout(() => {
// Handle React version comments
const reactComments = document.querySelectorAll('.react-issue-comment');
reactComments.forEach((comment) => {
minimizeReactComment(comment);
minimizeReactBlockquote(comment, seenReactComments);
});
// Handle PR comments
const timelineItems = document.querySelectorAll('.js-timeline-item');
timelineItems.forEach((timelineItem) => {
minimizePRComment(timelineItem);
});
// Discussion threads view
if (location.pathname.includes('/discussions/')) {
minimizeDiscussionThread();
}
setupDOMObserver();
}, 1000);
}
function injectCSS() {
// Remove existing style if any
const existingStyle = document.getElementById('refined-github-comments-style');
if (existingStyle) {
existingStyle.remove();
}
const style = document.createElement('style');
style.id = 'refined-github-comments-style';
style.textContent = `
/* Layout for minimized React comments */
.refined-github-comments-minimized .ActivityHeader-module__CommentHeaderContentContainer--OOrIN {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
flex-wrap: nowrap !important;
gap: 4px !important;
flex: 1 !important;
}
.refined-github-comments-minimized .ActivityHeader-module__FooterContainer--FHEpM {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
flex: 1 !important;
overflow: hidden !important;
}
.refined-github-comments-minimized .ActivityHeader-module__narrowViewportWrapper--k4ncm.ActivityHeader-module__ActionsContainer--Ebsux {
flex-grow: 0 !important;
}
.refined-github-comments-minimized .ActivityHeader-module__HeaderMutedText--aJAo0 {
flex-shrink: 0 !important;
}
/* Excerpt text styling */
.refined-github-comments-minimized .ActivityHeader-module__FooterContainer--FHEpM .color-fg-muted,
.timeline-comment-header .css-truncate-overflow {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
display: inline-block !important;
}
/* Toggle button styling */
.refined-github-comments-toggle.timeline-comment-action {
padding: 0 6px !important;
margin: 0 !important;
}
/* Hidden elements */
.refined-github-comments-hidden {
display: none !important;
}
`;
document.head.appendChild(style);
}
function setupDOMObserver() {
const observer = new MutationObserver((mutations) => {
const hasNewComments = mutations.some(
(mutation) =>
mutation.type === 'childList' &&
Array.from(mutation.addedNodes).some(
(node) =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('react-issue-comment') ||
node.querySelector?.('.react-issue-comment') ||
node.classList?.contains('js-timeline-item') ||
node.querySelector?.('.js-timeline-item')),
),
);
if (hasNewComments) {
setTimeout(() => {
// Handle React comments
document.querySelectorAll('.react-issue-comment').forEach((comment) => {
minimizeReactComment(comment);
minimizeReactBlockquote(comment, seenReactComments);
});
// Handle PR comments
document.querySelectorAll('.js-timeline-item').forEach((timelineItem) => {
minimizePRComment(timelineItem);
});
}, 500);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
/**
* Extract author name from link
* @param {HTMLElement} authorLink
* @returns {string}
*/
function getAuthorName(authorLink) {
let authorName =
authorLink.getAttribute('href')?.replace('/', '') || authorLink.textContent.trim();
if (authorName.startsWith('apps/')) {
authorName = authorName.replace('apps/', '');
}
return authorName;
}
/**
* Check if comment should be minimized
* @param {string} authorName
* @param {string} commentText
* @returns {boolean}
*/
function shouldMinimizeComment(authorName, commentText) {
const shouldMinimizeByAuthor = authorsToMinimize.includes(authorName);
const matchingPattern = commentMatchToMinimize.find((match) => match.test(commentText));
return shouldMinimizeByAuthor || matchingPattern;
}
/**
* Create comment excerpt element
* @param {string} text
* @returns {HTMLElement}
*/
function createExcerpt(text) {
const excerpt = document.createElement('span');
excerpt.className = 'css-truncate-overflow text-fg-muted text-italic';
excerpt.style.fontSize = '12px';
excerpt.style.opacity = '0.6';
excerpt.style.whiteSpace = 'nowrap';
excerpt.style.overflow = 'hidden';
excerpt.style.textOverflow = 'ellipsis';
excerpt.style.display = 'inline-block';
excerpt.style.verticalAlign = 'middle';
excerpt.textContent = text;
return excerpt;
}
/**
* Setup toggle button styling and placement
* @param {HTMLElement} commentActions
* @param {HTMLElement} toggleBtn
* @param {HTMLElement} beforeElement - Optional element to insert before
*/
function setupToggleButton(commentActions, toggleBtn, beforeElement = null) {
commentActions.style.display = 'flex';
commentActions.style.alignItems = 'center';
commentActions.style.gap = '4px';
if (beforeElement) {
commentActions.insertBefore(toggleBtn, beforeElement);
} else {
commentActions.insertBefore(toggleBtn, commentActions.firstChild);
}
}
/**
* Handle PR comments
* @param {HTMLElement} timelineItem
*/
function minimizePRComment(timelineItem) {
// Skip if already processed
if (timelineItem.querySelector('.refined-github-comments-toggle')) {
return;
}
// Find timeline comment
const timelineComment = timelineItem.querySelector('.timeline-comment');
if (!timelineComment) return;
// Find comment header
const header = timelineComment.querySelector('.timeline-comment-header');
if (!header) return;
// Find author in h3 strong a structure
const authorLink = header.querySelector('h3 strong .author');
if (!authorLink) return;
// Find comment body
const commentBody = timelineComment.querySelector(
'.comment-body.markdown-body.js-comment-body',
);
if (!commentBody) return;
const authorName = getAuthorName(authorLink);
const commentBodyText = commentBody.innerText.trim();
if (shouldMinimizeComment(authorName, commentBodyText)) {
// Find comment actions container
const commentActions = header.querySelector('.timeline-comment-actions');
if (!commentActions) return;
// Hide comment body content
const taskLists = timelineComment.querySelector('task-lists');
if (taskLists) {
taskLists.style.display = 'none';
} else {
const commentBody = timelineComment.querySelector('.comment-body');
if (commentBody) {
commentBody.style.display = 'none';
}
}
// Remove border bottom from header
header.style.borderBottom = 'none';
// Hide mention buttons
toggleMentionButtons(timelineItem, false);
// Add comment excerpt in header
const titleContainer = header.querySelector('h3.f5.text-normal');
if (titleContainer) {
// Find the div inside h3 to add excerpt there (keep it on same line)
const innerDiv = titleContainer.querySelector('div');
if (innerDiv) {
const excerpt = createExcerpt(commentBodyText);
innerDiv.parentElement.style.overflow = 'hidden';
innerDiv.style.display = 'flex';
innerDiv.style.alignItems = 'center';
innerDiv.style.gap = '4px';
excerpt.style.flex = '1';
innerDiv.appendChild(excerpt);
}
// Add toggle button
const toggleBtn = toggleComment((isShow) => {
const currentTaskLists = timelineComment.querySelector('task-lists');
const currentCommentBody = timelineComment.querySelector('.comment-body');
if (isShow) {
if (currentTaskLists) {
currentTaskLists.style.display = '';
} else if (currentCommentBody) {
currentCommentBody.style.display = '';
}
header.style.borderBottom = '';
if (innerDiv && innerDiv.querySelector('.css-truncate-overflow')) {
innerDiv.querySelector('.css-truncate-overflow').style.display = 'none';
}
toggleMentionButtons(timelineItem, true);
} else {
if (currentTaskLists) {
currentTaskLists.style.display = 'none';
} else if (currentCommentBody) {
currentCommentBody.style.display = 'none';
}
header.style.borderBottom = 'none';
if (innerDiv && innerDiv.querySelector('.css-truncate-overflow')) {
innerDiv.querySelector('.css-truncate-overflow').style.display = '';
}
toggleMentionButtons(timelineItem, false);
}
});
// Style and insert toggle button
setupToggleButton(commentActions, toggleBtn);
}
}
}
/**
* Toggle mention buttons visibility
* @param {HTMLElement} element - Can be either a React comment or PR timeline item
* @param {boolean} show
*/
function toggleMentionButtons(element, show) {
let mentionContainer = null;
// Strategy 1: Find mention container directly within the element (for PR comments)
mentionContainer = element.querySelector('.avatar-parent-child');
// Strategy 2: Find via closest timeline element (for React comments)
if (!mentionContainer) {
const timelineElement = element.closest(SELECTORS.TIMELINE_ELEMENT);
if (timelineElement) {
mentionContainer = timelineElement.querySelector('.avatar-parent-child');
}
}
// Strategy 3: For issue comments, try to find timeline element as sibling container
if (!mentionContainer) {
// Look for timeline element that contains both avatar-parent-child and this element
const timelineElements = document.querySelectorAll(SELECTORS.TIMELINE_ELEMENT);
for (const timeline of timelineElements) {
if (timeline.contains(element) && timeline.querySelector('.avatar-parent-child')) {
mentionContainer = timeline.querySelector('.avatar-parent-child');
break;
}
}
}
// Strategy 4: Direct search for mention buttons in nearby containers
if (!mentionContainer) {
// Look for mention buttons in the document that might be related to this comment
const commentId = element.querySelector('[data-testid="comment-header"]')?.id;
if (commentId) {
const timelineWrapper = document.querySelector(
`[data-wrapper-timeline-id="${commentId}"]`,
);
if (timelineWrapper) {
mentionContainer = timelineWrapper.querySelector('.avatar-parent-child');
}
}
}
if (!mentionContainer) return;
const mentionBtns = mentionContainer.querySelectorAll('.rgh-quick-mention');
mentionBtns.forEach((btn) => {
if (show) {
btn.classList.remove('refined-github-comments-hidden');
} else {
btn.classList.add('refined-github-comments-hidden');
}
});
}
/**
* Handle React version GitHub comments
* @param {HTMLElement} reactComment
*/
function minimizeReactComment(reactComment) {
// Skip if already processed
if (reactComment.querySelector('.refined-github-comments-toggle')) {
return;
}
// Find comment header
const header = reactComment.querySelector(SELECTORS.COMMENT_HEADER);
if (!header) return;
// Find author
const authorLink = header.querySelector(SELECTORS.AUTHOR_LINK);
if (!authorLink) return;
// Find comment body
const commentBody = reactComment.querySelector(SELECTORS.COMMENT_BODY);
if (!commentBody) return;
const authorName = getAuthorName(authorLink);
const commentBodyText = commentBody.innerText.trim();
if (shouldMinimizeComment(authorName, commentBodyText)) {
const commentContent = reactComment.querySelector(SELECTORS.COMMENT_CONTENT);
if (!commentContent) return;
const commentActions = header.querySelector(SELECTORS.COMMENT_ACTIONS);
if (!commentActions) return;
const titleContainer = header.querySelector(SELECTORS.TITLE_CONTAINER);
if (!titleContainer) return;
// Hide comment content
commentContent.style.display = 'none';
// Remove border bottom from header
header.style.borderBottom = 'none';
// Hide mention buttons
toggleMentionButtons(reactComment, false);
// Add CSS class for layout styling
reactComment.classList.add('refined-github-comments-minimized');
// Add comment excerpt
const footerContainer = header.querySelector(SELECTORS.FOOTER_CONTAINER);
let excerpt = null;
if (footerContainer) {
excerpt = document.createElement('span');
excerpt.setAttribute('class', 'color-fg-muted text-italic');
excerpt.innerHTML = commentBodyText;
excerpt.style.opacity = '0.5';
excerpt.style.fontSize = '12px';
excerpt.style.marginLeft = '4px';
footerContainer.appendChild(excerpt);
}
// Add toggle button
const toggleBtn = toggleComment((isShow) => {
if (isShow) {
commentContent.style.display = '';
header.style.borderBottom = '';
if (excerpt) excerpt.style.display = 'none';
toggleMentionButtons(reactComment, true);
reactComment.classList.remove('refined-github-comments-minimized');
} else {
commentContent.style.display = 'none';
header.style.borderBottom = 'none';
if (excerpt) excerpt.style.display = '';
toggleMentionButtons(reactComment, false);
reactComment.classList.add('refined-github-comments-minimized');
}
});
// Find actions container and setup toggle button
const actionsContainer = header.querySelector(SELECTORS.ACTIONS_CONTAINER);
if (!actionsContainer) return;
setupToggleButton(actionsContainer, toggleBtn, commentActions);
}
}
/**
* Handle blockquotes in React comments (new GitHub structure)
* @param {HTMLElement} reactComment
* @param {{ text: string, id: string, author: string }[]} seenComments
*/
function minimizeReactBlockquote(reactComment, seenComments) {
const commentBody = reactComment.querySelector('[data-testid="markdown-body"] .markdown-body');
if (!commentBody) return;
const commentHeader = reactComment.querySelector('[data-testid="comment-header"]');
if (!commentHeader) return;
const commentId = commentHeader.id; // e.g., "issuecomment-1528936387"
if (!commentId) return;
const authorLink = commentHeader.querySelector('[data-testid="avatar-link"]');
if (!authorLink) return;
const commentAuthor = authorLink.textContent.trim();
if (!commentAuthor) return;
const commentText = commentBody.innerText.trim().replace(/\s+/g, ' ');
// bail early in first comment and if comment is already checked before
if (
seenComments.length === 0 ||
commentBody.querySelector('.refined-github-comments-reply-text')
) {
seenComments.push({
text: commentText,
id: commentId,
author: commentAuthor,
});
return;
}
const blockquotes = commentBody.querySelectorAll(':scope > blockquote');
for (const blockquote of blockquotes) {
const blockquoteText = blockquote.innerText.trim().replace(/\s+/g, ' ');
const dupIndex = seenComments.findIndex((comment) => comment.text === blockquoteText);
if (dupIndex >= 0) {
const dup = seenComments[dupIndex];
// if replying to the one above, always minimize it
if (dupIndex === seenComments.length - 1) {
const summary = `\
`;
blockquote.innerHTML = `${summary}
${blockquote.innerHTML}${summary}
${blockquote.innerHTML}