Refined GitHub Notifications

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

当前为 2023-05-16 提交的版本,查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址