Collapses outdated GitHub PR threads only when you wrote the latest reply.
// ==UserScript==
// @name GitHub PR - Collapse Outdated Comments
// @namespace https://github.com/eveoh354/github-collapse-outdated-pr-comments
// @version 1.1.1
// @description Collapses outdated GitHub PR threads only when you wrote the latest reply.
// @author eveoh354
// @homepageURL https://github.com/eveoh354/github-collapse-outdated-pr-comments
// @supportURL https://github.com/eveoh354/github-collapse-outdated-pr-comments/issues
// @match https://github.com/*/*/pull/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const automaticallyCollapsed = new WeakSet();
let scheduled = false;
const isPullRequestPage = () => /^\/[^/]+\/[^/]+\/pull\/\d+(?:\/|$)/.test(location.pathname);
const isVisible = (element) => {
if (!(element instanceof HTMLElement)) return false;
return element.getClientRects().length > 0;
};
const getLoggedInUser = () =>
document.querySelector('meta[name="user-login"]')?.getAttribute('content')?.toLowerCase();
const getUsernameFromProfileLink = (link) => {
if (!(link instanceof HTMLAnchorElement)) return undefined;
const match = link.pathname.match(/^\/([^/]+)\/?$/);
return match ? decodeURIComponent(match[1]).toLowerCase() : undefined;
};
const getLastReplyAuthor = (thread) => {
const authorLinks = thread.querySelectorAll(
'a.author, ' +
'[data-testid="comment-header"] a[data-testid="avatar-link"], ' +
'[data-testid="comment-header"] a[data-hovercard-type="user"]',
);
const authors = [...authorLinks].map(getUsernameFromProfileLink).filter(Boolean);
return authors.at(-1);
};
const hasOutdatedBadge = (thread) => {
const badges = thread.querySelectorAll(
'.Label, ' +
'[class*="label" i], ' +
'[data-testid*="outdated" i], ' +
'[aria-label*="outdated" i], ' +
'[title*="outdated" i]',
);
return [...badges].some((badge) => {
const text = (badge.textContent ?? '').replace(/\s+/g, ' ').trim();
const metadata = `${badge.getAttribute('data-testid') ?? ''} ${badge.getAttribute('aria-label') ?? ''} ${badge.getAttribute('title') ?? ''}`;
return /^outdated$/i.test(text) || /\boutdated\b/i.test(metadata);
});
};
const isOutdatedThread = (thread) => {
if (thread.getAttribute('data-resolved') === 'true') return false;
const header = thread.querySelector(':scope > .js-toggle-outdated-comments');
const toggleText = header?.textContent ?? '';
if (/\boutdated\b/i.test(toggleText)) return true;
// GitHub's conversation view renders the Outdated badge in the nested file
// header rather than in the collapsible thread header.
if (hasOutdatedBadge(thread)) return true;
// In GitHub's current markup, an unresolved collapsible thread with this
// header class is an outdated thread. Resolved threads are excluded above.
if (header && thread.getAttribute('data-resolved') === 'false') return true;
return Boolean(
thread.querySelector(
':scope > [aria-label*="outdated" i], ' +
':scope > [title*="outdated" i], ' +
':scope > [data-testid*="outdated" i], ' +
':scope > .outdated-comment',
),
);
};
const findThread = (element) =>
element.closest(
'review-thread-collapsible, .js-resolvable-timeline-thread-container, .js-resolvable-thread, .review-thread',
);
const getReviewThreads = () => {
const threads = new Set(document.querySelectorAll('review-thread-collapsible'));
for (const toggle of document.querySelectorAll('.js-toggle-outdated-comments')) {
const thread = findThread(toggle);
if (thread) threads.add(thread);
}
return threads;
};
const getToggleButton = (thread, expanded) =>
thread.querySelector(
`[data-target="review-thread-collapsible.button"][aria-expanded="${expanded}"], ` +
`:is(button, [role="button"])[data-action*="review-thread-collapsible#toggle"][aria-expanded="${expanded}"], ` +
`.js-toggle-outdated-comments:is(button, [role="button"])[aria-expanded="${expanded}"]`,
);
const updateThreads = () => {
const loggedInUser = getLoggedInUser();
if (!loggedInUser) return;
for (const thread of getReviewThreads()) {
if (!isOutdatedThread(thread)) continue;
const lastReplyAuthor = getLastReplyAuthor(thread);
if (!lastReplyAuthor) continue;
const expanded = getToggleButton(thread, 'true');
const collapsed = getToggleButton(thread, 'false');
const lastReplyIsMine = lastReplyAuthor === loggedInUser;
if (lastReplyIsMine && expanded && isVisible(expanded)) {
expanded.click();
automaticallyCollapsed.add(thread);
continue;
}
// If somebody replies after this script collapsed the thread, reopen it so
// the new message cannot remain hidden in the same browser session.
if (!lastReplyIsMine && automaticallyCollapsed.has(thread) && collapsed && isVisible(collapsed)) {
automaticallyCollapsed.delete(thread);
collapsed.click();
}
}
};
const collapseOutdatedThreads = () => {
scheduled = false;
if (!isPullRequestPage()) return;
updateThreads();
};
const scheduleCollapse = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(collapseOutdatedThreads);
};
const observer = new MutationObserver(scheduleCollapse);
const start = () => {
observer.disconnect();
if (document.body) observer.observe(document.body, { childList: true, subtree: true });
scheduleCollapse();
};
// GitHub uses client-side navigation, so a traditional page-load hook is not enough.
document.addEventListener('turbo:load', start);
document.addEventListener('pjax:end', start);
window.addEventListener('popstate', start);
start();
})();