Refined GitHub Notifications

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

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

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