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