Refined GitHub Notifications

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

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

  1. // ==UserScript==
  2. // @name Refined GitHub Notifications
  3. // @namespace https://greasyfork.org/en/scripts/461320-refined-github-notifications
  4. // @version 0.2.0
  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. .js-notification-shelf {
  39. display: none !important;
  40. }
  41. `
  42. document.head.appendChild(style)
  43. }
  44.  
  45. /**
  46. * To have a FAB button to close current issue,
  47. * where you can mark done and then close the tab automatically
  48. */
  49. function notificationShelf() {
  50. function inject() {
  51. const shelf = document.querySelector('.js-notification-shelf')
  52. if (!shelf)
  53. return false
  54.  
  55. const containers = document.createElement('div')
  56. Object.assign(containers.style, {
  57. position: 'fixed',
  58. right: '20px',
  59. bottom: '20px',
  60. zIndex: 999,
  61. display: 'flex',
  62. flexDirection: 'column',
  63. gap: '10px',
  64. })
  65. document.body.appendChild(containers)
  66.  
  67. const doneButton = shelf.querySelector('button[title="Done"]')
  68. // const unsubscribeButton = shelf.querySelector('button[title="Unsubscribe"]')
  69.  
  70. const buttons = [
  71. // unsubscribeButton,
  72. doneButton,
  73. ].filter(Boolean)
  74.  
  75. for (const button of buttons) {
  76. const clickAndClose = async () => {
  77. button.click()
  78. await new Promise(resolve => setTimeout(resolve, 100))
  79. window.close()
  80. }
  81.  
  82. const fab = button.cloneNode(true)
  83. fab.classList.remove('btn-sm')
  84. fab.style.aspectRatio = '1/1'
  85. fab.style.broderRadius = '100%'
  86. fab.addEventListener('click', clickAndClose)
  87. containers.appendChild(fab)
  88.  
  89. if (button === doneButton) {
  90. document.addEventListener('keydown', (e) => {
  91. if ((e.metaKey || e.ctrlKey) && e.key === 'x') {
  92. e.preventDefault()
  93. clickAndClose()
  94. }
  95. })
  96. }
  97. }
  98.  
  99. return true
  100. }
  101.  
  102. // when first into the page, the notification shelf might not be loaded, we need to wait for it to show
  103. if (!inject()) {
  104. const observer = new MutationObserver((mutationList) => {
  105. const found = mutationList.some(i => i.type === 'childList' && Array.from(i.addedNodes).some(el => el.classList.contains('js-notification-shelf')))
  106. if (found) {
  107. inject()
  108. observer.disconnect()
  109. }
  110. })
  111. observer.observe(document.querySelector('[data-turbo-body]'), { childList: true })
  112. }
  113. }
  114.  
  115. function initBroadcastChannel() {
  116. bcInitTime = Date.now()
  117. bc = new BroadcastChannel('refined-github-notifications')
  118.  
  119. bc.onmessage = ({ data }) => {
  120. console.log(`[${NAME}]`, 'Received message', data)
  121. if (data.type === 'check-dedupe') {
  122. // If the new tab is opened after the current tab, close the current tab
  123. if (data.time > bcInitTime) {
  124. // TODO: close the tab
  125. try {
  126. window.close()
  127. }
  128. catch (e) {}
  129. location.href = 'about:blank'
  130. }
  131. }
  132. }
  133. }
  134.  
  135. function dedupeTab() {
  136. if (!bc)
  137. return
  138. bc.postMessage({ type: 'check-dedupe', time: bcInitTime, url: location.href })
  139. }
  140.  
  141. function externalize() {
  142. document.querySelectorAll('a')
  143. .forEach((r) => {
  144. if (r.href.startsWith('https://github.com/notifications'))
  145. return
  146. r.target = '_blank'
  147. r.rel = 'noopener noreferrer'
  148. })
  149. }
  150.  
  151. function initIdleListener() {
  152. // Auto refresh page on going back to the page
  153. document.addEventListener('visibilitychange', (e) => {
  154. if (document.visibilityState === 'visible')
  155. refresh()
  156. })
  157. }
  158.  
  159. function getIssues() {
  160. return [...document.querySelectorAll('.notifications-list-item')]
  161. .map((el) => {
  162. const url = el.querySelector('a.notification-list-item-link').href
  163. const status = el.querySelector('.color-fg-open')
  164. ? 'open'
  165. : el.querySelector('.color-fg-done')
  166. ? 'done'
  167. : el.querySelector('.color-fg-closed')
  168. ? 'closed'
  169. : el.querySelector('.color-fg-muted')
  170. ? 'muted'
  171. : 'unknown'
  172.  
  173. const notificationTypeEl = el.querySelector('.AvatarStack').nextElementSibling
  174. const notificationType = notificationTypeEl.textContent.trim()
  175.  
  176. // Colorize notification type
  177. if (notificationType === 'mention')
  178. notificationTypeEl.classList.add('color-fg-open')
  179. else if (notificationType === 'subscribed')
  180. notificationTypeEl.classList.add('color-fg-muted')
  181. else if (notificationType === 'review requested')
  182. notificationTypeEl.classList.add('color-fg-done')
  183.  
  184. const item = {
  185. title: el.querySelector('.markdown-title').textContent.trim(),
  186. el,
  187. url,
  188. read: el.classList.contains('notification-read'),
  189. starred: el.classList.contains('notification-starred'),
  190. type: notificationType,
  191. status,
  192. isClosed: ['closed', 'done', 'muted'].includes(status),
  193. markDone: () => {
  194. console.log(`[${NAME}]`, 'Mark notifications done', item)
  195. el.querySelector('button[type=submit] .octicon-check').parentElement.parentElement.click()
  196. },
  197. }
  198.  
  199. return item
  200. })
  201. }
  202.  
  203. function getReasonMarkedDone(item) {
  204. if (item.isClosed && (item.read || item.type === 'subscribed'))
  205. return 'Closed / merged'
  206.  
  207. if (item.title.startsWith('chore(deps): update ') && (item.read || item.type === 'subscribed'))
  208. return 'Renovate bot'
  209.  
  210. if (item.url.match('/pull/[0-9]+/files/'))
  211. return 'New commit pushed to PR'
  212.  
  213. if (item.type === 'ci activity' && /workflow run cancell?ed/.test(item.title))
  214. return 'GH PR Audit Action workflow run cancelled, probably due to another run taking precedence'
  215. }
  216.  
  217. function isInboxView() {
  218. const query = new URLSearchParams(window.location.search).get('query')
  219. if (!query)
  220. return true
  221.  
  222. const conditions = query.split(' ')
  223. return ['is:done', 'is:saved'].every(condition => !conditions.includes(condition))
  224. }
  225.  
  226. function autoMarkDone() {
  227. // Only mark on "Inbox" view
  228. if (!isInboxView())
  229. return
  230.  
  231. const items = getIssues()
  232.  
  233. console.log(`[${NAME}] ${items}`)
  234. let count = 0
  235.  
  236. const done = []
  237.  
  238. items.forEach((i) => {
  239. // skip bookmarked notifications
  240. if (i.starred)
  241. return
  242.  
  243. const reason = getReasonMarkedDone(i)
  244. if (!reason)
  245. return
  246.  
  247. count++
  248. i.markDone()
  249. done.push({
  250. title: i.title,
  251. reason,
  252. link: i.link,
  253. })
  254. })
  255.  
  256. if (done.length) {
  257. console.log(`[${NAME}]`, `${count} notifications marked done`)
  258. console.table(done)
  259. }
  260.  
  261. // Refresh page after marking done (expand the pagination)
  262. if (count >= 5)
  263. setTimeout(() => refresh(), 200)
  264. }
  265.  
  266. function removeBotAvatars() {
  267. document.querySelectorAll('.AvatarStack-body > a')
  268. .forEach((r) => {
  269. if (r.href.startsWith('/apps/') || r.href.startsWith('https://github.com/apps/'))
  270. r.remove()
  271. })
  272. }
  273.  
  274. /**
  275. * The "x new notifications" badge
  276. */
  277. function hasNewNotifications() {
  278. return !!document.querySelector('.js-updatable-content a[href="/notifications?query="]')
  279. }
  280.  
  281. // Click the notification tab to do soft refresh
  282. function refresh() {
  283. if (!isInNotificationPage())
  284. return
  285. document.querySelector('.filter-list a[href="/notifications"]').click()
  286. lastUpdate = Date.now()
  287. }
  288.  
  289. function isInNotificationPage() {
  290. return location.href.startsWith('https://github.com/notifications')
  291. }
  292.  
  293. function observeForNewNotifications() {
  294. try {
  295. const observer = new MutationObserver(() => {
  296. if (hasNewNotifications())
  297. refresh()
  298. })
  299. observer.observe(document.querySelector('.js-check-all-container').children[0], {
  300. childList: true,
  301. subtree: true,
  302. })
  303. }
  304. catch (e) {
  305. }
  306. }
  307.  
  308. ////////////////////////////////////////
  309.  
  310. let initialized = false
  311.  
  312. function run() {
  313. if (isInNotificationPage()) {
  314. // Run only once
  315. if (!initialized) {
  316. initIdleListener()
  317. initBroadcastChannel()
  318. observeForNewNotifications()
  319. initialized = true
  320. }
  321.  
  322. // Run every render
  323. dedupeTab()
  324. externalize()
  325. removeBotAvatars()
  326. autoMarkDone()
  327. }
  328. else {
  329. notificationShelf()
  330. }
  331. }
  332.  
  333. injectStyle()
  334. run()
  335.  
  336. // listen to github page loaded event
  337. document.addEventListener('pjax:end', () => run())
  338. document.addEventListener('turbo:render', () => run())
  339. })()