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.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 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. // wait for the notification shelf to be updated
  87. await new Promise((resolve) => {
  88. new MutationObserver(() => {
  89. resolve()
  90. })
  91. .observe(
  92. shelf,
  93. {
  94. childList: true,
  95. attributes: true,
  96. subtree: true,
  97. attributeFilter: ['data-redirect-to-inbox-on-submit'],
  98. },
  99. )
  100. })
  101. // close the tab
  102. window.close()
  103. }
  104.  
  105. const fab = button.cloneNode(true)
  106. fab.classList.remove('btn-sm')
  107. fab.classList.add('btn-hover-primary')
  108. fab.style.aspectRatio = '1/1'
  109. fab.style.borderRadius = '100%'
  110. fab.addEventListener('click', clickAndClose)
  111. containers.appendChild(fab)
  112.  
  113. if (button === doneButton) {
  114. document.addEventListener('keydown', (e) => {
  115. if ((e.metaKey || e.ctrlKey) && e.key === 'x') {
  116. e.preventDefault()
  117. clickAndClose()
  118. }
  119. })
  120. }
  121. }
  122.  
  123. return true
  124. }
  125.  
  126. // when first into the page, the notification shelf might not be loaded, we need to wait for it to show
  127. if (!inject()) {
  128. const observer = new MutationObserver((mutationList) => {
  129. const found = mutationList.some(i => i.type === 'childList' && Array.from(i.addedNodes).some(el => el.classList.contains('js-notification-shelf')))
  130. if (found) {
  131. inject()
  132. observer.disconnect()
  133. }
  134. })
  135. observer.observe(document.querySelector('[data-turbo-body]'), { childList: true })
  136. }
  137. }
  138.  
  139. function initBroadcastChannel() {
  140. bcInitTime = Date.now()
  141. bc = new BroadcastChannel('refined-github-notifications')
  142.  
  143. bc.onmessage = ({ data }) => {
  144. console.log(`[${NAME}]`, 'Received message', data)
  145. if (data.type === 'check-dedupe') {
  146. // If the new tab is opened after the current tab, close the current tab
  147. if (data.time > bcInitTime) {
  148. window.close()
  149. location.href = 'https://close-me.netlify.app'
  150. }
  151. }
  152. }
  153. }
  154.  
  155. function dedupeTab() {
  156. if (!bc)
  157. return
  158. bc.postMessage({ type: 'check-dedupe', time: bcInitTime, url: location.href })
  159. }
  160.  
  161. function externalize() {
  162. document.querySelectorAll('a')
  163. .forEach((r) => {
  164. if (r.href.startsWith('https://github.com/notifications'))
  165. return
  166. r.target = '_blank'
  167. r.rel = 'noopener noreferrer'
  168. })
  169. }
  170.  
  171. function initIdleListener() {
  172. // Auto refresh page on going back to the page
  173. document.addEventListener('visibilitychange', (e) => {
  174. if (document.visibilityState === 'visible')
  175. refresh()
  176. })
  177. }
  178.  
  179. function getIssues() {
  180. return [...document.querySelectorAll('.notifications-list-item')]
  181. .map((el) => {
  182. const url = el.querySelector('a.notification-list-item-link').href
  183. const status = el.querySelector('.color-fg-open')
  184. ? 'open'
  185. : el.querySelector('.color-fg-done')
  186. ? 'done'
  187. : el.querySelector('.color-fg-closed')
  188. ? 'closed'
  189. : el.querySelector('.color-fg-muted')
  190. ? 'muted'
  191. : 'unknown'
  192.  
  193. const notificationTypeEl = el.querySelector('.AvatarStack').nextElementSibling
  194. const notificationType = notificationTypeEl.textContent.trim()
  195.  
  196. // Colorize notification type
  197. if (notificationType === 'mention')
  198. notificationTypeEl.classList.add('color-fg-open')
  199. else if (notificationType === 'subscribed')
  200. notificationTypeEl.classList.add('color-fg-muted')
  201. else if (notificationType === 'review requested')
  202. notificationTypeEl.classList.add('color-fg-done')
  203.  
  204. const item = {
  205. title: el.querySelector('.markdown-title').textContent.trim(),
  206. el,
  207. url,
  208. read: el.classList.contains('notification-read'),
  209. starred: el.classList.contains('notification-starred'),
  210. type: notificationType,
  211. status,
  212. isClosed: ['closed', 'done', 'muted'].includes(status),
  213. markDone: () => {
  214. console.log(`[${NAME}]`, 'Mark notifications done', item)
  215. el.querySelector('button[type=submit] .octicon-check').parentElement.parentElement.click()
  216. },
  217. }
  218.  
  219. return item
  220. })
  221. }
  222.  
  223. function getReasonMarkedDone(item) {
  224. if (item.isClosed && (item.read || item.type === 'subscribed'))
  225. return 'Closed / merged'
  226.  
  227. if (item.title.startsWith('chore(deps): update ') && (item.read || item.type === 'subscribed'))
  228. return 'Renovate bot'
  229.  
  230. if (item.url.match('/pull/[0-9]+/files/'))
  231. return 'New commit pushed to PR'
  232.  
  233. if (item.type === 'ci activity' && /workflow run cancell?ed/.test(item.title))
  234. return 'GH PR Audit Action workflow run cancelled, probably due to another run taking precedence'
  235. }
  236.  
  237. function isInboxView() {
  238. const query = new URLSearchParams(window.location.search).get('query')
  239. if (!query)
  240. return true
  241.  
  242. const conditions = query.split(' ')
  243. return ['is:done', 'is:saved'].every(condition => !conditions.includes(condition))
  244. }
  245.  
  246. function autoMarkDone() {
  247. // Only mark on "Inbox" view
  248. if (!isInboxView())
  249. return
  250.  
  251. const items = getIssues()
  252.  
  253. console.log(`[${NAME}] ${items}`)
  254. let count = 0
  255.  
  256. const done = []
  257.  
  258. items.forEach((i) => {
  259. // skip bookmarked notifications
  260. if (i.starred)
  261. return
  262.  
  263. const reason = getReasonMarkedDone(i)
  264. if (!reason)
  265. return
  266.  
  267. count++
  268. i.markDone()
  269. done.push({
  270. title: i.title,
  271. reason,
  272. link: i.link,
  273. })
  274. })
  275.  
  276. if (done.length) {
  277. console.log(`[${NAME}]`, `${count} notifications marked done`)
  278. console.table(done)
  279. }
  280.  
  281. // Refresh page after marking done (expand the pagination)
  282. if (count >= 5)
  283. setTimeout(() => refresh(), 200)
  284. }
  285.  
  286. function removeBotAvatars() {
  287. document.querySelectorAll('.AvatarStack-body > a')
  288. .forEach((r) => {
  289. if (r.href.startsWith('/apps/') || r.href.startsWith('https://github.com/apps/'))
  290. r.remove()
  291. })
  292. }
  293.  
  294. /**
  295. * The "x new notifications" badge
  296. */
  297. function hasNewNotifications() {
  298. return !!document.querySelector('.js-updatable-content a[href="/notifications?query="]')
  299. }
  300.  
  301. // Click the notification tab to do soft refresh
  302. function refresh() {
  303. if (!isInNotificationPage())
  304. return
  305. document.querySelector('.filter-list a[href="/notifications"]').click()
  306. }
  307.  
  308. function isInNotificationPage() {
  309. return location.href.startsWith('https://github.com/notifications')
  310. }
  311.  
  312. function observeForNewNotifications() {
  313. try {
  314. const observer = new MutationObserver(() => {
  315. if (hasNewNotifications())
  316. refresh()
  317. })
  318. observer.observe(document.querySelector('.js-check-all-container').children[0], {
  319. childList: true,
  320. subtree: true,
  321. })
  322. }
  323. catch (e) {
  324. }
  325. }
  326.  
  327. ////////////////////////////////////////
  328.  
  329. let initialized = false
  330.  
  331. function run() {
  332. if (isInNotificationPage()) {
  333. // Run only once
  334. if (!initialized) {
  335. initIdleListener()
  336. initBroadcastChannel()
  337. observeForNewNotifications()
  338. initialized = true
  339. }
  340.  
  341. // Run every render
  342. dedupeTab()
  343. externalize()
  344. removeBotAvatars()
  345. autoMarkDone()
  346. }
  347. else {
  348. notificationShelf()
  349. }
  350. }
  351.  
  352. injectStyle()
  353. run()
  354.  
  355. // listen to github page loaded event
  356. document.addEventListener('pjax:end', () => run())
  357. document.addEventListener('turbo:render', () => run())
  358. })()