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