Refined GitHub Notifications

Enhances the GitHub Notifications page, making it more productive and less noisy.

当前为 2023-04-06 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Refined GitHub Notifications
  3. // @namespace https://greasyfork.org/en/scripts/461320-refined-github-notifications
  4. // @version 0.3.1
  5. // @description Enhances the GitHub Notifications page, making it more productive and less noisy.
  6. // @author Anthony Fu (https://github.com/antfu)
  7. // @license MIT
  8. // @homepageURL https://github.com/antfu/refined-github-notifications
  9. // @supportURL https://github.com/antfu/refined-github-notifications
  10. // @match https://github.com/**
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
  12. // @grant window.close
  13. // ==/UserScript==
  14.  
  15. /* eslint-disable no-console */
  16.  
  17. (function () {
  18. 'use strict'
  19.  
  20. // Fix the archive link
  21. if (location.pathname === '/notifications/beta/archive')
  22. location.pathname = '/notifications'
  23.  
  24. // list of functions to be cleared on page change
  25. const cleanups = []
  26.  
  27. const NAME = 'Refined GitHub Notifications'
  28. const STORAGE_KEY = 'refined-github-notifications'
  29.  
  30. const config = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
  31.  
  32. let bc
  33. let bcInitTime = 0
  34.  
  35. function writeConfig() {
  36. localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
  37. }
  38.  
  39. function injectStyle() {
  40. const style = document.createElement('style')
  41. style.innerHTML = `
  42. /* Hide blue dot on notification icon */
  43. .mail-status.unread {
  44. display: none !important;
  45. }
  46. /* Hide blue dot on notification with the new navigration */
  47. .AppHeader .AppHeader-button.AppHeader-button--hasIndicator::before {
  48. display: none !important;
  49. }
  50. /* Hide the notification shelf and add a FAB */
  51. .js-notification-shelf {
  52. display: none !important;
  53. }
  54. .btn-hover-primary {
  55. transform: scale(1.2);
  56. transition: all .3s ease-in-out;
  57. }
  58. .btn-hover-primary:hover {
  59. color: var(--color-btn-primary-text);
  60. background-color: var(--color-btn-primary-bg);
  61. border-color: var(--color-btn-primary-border);
  62. box-shadow: var(--color-btn-primary-shadow),var(--color-btn-primary-inset-shadow);
  63. }
  64. `
  65. document.head.appendChild(style)
  66. }
  67.  
  68. /**
  69. * To have a FAB button to close current issue,
  70. * where you can mark done and then close the tab automatically
  71. */
  72. function notificationShelf() {
  73. function inject() {
  74. const shelf = document.querySelector('.js-notification-shelf')
  75. if (!shelf)
  76. return false
  77.  
  78. const doneButton = shelf.querySelector('button[title="Done"]')
  79. if (!doneButton)
  80. return false
  81.  
  82. const clickAndClose = async () => {
  83. doneButton.click()
  84. // wait for the notification shelf to be updated
  85. await Promise.race([
  86. new Promise((resolve) => {
  87. const ob = new MutationObserver((r) => {
  88. resolve()
  89. ob.disconnect()
  90. })
  91. .observe(
  92. shelf,
  93. {
  94. childList: true,
  95. subtree: true,
  96. attributes: true,
  97. },
  98. )
  99. }),
  100. new Promise(resolve => setTimeout(resolve, 1000)),
  101. ])
  102. // close the tab
  103. window.close()
  104. }
  105. const keyDownHandle = (e) => {
  106. if ((e.metaKey || e.ctrlKey) && e.key === 'x') {
  107. e.preventDefault()
  108. clickAndClose()
  109. }
  110. }
  111.  
  112. const fab = doneButton.cloneNode(true)
  113. fab.classList.remove('btn-sm')
  114. fab.classList.add('btn-hover-primary')
  115. fab.addEventListener('click', clickAndClose)
  116. Object.assign(fab.style, {
  117. position: 'fixed',
  118. right: '25px',
  119. bottom: '25px',
  120. zIndex: 999,
  121. aspectRatio: '1/1',
  122. borderRadius: '50%',
  123. })
  124.  
  125. const commentActions = document.querySelector('#partial-new-comment-form-actions')
  126. if (commentActions) {
  127. const key = 'markDoneAfterComment'
  128. const label = document.createElement('label')
  129. const input = document.createElement('input')
  130. label.classList.add('color-fg-muted')
  131. input.type = 'checkbox'
  132. input.checked = !!config[key]
  133. input.addEventListener('change', (e) => {
  134. config[key] = !!e.target.checked
  135. writeConfig()
  136. })
  137. label.appendChild(input)
  138. label.appendChild(document.createTextNode(' Mark done and close after comment'))
  139. Object.assign(label.style, {
  140. display: 'flex',
  141. alignItems: 'center',
  142. justifyContent: 'end',
  143. gap: '5px',
  144. userSelect: 'none',
  145. fontWeight: '400',
  146. })
  147. const div = document.createElement('div')
  148. Object.assign(div.style, {
  149. paddingBottom: '5px',
  150. })
  151. div.appendChild(label)
  152. commentActions.parentElement.prepend(div)
  153.  
  154. const commentButton = commentActions.querySelector('button.btn-primary[type="submit"]')
  155. const closeButton = commentActions.querySelector('[name="comment_and_close"]')
  156. const buttons = [commentButton, closeButton].filter(Boolean)
  157.  
  158. for (const button of buttons) {
  159. button.addEventListener('click', async (e) => {
  160. if (config[key]) {
  161. await new Promise(resolve => setTimeout(resolve, 1000))
  162. clickAndClose()
  163. }
  164. })
  165. }
  166. }
  167.  
  168. const mergeMessage = document.querySelector('.merge-message')
  169. if (mergeMessage) {
  170. const key = 'markDoneAfterMerge'
  171. const label = document.createElement('label')
  172. const input = document.createElement('input')
  173. label.classList.add('color-fg-muted')
  174. input.type = 'checkbox'
  175. input.checked = !!config[key]
  176. input.addEventListener('change', (e) => {
  177. config[key] = !!e.target.checked
  178. writeConfig()
  179. })
  180. label.appendChild(input)
  181. label.appendChild(document.createTextNode(' Mark done and close after merge'))
  182. Object.assign(label.style, {
  183. display: 'flex',
  184. alignItems: 'center',
  185. justifyContent: 'end',
  186. gap: '5px',
  187. userSelect: 'none',
  188. fontWeight: '400',
  189. })
  190. mergeMessage.prepend(label)
  191.  
  192. const buttons = mergeMessage.querySelectorAll('.js-auto-merge-box button')
  193. for (const button of buttons) {
  194. button.addEventListener('click', async (e) => {
  195. if (config[key]) {
  196. await new Promise(resolve => setTimeout(resolve, 1000))
  197. clickAndClose()
  198. }
  199. })
  200. }
  201. }
  202.  
  203. document.body.appendChild(fab)
  204. document.addEventListener('keydown', keyDownHandle)
  205. cleanups.push(() => {
  206. document.body.removeChild(fab)
  207. document.removeEventListener('keydown', keyDownHandle)
  208. })
  209.  
  210. return true
  211. }
  212.  
  213. // when first into the page, the notification shelf might not be loaded, we need to wait for it to show
  214. if (!inject()) {
  215. const observer = new MutationObserver((mutationList) => {
  216. const found = mutationList.some(i => i.type === 'childList' && Array.from(i.addedNodes).some(el => el.classList.contains('js-notification-shelf')))
  217. if (found) {
  218. inject()
  219. observer.disconnect()
  220. }
  221. })
  222. observer.observe(document.querySelector('[data-turbo-body]'), { childList: true })
  223. cleanups.push(() => {
  224. observer.disconnect()
  225. })
  226. }
  227. }
  228.  
  229. function initBroadcastChannel() {
  230. bcInitTime = Date.now()
  231. bc = new BroadcastChannel('refined-github-notifications')
  232.  
  233. bc.onmessage = ({ data }) => {
  234. if (isInNotificationPage()) {
  235. console.log(`[${NAME}]`, 'Received message', data)
  236. if (data.type === 'check-dedupe') {
  237. // If the new tab is opened after the current tab, close the current tab
  238. if (data.time > bcInitTime) {
  239. window.close()
  240. location.href = 'https://close-me.netlify.app'
  241. }
  242. }
  243. }
  244. }
  245. }
  246.  
  247. function dedupeTab() {
  248. if (!bc)
  249. return
  250. bc.postMessage({ type: 'check-dedupe', time: bcInitTime, url: location.href })
  251. }
  252.  
  253. function externalize() {
  254. document.querySelectorAll('a')
  255. .forEach((r) => {
  256. if (r.href.startsWith('https://github.com/notifications'))
  257. return
  258. // try to use the same tab
  259. r.target = r.href.replace('https://github.com', '').replace(/[\\/?#-]/g, '_')
  260. })
  261. }
  262.  
  263. function initIdleListener() {
  264. // Auto refresh page on going back to the page
  265. document.addEventListener('visibilitychange', () => {
  266. if (document.visibilityState === 'visible')
  267. refresh()
  268. })
  269. }
  270.  
  271. function getIssues() {
  272. return [...document.querySelectorAll('.notifications-list-item')]
  273. .map((el) => {
  274. const url = el.querySelector('a.notification-list-item-link').href
  275. const status = el.querySelector('.color-fg-open')
  276. ? 'open'
  277. : el.querySelector('.color-fg-done')
  278. ? 'done'
  279. : el.querySelector('.color-fg-closed')
  280. ? 'closed'
  281. : el.querySelector('.color-fg-muted')
  282. ? 'muted'
  283. : 'unknown'
  284.  
  285. const notificationTypeEl = el.querySelector('.AvatarStack').nextElementSibling
  286. const notificationType = notificationTypeEl.textContent.trim()
  287.  
  288. // Colorize notification type
  289. if (notificationType === 'mention')
  290. notificationTypeEl.classList.add('color-fg-open')
  291. else if (notificationType === 'subscribed')
  292. notificationTypeEl.classList.add('color-fg-muted')
  293. else if (notificationType === 'review requested')
  294. notificationTypeEl.classList.add('color-fg-done')
  295.  
  296. const item = {
  297. title: el.querySelector('.markdown-title').textContent.trim(),
  298. el,
  299. url,
  300. read: el.classList.contains('notification-read'),
  301. starred: el.classList.contains('notification-starred'),
  302. type: notificationType,
  303. status,
  304. isClosed: ['closed', 'done', 'muted'].includes(status),
  305. markDone: () => {
  306. console.log(`[${NAME}]`, 'Mark notifications done', item)
  307. el.querySelector('button[type=submit] .octicon-check').parentElement.parentElement.click()
  308. },
  309. }
  310.  
  311. return item
  312. })
  313. }
  314.  
  315. function getReasonMarkedDone(item) {
  316. if (item.isClosed && (item.read || item.type === 'subscribed'))
  317. return 'Closed / merged'
  318.  
  319. if (item.title.startsWith('chore(deps): update ') && (item.read || item.type === 'subscribed'))
  320. return 'Renovate bot'
  321.  
  322. if (item.url.match('/pull/[0-9]+/files/'))
  323. return 'New commit pushed to PR'
  324.  
  325. if (item.type === 'ci activity' && /workflow run cancell?ed/.test(item.title))
  326. return 'GH PR Audit Action workflow run cancelled, probably due to another run taking precedence'
  327. }
  328.  
  329. function isInboxView() {
  330. const query = new URLSearchParams(window.location.search).get('query')
  331. if (!query)
  332. return true
  333.  
  334. const conditions = query.split(' ')
  335. return ['is:done', 'is:saved'].every(condition => !conditions.includes(condition))
  336. }
  337.  
  338. function autoMarkDone() {
  339. // Only mark on "Inbox" view
  340. if (!isInboxView())
  341. return
  342.  
  343. const items = getIssues()
  344.  
  345. console.log(`[${NAME}] ${items}`)
  346. let count = 0
  347.  
  348. const done = []
  349.  
  350. items.forEach((i) => {
  351. // skip bookmarked notifications
  352. if (i.starred)
  353. return
  354.  
  355. const reason = getReasonMarkedDone(i)
  356. if (!reason)
  357. return
  358.  
  359. count++
  360. i.markDone()
  361. done.push({
  362. title: i.title,
  363. reason,
  364. link: i.link,
  365. })
  366. })
  367.  
  368. if (done.length) {
  369. console.log(`[${NAME}]`, `${count} notifications marked done`)
  370. console.table(done)
  371. }
  372.  
  373. // Refresh page after marking done (expand the pagination)
  374. if (count >= 5)
  375. setTimeout(() => refresh(), 200)
  376. }
  377.  
  378. function removeBotAvatars() {
  379. document.querySelectorAll('.AvatarStack-body > a')
  380. .forEach((r) => {
  381. if (r.href.startsWith('/apps/') || r.href.startsWith('https://github.com/apps/'))
  382. r.remove()
  383. })
  384. }
  385.  
  386. /**
  387. * The "x new notifications" badge
  388. */
  389. function hasNewNotifications() {
  390. return !!document.querySelector('.js-updatable-content a[href="/notifications?query="]')
  391. }
  392.  
  393. function cleanup() {
  394. cleanups.forEach(fn => fn())
  395. cleanups.length = 0
  396. }
  397.  
  398. // Click the notification tab to do soft refresh
  399. function refresh() {
  400. if (!isInNotificationPage())
  401. return
  402. document.querySelector('.filter-list a[href="/notifications"]').click()
  403. }
  404.  
  405. function isInNotificationPage() {
  406. return location.href.startsWith('https://github.com/notifications')
  407. }
  408.  
  409. function observeForNewNotifications() {
  410. try {
  411. const observer = new MutationObserver(() => {
  412. if (hasNewNotifications())
  413. refresh()
  414. })
  415. observer.observe(document.querySelector('.js-check-all-container').children[0], {
  416. childList: true,
  417. subtree: true,
  418. })
  419. }
  420. catch (e) {
  421. }
  422. }
  423.  
  424. ////////////////////////////////////////
  425.  
  426. let initialized = false
  427.  
  428. function run() {
  429. cleanup()
  430. if (isInNotificationPage()) {
  431. // Run only once
  432. if (!initialized) {
  433. initIdleListener()
  434. initBroadcastChannel()
  435. observeForNewNotifications()
  436. initialized = true
  437. }
  438.  
  439. // Run every render
  440. dedupeTab()
  441. externalize()
  442. removeBotAvatars()
  443. autoMarkDone()
  444. }
  445. else {
  446. notificationShelf()
  447. }
  448. }
  449.  
  450. injectStyle()
  451. run()
  452.  
  453. // listen to github page loaded event
  454. document.addEventListener('pjax:end', () => run())
  455. document.addEventListener('turbo:render', () => run())
  456. })()