Refined GitHub Notifications

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

当前为 2023-03-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Refined GitHub Notifications
  3. // @namespace https://greasyfork.org/en/scripts/461320-refined-github-notifications
  4. // @version 0.1.4
  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 none
  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. const TIMEOUT = 60_000
  25. const NAME = 'Refined GitHub Notifications'
  26. let lastUpdate = Date.now()
  27.  
  28. let bc
  29. let bcInitTime = 0
  30.  
  31. function injectStyle() {
  32. const style = document.createElement('style')
  33. style.innerHTML = `
  34. /* Hide blue dot on notification icon */
  35. .mail-status.unread {
  36. display: none !important;
  37. }
  38. `
  39. document.head.appendChild(style)
  40. }
  41.  
  42. function initBroadcastChannel() {
  43. bcInitTime = Date.now()
  44. bc = new BroadcastChannel('refined-github-notifications')
  45.  
  46. bc.onmessage = ({ data }) => {
  47. console.log(`[${NAME}]`, 'Received message', data)
  48. if (data.type === 'check-dedupe') {
  49. // If the new tab is opened after the current tab, close the current tab
  50. if (data.time > bcInitTime) {
  51. // TODO: close the tab
  52. try {
  53. window.close()
  54. }
  55. catch (e) {}
  56. location.href = 'about:blank'
  57. }
  58. }
  59. }
  60. }
  61.  
  62. function dedupeTab() {
  63. if (!bc)
  64. return
  65. bc.postMessage({ type: 'check-dedupe', time: bcInitTime, url: location.href })
  66. }
  67.  
  68. function externalize() {
  69. document.querySelectorAll('a')
  70. .forEach((r) => {
  71. if (r.href.startsWith('https://github.com/notifications'))
  72. return
  73. r.target = '_blank'
  74. r.rel = 'noopener noreferrer'
  75. const url = new URL(r.href)
  76.  
  77. // Remove notification_referrer_id
  78. if (url.searchParams.get('notification_referrer_id')) {
  79. url.searchParams.delete('notification_referrer_id')
  80. r.href = url.toString()
  81. }
  82. })
  83. }
  84.  
  85. function initIdleListener() {
  86. // Auto refresh page on idle
  87. document.addEventListener('focus', () => {
  88. if (Date.now() - lastUpdate > TIMEOUT)
  89. setTimeout(() => refresh(), 100)
  90. lastUpdate = Date.now()
  91. })
  92. }
  93.  
  94. function getIssues() {
  95. return [...document.querySelectorAll('.notifications-list-item')]
  96. .map((el) => {
  97. const url = el.querySelector('a.notification-list-item-link').href
  98. const status = el.querySelector('.color-fg-open')
  99. ? 'open'
  100. : el.querySelector('.color-fg-done')
  101. ? 'done'
  102. : el.querySelector('.color-fg-closed')
  103. ? 'closed'
  104. : el.querySelector('.color-fg-muted')
  105. ? 'muted'
  106. : 'unknown'
  107.  
  108. const notificationTypeEl = el.querySelector('.AvatarStack').nextElementSibling
  109. const notificationType = notificationTypeEl.textContent.trim()
  110.  
  111. // Colorize notification type
  112. if (notificationType === 'mention')
  113. notificationTypeEl.classList.add('color-fg-open')
  114. else if (notificationType === 'subscribed')
  115. notificationTypeEl.classList.add('color-fg-muted')
  116. else if (notificationType === 'review requested')
  117. notificationTypeEl.classList.add('color-fg-done')
  118.  
  119. const item = {
  120. title: el.querySelector('.markdown-title').textContent.trim(),
  121. el,
  122. url,
  123. read: el.classList.contains('notification-read'),
  124. starred: el.classList.contains('notification-starred'),
  125. type: notificationType,
  126. status,
  127. isClosed: ['closed', 'done', 'muted'].includes(status),
  128. markDone: () => {
  129. console.log(`[${NAME}]`, 'Mark notifications done', item)
  130. el.querySelector('button[type=submit] .octicon-check').parentElement.parentElement.click()
  131. },
  132. }
  133.  
  134. return item
  135. })
  136. }
  137.  
  138. function getReasonMarkedDone(item) {
  139. if (item.isClosed && (item.read || item.type === 'subscribed'))
  140. return 'Closed / merged'
  141.  
  142. if (item.title.startsWith('chore(deps): update ') && (item.read || item.type === 'subscribed'))
  143. return 'Renovate bot'
  144.  
  145. if (item.url.match('/pull/[0-9]+/files/'))
  146. return 'New commit pushed to PR'
  147.  
  148. if (item.type === 'ci activity' && /workflow run cancell?ed/.test(item.title))
  149. return 'GH PR Audit Action workflow run cancelled, probably due to another run taking precedence'
  150. }
  151.  
  152. function isInboxView() {
  153. const query = new URLSearchParams(window.location.search).get('query')
  154. if (!query)
  155. return true
  156.  
  157. const conditions = query.split(' ')
  158. return ['is:done', 'is:saved'].every(condition => !conditions.includes(condition))
  159. }
  160.  
  161. function autoMarkDone() {
  162. // Only mark on "Inbox" view
  163. if (!isInboxView())
  164. return
  165.  
  166. const items = getIssues()
  167.  
  168. console.log(items)
  169. let count = 0
  170.  
  171. const done = []
  172.  
  173. items.forEach((i) => {
  174. // skip bookmarked notifications
  175. if (i.starred)
  176. return
  177.  
  178. const reason = getReasonMarkedDone(i)
  179. if (!reason)
  180. return
  181.  
  182. count++
  183. i.markDone()
  184. done.push({
  185. title: i.title,
  186. reason,
  187. link: i.link,
  188. })
  189. })
  190.  
  191. if (done.length) {
  192. console.log(`[${NAME}]`, `${count} notifications marked done`)
  193. console.table(done)
  194. }
  195.  
  196. // Refresh page after marking done (expand the pagination)
  197. if (count >= 5)
  198. setTimeout(() => refresh(), 200)
  199. }
  200.  
  201. function removeBotAvatars() {
  202. document.querySelectorAll('.AvatarStack-body > a')
  203. .forEach((r) => {
  204. if (r.href.startsWith('/apps/') || r.href.startsWith('https://github.com/apps/'))
  205. r.remove()
  206. })
  207. }
  208.  
  209. /**
  210. * The "x new notifications" badge
  211. */
  212. function hasNewNotifications() {
  213. return !!document.querySelector('.js-updatable-content a[href="/notifications?query="]')
  214. }
  215.  
  216. // Click the notification tab to do soft refresh
  217. function refresh() {
  218. if (!isInNotificationPage())
  219. return
  220. document.querySelector('.filter-list a[href="/notifications"]').click()
  221. lastUpdate = Date.now()
  222. }
  223.  
  224. function isInNotificationPage() {
  225. return location.href.startsWith('https://github.com/notifications')
  226. }
  227.  
  228. ////////////////////////////////////////
  229.  
  230. let initialized = false
  231.  
  232. function run() {
  233. if (isInNotificationPage()) {
  234. // Run only once
  235. if (!initialized) {
  236. initIdleListener()
  237. initBroadcastChannel()
  238. initialized = true
  239.  
  240. setInterval(() => {
  241. if (hasNewNotifications())
  242. refresh()
  243. }, 2000)
  244. }
  245.  
  246. // Run every render
  247. dedupeTab()
  248. externalize()
  249. removeBotAvatars()
  250. autoMarkDone()
  251. }
  252. }
  253.  
  254. injectStyle()
  255. run()
  256.  
  257. // listen to github page loaded event
  258. document.addEventListener('pjax:end', () => run())
  259. document.addEventListener('turbo:render', () => run())
  260. })()