Refined GitHub Comments

Remove clutter in the comments view

  1. // ==UserScript==
  2. // @name Refined GitHub Comments
  3. // @license MIT
  4. // @homepageURL https://github.com/bluwy/refined-github-comments
  5. // @supportURL https://github.com/bluwy/refined-github-comments
  6. // @namespace https://gf.qytechs.cn/en/scripts/465056-refined-github-comments
  7. // @version 0.2.2
  8. // @description Remove clutter in the comments view
  9. // @author Bjorn Lu
  10. // @match https://github.com/**
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
  12. // @grant none
  13. // ==/UserScript==
  14.  
  15. // #region User settings
  16.  
  17. // common bots that i already know what they do
  18. const authorsToMinimize = [
  19. 'changeset-bot',
  20. 'codeflowapp',
  21. 'netlify',
  22. 'vercel',
  23. 'pkg-pr-new',
  24. 'codecov',
  25. 'astrobot-houston',
  26. 'codspeed-hq',
  27. ]
  28.  
  29. // common comments that don't really add value
  30. const commentMatchToMinimize = [
  31. /^![a-z]/, // commands that start with !
  32. /^\/[a-z]/, // commands that start with /
  33. /^> root@0.0.0/, // astro preview release bot
  34. ]
  35.  
  36. // #endregion
  37.  
  38. // #region Run code
  39.  
  40. // Used by `minimizeDiscussionThread`
  41. let expandedThread = false
  42. const maxParentThreadHeight = 185
  43.  
  44. ;(function () {
  45. 'use strict'
  46.  
  47. run()
  48.  
  49. // listen to github page loaded event
  50. document.addEventListener('pjax:end', () => run())
  51. document.addEventListener('turbo:render', () => run())
  52. })()
  53.  
  54. function run() {
  55. // Comments view
  56. const allTimelineItem = document.querySelectorAll('.js-timeline-item')
  57. const seenComments = []
  58.  
  59. allTimelineItem.forEach((timelineItem) => {
  60. minimizeComment(timelineItem)
  61. minimizeBlockquote(timelineItem, seenComments)
  62. })
  63.  
  64. // Discussion threads view
  65. if (location.pathname.includes('/discussions/')) {
  66. minimizeDiscussionThread()
  67. }
  68. }
  69.  
  70. // #endregion
  71.  
  72. // #region Features: minimize comment
  73.  
  74. // test urls:
  75. // https://github.com/withastro/astro/pull/6845
  76. /**
  77. * @param {HTMLElement} timelineItem
  78. */
  79. function minimizeComment(timelineItem) {
  80. // things can happen twice in github for some reason
  81. if (timelineItem.querySelector('.refined-github-comments-toggle')) return
  82.  
  83. const header = timelineItem.querySelector('.timeline-comment-header')
  84. if (!header) return
  85.  
  86. const headerName = header.querySelector('a.author')
  87. if (!headerName) return
  88.  
  89. const commentBody = timelineItem.querySelector('.comment-body')
  90. if (!commentBody) return
  91.  
  92. const commentBodyText = commentBody.innerText.trim()
  93.  
  94. // minimize the comment
  95. if (
  96. authorsToMinimize.includes(headerName.innerText) ||
  97. commentMatchToMinimize.some((match) => match.test(commentBodyText))
  98. ) {
  99. const commentContent = timelineItem.querySelector('.edit-comment-hide')
  100. if (!commentContent) return
  101. const commentActions = timelineItem.querySelector(
  102. '.timeline-comment-actions'
  103. )
  104. if (!commentActions) return
  105. const headerH3 = header.querySelector('h3')
  106. if (!headerH3) return
  107. const headerDiv = headerH3.querySelector('div')
  108. if (!headerDiv) return
  109.  
  110. // hide comment
  111. header.style.borderBottom = 'none'
  112. commentContent.style.display = 'none'
  113.  
  114. // add comment excerpt
  115. const excerpt = document.createElement('span')
  116. excerpt.setAttribute(
  117. 'class',
  118. 'text-fg-muted text-normal text-italic css-truncate css-truncate-overflow mr-2'
  119. )
  120. excerpt.innerHTML = commentBodyText.slice(0, 100)
  121. excerpt.style.opacity = '0.5'
  122. headerH3.classList.add('css-truncate', 'css-truncate-overflow')
  123. headerDiv.appendChild(excerpt)
  124.  
  125. // add toggle button
  126. const toggleBtn = toggleComment((isShow) => {
  127. // headerH3 class needs to be toggled too so that the "edited dropdown" can be toggled
  128. if (isShow) {
  129. headerH3.classList.remove('css-truncate', 'css-truncate-overflow')
  130. header.style.borderBottom = ''
  131. commentContent.style.display = ''
  132. excerpt.style.display = 'none'
  133. } else {
  134. headerH3.classList.add('css-truncate', 'css-truncate-overflow')
  135. header.style.borderBottom = 'none'
  136. commentContent.style.display = 'none'
  137. excerpt.style.display = ''
  138. }
  139. })
  140. commentActions.prepend(toggleBtn)
  141. }
  142. }
  143.  
  144. // #endregion
  145.  
  146. // #region Features: minimize blockquote
  147.  
  148. // test urls:
  149. // https://github.com/bluwy/refined-github-comments/issues/1
  150. // https://github.com/sveltejs/svelte/issues/2323
  151. // https://github.com/pnpm/pnpm/issues/6463
  152. /**
  153. * @param {HTMLElement} timelineItem
  154. * @param {{ text: string, id: string, author: string }[]} seenComments
  155. */
  156. function minimizeBlockquote(timelineItem, seenComments) {
  157. const commentBody = timelineItem.querySelector('.comment-body')
  158. if (!commentBody) return
  159.  
  160. const commentId = timelineItem.querySelector('.timeline-comment-group')?.id
  161. if (!commentId) return
  162.  
  163. const commentAuthor = timelineItem.querySelector(
  164. '.timeline-comment-header a.author'
  165. )?.innerText
  166. if (!commentAuthor) return
  167.  
  168. const commentText = commentBody.innerText.trim().replace(/\s+/g, ' ')
  169.  
  170. // bail early in first comment and if comment is already checked before
  171. if (
  172. seenComments.length === 0 ||
  173. commentBody.querySelector('.refined-github-comments-reply-text')
  174. ) {
  175. seenComments.push({
  176. text: commentText,
  177. id: commentId,
  178. author: commentAuthor,
  179. })
  180. return
  181. }
  182.  
  183. const blockquotes = commentBody.querySelectorAll(':scope > blockquote')
  184. for (const blockquote of blockquotes) {
  185. const blockquoteText = blockquote.innerText.trim().replace(/\s+/g, ' ')
  186.  
  187. const dupIndex = seenComments.findIndex(
  188. (comment) => comment.text === blockquoteText
  189. )
  190. if (dupIndex >= 0) {
  191. const dup = seenComments[dupIndex]
  192. // if replying to the one above, always minimize it
  193. if (dupIndex === seenComments.length - 1) {
  194. // use span.js-clear so github would remove this summary when re-quoting this reply,
  195. // add nbsp so that the summary tag has some content, that the details would also
  196. // get copied when re-quoting too.
  197. const summary = `\
  198. <span class="js-clear text-italic refined-github-comments-reply-text">
  199. Replying to <strong>@${dup.author}</strong> above
  200. </span>&nbsp;`
  201. blockquote.innerHTML = `<details><summary>${summary}</summary>${blockquote.innerHTML}</details>`
  202. }
  203. // if replying to a long comment, or a comment with code, always minimize it
  204. else if (blockquoteText.length > 200 || blockquote.querySelector('pre')) {
  205. // use span.js-clear so github would remove this summary when re-quoting this reply,
  206. // add nbsp so that the summary tag has some content, that the details would also
  207. // get copied when re-quoting too.
  208. const summary = `\
  209. <span class="js-clear text-italic refined-github-comments-reply-text">
  210. Replying to <strong>@${dup.author}</strong>'s <a href="#${dup.id}">comment</a>
  211. </span>&nbsp;`
  212. blockquote.innerHTML = `<details><summary>${summary}</summary>${blockquote.innerHTML}</details>`
  213. }
  214. // otherwise, just add a hint so we don't have to navigate away a short sentence
  215. else {
  216. // use span.js-clear so github would remove this hint when re-quoting this reply
  217. const hint = `\
  218. <span dir="auto" class="js-clear text-italic refined-github-comments-reply-text" style="display: block; margin-top: -0.5rem; opacity: 0.7; font-size: 90%;">
  219. <strong>@${dup.author}</strong> said in <a href="#${dup.id}">comment</a>
  220. </span>`
  221. blockquote.insertAdjacentHTML('beforeend', hint)
  222. }
  223. continue
  224. }
  225.  
  226. const partialDupIndex = seenComments.findIndex((comment) =>
  227. comment.text.includes(blockquoteText)
  228. )
  229. if (partialDupIndex >= 0) {
  230. const dup = seenComments[partialDupIndex]
  231. // get first four words and last four words, craft a text fragment to highlight
  232. const splitted = blockquoteText.split(' ')
  233. const textFragment =
  234. splitted.length < 9
  235. ? `#:~:text=${encodeURIComponent(blockquoteText)}`
  236. : `#:~:text=${encodeURIComponent(
  237. splitted.slice(0, 4).join(' ')
  238. )},${encodeURIComponent(splitted.slice(-4).join(' '))}`
  239.  
  240. // if replying to the one above, prepend hint
  241. if (partialDupIndex === seenComments.length - 1) {
  242. // use span.js-clear so github would remove this hint when re-quoting this reply
  243. const hint = `\
  244. <span dir="auto" class="js-clear text-italic refined-github-comments-reply-text" style="display: block; margin-top: -0.5rem; opacity: 0.7; font-size: 90%;">
  245. <strong>@${dup.author}</strong> <a href="${textFragment}">said</a> above
  246. </span>`
  247. blockquote.insertAdjacentHTML('beforeend', hint)
  248. }
  249. // prepend generic hint
  250. else {
  251. // use span.js-clear so github would remove this hint when re-quoting this reply
  252. const hint = `\
  253. <span dir="auto" class="js-clear text-italic refined-github-comments-reply-text" style="display: block; margin-top: -0.5rem; opacity: 0.7; font-size: 90%;">
  254. <strong>@${dup.author}</strong> <a href="${textFragment}">said</a> in <a href="#${dup.id}">comment</a>
  255. </span>`
  256. blockquote.insertAdjacentHTML('beforeend', hint)
  257. }
  258. }
  259. }
  260.  
  261. seenComments.push({ text: commentText, id: commentId, author: commentAuthor })
  262. }
  263.  
  264. // #endregion
  265.  
  266. // #region Features: minimize discussion threads
  267.  
  268. // test urls:
  269. // https://github.com/vitejs/vite/discussions/18191
  270. function minimizeDiscussionThread() {
  271. if (expandedThread) {
  272. _minimizeDiscussionThread()
  273. return
  274. }
  275.  
  276. const discussionContainer = document.querySelector(
  277. '.discussion.js-discussion > .js-timeline-marker'
  278. )
  279. if (!discussionContainer) return
  280.  
  281. const tripleDotMenuContainer = document.querySelector(
  282. '.timeline-comment-actions'
  283. )
  284. if (!tripleDotMenuContainer) return
  285.  
  286. // Skip if already added
  287. if (document.getElementById('refined-github-comments-expand-btn') != null)
  288. return
  289.  
  290. tripleDotMenuContainer.style.display = 'flex'
  291. tripleDotMenuContainer.style.alignItems = 'center'
  292.  
  293. // Create a "Collapse threads" button to enable this feature
  294. const expandBtn = document.createElement('button')
  295. expandBtn.id = 'refined-github-comments-expand-btn'
  296. expandBtn.setAttribute(
  297. 'class',
  298. 'Button--iconOnly Button--invisible Button--medium Button mr-2'
  299. )
  300. expandBtn.innerHTML = `\
  301. <svg class="Button-visual octicon octicon-zap" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
  302. <path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004L9.503.429Zm1.047 1.074L3.286 8.571A.25.25 0 0 0 3.462 9H6.75a.75.75 0 0 1 .694 1.034l-1.713 4.188 6.982-6.793A.25.25 0 0 0 12.538 7H9.25a.75.75 0 0 1-.683-1.06l2.008-4.418.003-.006a.036.036 0 0 0-.004-.009l-.006-.006-.008-.001c-.003 0-.006.002-.009.004Z"></path>
  303. </svg>
  304. `
  305. expandBtn.title = 'Collapse threads'
  306. expandBtn.addEventListener('click', () => {
  307. expandedThread = true
  308. _minimizeDiscussionThread()
  309. expandBtn.remove()
  310. })
  311. tripleDotMenuContainer.prepend(expandBtn)
  312. }
  313.  
  314. function _minimizeDiscussionThread() {
  315. const timelineComments = document.querySelectorAll(
  316. '.timeline-comment.comment:not(.nested-discussion-timeline-comment)'
  317. )
  318. for (const timelineComment of timelineComments) {
  319. // Skip if already handled
  320. if (timelineComment.querySelector('.refined-github-comments-toggle'))
  321. continue
  322.  
  323. const parentThreadContent = timelineComment.children[1]
  324. if (!parentThreadContent) continue
  325.  
  326. // Find the "N replies" bottom text (a bit finicky but seems like the best selector)
  327. const bottomText = parentThreadContent.querySelector(
  328. 'span.color-fg-muted.no-wrap'
  329. )
  330. const childrenThread = timelineComment.querySelector(
  331. '[data-child-comments]'
  332. )
  333. // Skip if 0 replies
  334. if (
  335. bottomText &&
  336. childrenThread &&
  337. /\d+/.exec(bottomText.textContent)?.[0] !== '0'
  338. ) {
  339. // Prepend a "expand thread" button
  340. // const expandBtn = document.createElement('button')
  341. // expandBtn.setAttribute('class', 'Button--secondary Button--small Button')
  342. // expandBtn.innerHTML = 'Expand thread'
  343. // expandBtn.addEventListener('click', () => {
  344. // threadComment.style.display = ''
  345. // bottomText.style.display = 'none'
  346. // })
  347. const toggleBtn = toggleComment((isShow) => {
  348. // Re-query as GitHub may update it when , e.g. showing more comments
  349. const childrenThreadAgain = timelineComment.querySelector(
  350. '[data-child-comments]'
  351. )
  352. if (childrenThreadAgain) {
  353. if (isShow) {
  354. childrenThreadAgain.style.display = ''
  355. bottomText.classList.add('color-fg-muted')
  356. } else {
  357. childrenThreadAgain.style.display = 'none'
  358. bottomText.classList.remove('color-fg-muted')
  359. }
  360. }
  361. })
  362. bottomText.parentElement.insertBefore(toggleBtn, bottomText)
  363. childrenThread.style.display = 'none'
  364. bottomText.classList.remove('color-fg-muted')
  365. // Lazy to make the bottom text a button, share the click event to the button for now
  366. // NOTE: This click happens to expand the comment too. I'm not sure how to prevent that.
  367. bottomText.addEventListener('click', () => {
  368. toggleBtn.click()
  369. })
  370. }
  371.  
  372. const commentBody = parentThreadContent.querySelector('.comment-body')
  373. if (commentBody && commentBody.clientHeight > maxParentThreadHeight) {
  374. // Shrink the OP thread to max height
  375. const css = `max-height:${maxParentThreadHeight}px;mask-image:linear-gradient(180deg, #000 80%, transparent);-webkit-mask-image:linear-gradient(180deg, #000 80%, transparent);`
  376. commentBody.style.cssText += css
  377. // Add "view"
  378. const commentActions = timelineComment.querySelector(
  379. '.timeline-comment-actions'
  380. )
  381. const toggleCommentBodyBtn = toggleComment((isShow) => {
  382. if (isShow) {
  383. commentBody.style.maxHeight = ''
  384. commentBody.style.maskImage = ''
  385. commentBody.style.webkitMaskImage = ''
  386. } else {
  387. commentBody.style.cssText += css
  388. }
  389. })
  390. commentActions.style.display = 'flex'
  391. commentActions.style.alignItems = 'center'
  392. commentActions.prepend(toggleCommentBodyBtn)
  393. // Auto-expand on first click for nicer UX
  394. commentBody.addEventListener('click', () => {
  395. if (toggleCommentBodyBtn.dataset.show === 'false') {
  396. toggleCommentBodyBtn.click()
  397. }
  398. })
  399. }
  400. }
  401. }
  402.  
  403. // #endregion
  404.  
  405. // #region Utilities
  406.  
  407. // create the toggle comment like github does when you hide a comment
  408. function toggleComment(onClick) {
  409. const btn = document.createElement('button')
  410. // copied from github hidden comment style
  411. btn.innerHTML = `
  412. <div class="color-fg-muted f6 no-wrap">
  413. <svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-unfold position-relative">
  414. <path d="m8.177.677 2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25a.75.75 0 0 1-1.5 0V4H5.104a.25.25 0 0 1-.177-.427L7.823.677a.25.25 0 0 1 .354 0ZM7.25 10.75a.75.75 0 0 1 1.5 0V12h2.146a.25.25 0 0 1 .177.427l-2.896 2.896a.25.25 0 0 1-.354 0l-2.896-2.896A.25.25 0 0 1 5.104 12H7.25v-1.25Zm-5-2a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z"></path>
  415. </svg>
  416. </div>
  417. <div class="color-fg-muted f6 no-wrap" style="display: none">
  418. <svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-fold position-relative">
  419. <path d="M10.896 2H8.75V.75a.75.75 0 0 0-1.5 0V2H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0l2.896-2.896A.25.25 0 0 0 10.896 2ZM8.75 15.25a.75.75 0 0 1-1.5 0V14H5.104a.25.25 0 0 1-.177-.427l2.896-2.896a.25.25 0 0 1 .354 0l2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25Zm-6.5-6.5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z"></path>
  420. </svg>
  421. </div>
  422. `
  423. const showNode = btn.querySelector('div:nth-child(1)')
  424. const hideNode = btn.querySelector('div:nth-child(2)')
  425. let isShow = false
  426. btn.setAttribute('type', 'button')
  427. btn.setAttribute(
  428. 'class',
  429. 'refined-github-comments-toggle timeline-comment-action btn-link'
  430. )
  431. btn.dataset.show = isShow
  432. btn.addEventListener('click', () => {
  433. isShow = !isShow
  434. btn.dataset.show = isShow
  435. if (isShow) {
  436. showNode.style.display = 'none'
  437. hideNode.style.display = ''
  438. } else {
  439. showNode.style.display = ''
  440. hideNode.style.display = 'none'
  441. }
  442. onClick(isShow)
  443. })
  444. return btn
  445. }
  446.  
  447. // #endregion

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址