Refined GitHub Notifications

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

目前為 2023-04-04 提交的版本,檢視 最新版本

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