diff --git a/src/css/content.scss b/src/css/content.scss index 6e63d43b..60d0a41c 100644 --- a/src/css/content.scss +++ b/src/css/content.scss @@ -39,6 +39,29 @@ $color-dark-yellow: #DAA520; .almost-transparent { opacity: 0.1; } + +.k2-hide-comment-buttons { + display: inline-flex; + gap: 4px; + margin-right: 8px; + align-items: center; + vertical-align: middle; + + .k2-hide-comment-button { + height: 22px; + padding: 0 6px; + font-size: 12px; + line-height: 20px; + } +} + +.k2-minimized-comment { + padding: 8px 12px; + border: 1px solid $color-neutral-light; + color: $color-neutral; + font-style: italic; +} + .k2-inactive { opacity: 0.2; } diff --git a/src/js/lib/api.js b/src/js/lib/api.js index 81c95f6c..15619136 100644 --- a/src/js/lib/api.js +++ b/src/js/lib/api.js @@ -40,7 +40,7 @@ function getCurrentUser() { */ function getRequestParams() { const url = window.location.href; - const regex = /github.com\/(?\w*)\/(?\w*)(?:\/(?:issues|pull)\/(?\d*))?/; + const regex = /github.com\/(?[\w-]+)\/(?[\w.-]+)(?:\/(?:issues|pull)\/(?\d+))?/; const matches = url.match(regex); return { @@ -609,6 +609,66 @@ function updateComment(commentId, body) { }); } +/** + * Look up an issue comment's GraphQL node ID by its numeric REST ID. + * @param {Number|String} commentID + * @returns {Promise} + */ +function getIssueCommentNodeID(commentID) { + const {owner, repo} = getRequestParams(); + return getOctokit().rest.issues.getComment({owner, repo, comment_id: Number(commentID)}) + .then(response => response.data.node_id); +} + +/** + * Look up a pull-request review's GraphQL node ID by its numeric REST ID. + * @param {Number|String} reviewID + * @returns {Promise} + */ +function getPullRequestReviewNodeID(reviewID) { + const {owner, repo, issue_number} = getRequestParams(); + return getOctokit().rest.pulls.getReview({ + owner, + repo, + pull_number: Number(issue_number), + review_id: Number(reviewID), + }).then(response => response.data.node_id); +} + +/** + * Look up an inline PR review-thread comment's GraphQL node ID by its numeric REST ID. + * @param {Number|String} commentID + * @returns {Promise} + */ +function getPullRequestReviewCommentNodeID(commentID) { + const {owner, repo} = getRequestParams(); + return getOctokit().rest.pulls.getReviewComment({ + owner, + repo, + comment_id: Number(commentID), + }).then(response => response.data.node_id); +} + +/** + * Minimize a comment with a GitHub reported-content classifier. + * @param {String} nodeID GraphQL node ID of the comment subject + * @param {String} classifier ReportedContentClassifiers enum value + * @returns {Promise} + */ +function minimizeComment(nodeID, classifier) { + const mutation = ` + mutation MinimizeComment($id: ID!, $classifier: ReportedContentClassifiers!) { + minimizeComment(input: {subjectId: $id, classifier: $classifier}) { + minimizedComment { + isMinimized + minimizedReason + } + } + } + `; + return getOctokit().graphql(mutation, {id: nodeID, classifier}); +} + /** * Get recent workflow runs for a specific workflow * @param {String} workflowId @@ -658,6 +718,10 @@ export { updateComment, getWorkflowRuns, getWorkflowRun, + getIssueCommentNodeID, + getPullRequestReviewNodeID, + getPullRequestReviewCommentNodeID, + minimizeComment, getStatusCheckRollup, getPullRequestHeadRefOid, }; diff --git a/src/js/lib/hideCommentButtons.js b/src/js/lib/hideCommentButtons.js new file mode 100644 index 00000000..1b0d5f84 --- /dev/null +++ b/src/js/lib/hideCommentButtons.js @@ -0,0 +1,258 @@ +import _ from 'underscore'; +import * as API from './api'; + +const ACTIONS = [ + {classifier: 'OFF_TOPIC', label: 'Off-topic'}, + {classifier: 'OUTDATED', label: 'Outdated'}, + {classifier: 'RESOLVED', label: 'Resolved'}, +]; + +const BUTTONS_CLASS = 'k2-hide-comment-buttons'; + +// Author profile links in comment headers use hovercards across GitHub UIs. +// GitHub App bots use an /apps/ URL instead. +const AUTHOR_SELECTOR = [ + '.timeline-comment-header a.author[data-hovercard-type="user"]', + '.timeline-comment-header a.author[data-hovercard-url*="/users/"]', + '.timeline-comment-header a.author[href*="/apps/"]', +].join(', '); +const COMMENT_BODY_SELECTOR = '.comment-body.js-comment-body'; + +// Match the comment type needed by the REST endpoint that returns its GraphQL node ID. +// Check review-thread comments before review comments because their URLs overlap. +const PERMALINK_TYPES = [ + {type: 'pullrequestreviewcomment', pattern: /pullrequestreviewcomment-(\d+)/}, + {type: 'pullrequestreviewcomment', pattern: /discussion_r(\d+)/}, + {type: 'pullrequestreview', pattern: /pullrequestreview-(\d+)/}, + {type: 'issuecomment', pattern: /issuecomment-(\d+)/}, +]; +const PERMALINK_SELECTOR = [ + 'a[href*="#issuecomment-"]', + 'a[href*="#pullrequestreview-"]', + 'a[href*="#pullrequestreviewcomment-"]', + 'a[href*="#discussion_r"]', +].join(', '); + +let observer = null; +let scanScheduled = false; + +function isOptionsButton(btn) { + const label = (btn.getAttribute('aria-label') || '').toLowerCase(); + if (label.includes('options') || label.includes('show menu') || label === 'more') { + return true; + } + + return !!btn.querySelector('.octicon-kebab-horizontal, [class*="KebabHorizontal"]'); +} + +// The React UI uses a button. The legacy review UI uses a summary element. +function findOptionsButton(rootEl) { + return _.find(rootEl.querySelectorAll('button, summary'), btn => isOptionsButton(btn)) || null; +} + +// Ignore user mentions inside comment bodies because they are not comment authors. +function isHeaderAuthorLink(link) { + const text = (link.textContent || '').trim(); + if (text.startsWith('@')) { + return false; + } + if (link.classList.contains('user-mention')) { + return false; + } + if (link.closest(COMMENT_BODY_SELECTOR)) { + return false; + } + if (link.closest('.timeline-comment[id^="pullrequest-"]')) { + return false; + } + return true; +} + +function parsePermalink(permalink) { + const href = permalink.getAttribute('href') || ''; + for (let i = 0; i < PERMALINK_TYPES.length; i++) { + const {type, pattern} = PERMALINK_TYPES[i]; + const match = href.match(pattern); + if (match) { + return {type, id: match[1]}; + } + } + return null; +} + +function getPermalinkHash(permalink) { + const href = permalink.getAttribute('href') || ''; + const hashIdx = href.indexOf('#'); + return hashIdx >= 0 ? href.slice(hashIdx + 1) : ''; +} + +// Find the smallest ancestor that contains the author, permalink, and comment body. +function findCommentHeader(authorLink) { + let el = authorLink.parentElement; + while (el && el !== document.body) { + const permalink = el.querySelector(PERMALINK_SELECTOR); + if (permalink) { + const parsed = parsePermalink(permalink); + if (parsed) { + const hash = getPermalinkHash(permalink); + const idMatches = hash && (el.id === hash || !!el.querySelector(`[id="${CSS.escape(hash)}"]`)); + const hasBody = !!el.querySelector(COMMENT_BODY_SELECTOR); + if (idMatches || hasBody) { + return { + container: el, permalink, parsed, optionsBtn: findOptionsButton(el), + }; + } + } + } + el = el.parentElement; + } + return null; +} + +function getMinimizeForm(wrapper) { + const comment = wrapper.closest('.timeline-comment-group'); + return comment && comment.querySelector('form.js-timeline-comment-minimize'); +} + +// Render the same state that GitHub uses when the native form is unavailable. +function showMinimizedComment(wrapper) { + const comment = wrapper.closest('.timeline-comment, .timeline-comment-group'); + const body = comment && comment.querySelector(COMMENT_BODY_SELECTOR); + if (!comment || !body) { + return; + } + + const header = comment.querySelector('.timeline-comment-header'); + const bodyContainer = body.closest('.edit-comment-hide') || body; + if (header) { + header.style.display = 'none'; + } + bodyContainer.style.display = 'none'; + comment.classList.remove('unminimized-comment'); + comment.classList.add('minimized-comment', 'position-relative'); + + const minimized = document.createElement('div'); + minimized.className = 'k2-minimized-comment'; + minimized.textContent = body.textContent.trim() || 'This comment has been minimized.'; + comment.appendChild(minimized); +} + +function lookupNodeID(commentType, commentID) { + if (commentType === 'pullrequestreview') { + return API.getPullRequestReviewNodeID(commentID); + } + if (commentType === 'pullrequestreviewcomment') { + return API.getPullRequestReviewCommentNodeID(commentID); + } + return API.getIssueCommentNodeID(commentID); +} + +function setButtonsDisabled(wrapper, disabled) { + _.each(wrapper.querySelectorAll('button'), (button) => { + if (disabled) { + button.setAttribute('disabled', 'disabled'); + } else { + button.removeAttribute('disabled'); + } + }); +} + +async function minimizeComment(event) { + const button = event.currentTarget; + const wrapper = button.closest(`.${BUTTONS_CLASS}`); + const commentID = wrapper && wrapper.dataset.commentId; + const commentType = wrapper && wrapper.dataset.commentType; + const classifier = button.dataset.classifier; + if (!commentID || !commentType || !classifier) { + return; + } + setButtonsDisabled(wrapper, true); + const form = getMinimizeForm(wrapper); + if (form) { + const select = form.querySelector('select[name="classifier"]'); + if (select) { + select.value = classifier; + form.requestSubmit(); + return; + } + } + try { + const nodeID = await lookupNodeID(commentType, commentID); + await API.minimizeComment(nodeID, classifier); + showMinimizedComment(wrapper); + } catch (error) { + setButtonsDisabled(wrapper, false); + wrapper.title = error instanceof Error ? error.message : 'Failed to hide comment'; + } +} + +function addButtons({ + container, permalink, parsed, optionsBtn, +}) { + if (container.querySelector(`.${BUTTONS_CLASS}`)) { + return; + } + + // A review can render more than one author link. Prevent duplicate button groups. + if (document.querySelector(`.${BUTTONS_CLASS}[data-comment-id="${CSS.escape(parsed.id)}"][data-comment-type="${CSS.escape(parsed.type)}"]`)) { + return; + } + + const wrapper = document.createElement('span'); + wrapper.className = `${BUTTONS_CLASS} k2-element`; + wrapper.dataset.commentId = parsed.id; + wrapper.dataset.commentType = parsed.type; + _.each(ACTIONS, (action) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm k2-hide-comment-button'; + btn.dataset.classifier = action.classifier; + btn.textContent = action.label; + btn.addEventListener('click', minimizeComment); + wrapper.appendChild(btn); + }); + + // Place buttons before the kebab when GitHub exposes the action menu. + if (optionsBtn) { + const anchor = optionsBtn; + const target = anchor.tagName === 'SUMMARY' ? (anchor.closest('details') || anchor) : anchor; + target.parentNode.insertBefore(wrapper, target); + } else { + permalink.parentNode.insertBefore(wrapper, permalink.nextSibling); + } +} + +function scan() { + _.each(document.querySelectorAll(AUTHOR_SELECTOR), (authorLink) => { + if (!isHeaderAuthorLink(authorLink)) { + return; + } + const header = findCommentHeader(authorLink); + if (!header) { + return; + } + addButtons(header); + }); +} + +function scheduleScan() { + if (scanScheduled) { + return; + } + scanScheduled = true; + requestAnimationFrame(() => { + scanScheduled = false; + scan(); + }); +} + +function initHideCommentButtons() { + if (observer) { + return; + } + scheduleScan(); + observer = new MutationObserver(scheduleScan); + observer.observe(document.body, {childList: true, subtree: true}); +} + +export default initHideCommentButtons; diff --git a/src/js/lib/pages/github/issue.js b/src/js/lib/pages/github/issue.js index 7076739f..8e308c8c 100644 --- a/src/js/lib/pages/github/issue.js +++ b/src/js/lib/pages/github/issue.js @@ -14,6 +14,7 @@ import K2previousissues from '../../../module/K2previousissues/K2previousissues' import ONYXKEYS from '../../../ONYXKEYS'; import * as API from '../../api'; import * as autoLoadMoreComments from '../../autoLoadMoreComments'; +import hideCommentButtons from '../../hideCommentButtons'; let clearErrorTimeoutID; function catchError(e) { @@ -231,6 +232,7 @@ export default function () { setInterval(() => IssuePage.renderPaymentDetailsButton(), 2000); autoLoadMoreComments.initAutoLoadMoreComments(); + hideCommentButtons(); }; return IssuePage; diff --git a/src/js/lib/pages/github/pr.js b/src/js/lib/pages/github/pr.js index 1cc7b031..fd03ecce 100644 --- a/src/js/lib/pages/github/pr.js +++ b/src/js/lib/pages/github/pr.js @@ -3,6 +3,7 @@ import Base from './_base'; import ToggleTimestamps from '../../../module/ToggleTimestamps/ToggleTimestamps'; import ToggleAutoLoadMore from '../../../module/ToggleAutoLoadMore/ToggleAutoLoadMore'; import * as autoLoadMoreComments from '../../autoLoadMoreComments'; +import hideCommentButtons from '../../hideCommentButtons'; import * as commitCheckStatuses from '../../commitCheckStatuses'; import * as prFavicon from '../../prFavicon'; @@ -146,10 +147,10 @@ export default function () { setInterval(() => PrPage.renderTranslationWorkflowButtons(), 2000); autoLoadMoreComments.initAutoLoadMoreComments(); + hideCommentButtons(); commitCheckStatuses.initCommitCheckStatuses(); prFavicon.initPrFavicon(); }; return PrPage; } -