Refined GitHub Notifications

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

  1. // ==UserScript==
  2. // @name Refined GitHub Notifications
  3. // @namespace https://greasyfork.org/en/scripts/461320-refined-github-notifications
  4. // @version 0.6.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. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_unregisterMenuCommand
  17. // ==/UserScript==
  18.  
  19. // @ts-check
  20. /* eslint-disable no-console */
  21.  
  22. /**
  23. * @typedef {import('./index.d').NotificationItem} Item
  24. * @typedef {import('./index.d').Subject} Subject
  25. * @typedef {import('./index.d').DetailsCache} DetailsCache
  26. */
  27.  
  28. (function () {
  29. 'use strict'
  30.  
  31. // Fix the archive link
  32. if (location.pathname === '/notifications/beta/archive')
  33. location.pathname = '/notifications'
  34.  
  35. /**
  36. * list of functions to be cleared on page change
  37. * @type {(() => void)[]}
  38. */
  39. const cleanups = []
  40.  
  41. const NAME = 'Refined GitHub Notifications'
  42. const STORAGE_KEY = 'refined-github-notifications'
  43. const STORAGE_KEY_DETAILS = 'refined-github-notifications:details-cache'
  44. const DETAILS_CACHE_TIMEOUT = 1000 * 60 * 60 * 6 // 6 hours
  45.  
  46. const AUTO_MARK_DONE = useOption('rgn_auto_mark_done', 'Auto mark done', true)
  47. const HIDE_CHECKBOX = useOption('rgn_hide_checkbox', 'Hide checkbox', true)
  48. const HIDE_ISSUE_NUMBER = useOption('rgn_hide_issue_number', 'Hide issue number', true)
  49. const HIDE_EMPTY_INBOX_IMAGE = useOption('rgn_hide_empty_inbox_image', 'Hide empty inbox image', true)
  50. const ENHANCE_NOTIFICATION_SHELF = useOption('rgn_enhance_notification_shelf', 'Enhance notification shelf', true)
  51. const SHOW_DEATAILS = useOption('rgn_show_details', 'Detail Preview', false)
  52. const SHOW_REACTIONS = useOption('rgn_show_reactions', 'Reactions Preview', false)
  53.  
  54. const GITHUB_TOKEN = localStorage.getItem('github_token') || ''
  55.  
  56. const config = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
  57. /**
  58. * @type {Record<string, DetailsCache>}
  59. */
  60. const detailsCache = JSON.parse(localStorage.getItem(STORAGE_KEY_DETAILS) || '{}')
  61.  
  62. let bc
  63. let bcInitTime = 0
  64.  
  65. const reactionsMap = {
  66. '+1': '👍',
  67. '-1': '👎',
  68. 'laugh': '😄',
  69. 'hooray': '🎉',
  70. 'confused': '😕',
  71. 'heart': '❤️',
  72. 'rocket': '🚀',
  73. 'eyes': '👀',
  74. }
  75.  
  76. function writeConfig() {
  77. localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
  78. }
  79.  
  80. function injectStyle() {
  81. const style = document.createElement('style')
  82. style.innerHTML = [
  83. `
  84. /* Hide blue dot on notification icon */
  85. .mail-status.unread {
  86. display: none !important;
  87. }
  88. /* Hide blue dot on notification with the new navigration */
  89. .AppHeader .AppHeader-button.AppHeader-button--hasIndicator::before {
  90. display: none !important;
  91. }
  92. /* Limit notification container width on large screen for better readability */
  93. .notifications-v2 .js-check-all-container {
  94. max-width: 1000px;
  95. margin: 0 auto;
  96. }
  97. /* Hide sidebar earlier, override the breakpoints */
  98. @media (min-width: 768px) {
  99. .js-notifications-container {
  100. flex-direction: column !important;
  101. }
  102. .js-notifications-container > .d-none.d-md-flex {
  103. display: none !important;
  104. }
  105. .js-notifications-container > .col-md-9 {
  106. width: 100% !important;
  107. }
  108. }
  109. @media (min-width: 1268px) {
  110. .js-notifications-container {
  111. flex-direction: row !important;
  112. }
  113. .js-notifications-container > .d-none.d-md-flex {
  114. display: flex !important;
  115. }
  116. }
  117. `,
  118. HIDE_CHECKBOX.value && `
  119. /* Hide check box on notification list */
  120. .notifications-list-item > *:first-child label {
  121. opacity: 0 !important;
  122. width: 0 !important;
  123. margin-right: -10px !important;
  124. }`,
  125. ENHANCE_NOTIFICATION_SHELF.value && `
  126. /* Hide the notification shelf and add a FAB */
  127. .js-notification-shelf {
  128. display: none !important;
  129. }
  130. .btn-hover-primary {
  131. transform: scale(1.2);
  132. transition: all .3s ease-in-out;
  133. }
  134. .btn-hover-primary:hover {
  135. color: var(--color-btn-primary-text);
  136. background-color: var(--color-btn-primary-bg);
  137. border-color: var(--color-btn-primary-border);
  138. box-shadow: var(--color-btn-primary-shadow),var(--color-btn-primary-inset-shadow);
  139. }`,
  140. HIDE_EMPTY_INBOX_IMAGE.value && `/* Hide the image on zero-inbox */
  141. .js-notifications-blankslate picture {
  142. display: none !important;
  143. }`,
  144. ]
  145. .filter(Boolean)
  146. .join('\n')
  147. document.head.appendChild(style)
  148. }
  149.  
  150. /**
  151. * Create UI for the options
  152. * @template T
  153. * @param {string} key
  154. * @param {string} title
  155. * @param {T} defaultValue
  156. * @returns {{ value: T }} return
  157. */
  158. function useOption(key, title, defaultValue) {
  159. if (typeof GM_getValue === 'undefined') {
  160. return {
  161. value: defaultValue,
  162. }
  163. }
  164.  
  165. let value = GM_getValue(key, defaultValue)
  166. const ref = {
  167. get value() {
  168. return value
  169. },
  170. set value(v) {
  171. value = v
  172. GM_setValue(key, v)
  173. location.reload()
  174. },
  175. }
  176.  
  177. GM_registerMenuCommand(`${title}: ${value ? '✅' : '❌'}`, () => {
  178. ref.value = !value
  179. })
  180.  
  181. return ref
  182. }
  183.  
  184. /**
  185. * To have a FAB button to close current issue,
  186. * where you can mark done and then close the tab automatically
  187. */
  188. function enhanceNotificationShelf() {
  189. function inject() {
  190. const shelf = document.querySelector('.js-notification-shelf')
  191. if (!shelf)
  192. return false
  193.  
  194. /** @type {HTMLButtonElement} */
  195. const doneButton = shelf.querySelector('button[aria-label="Done"]')
  196. if (!doneButton)
  197. return false
  198.  
  199. const clickAndClose = async () => {
  200. doneButton.click()
  201. // wait for the notification shelf to be updated
  202. await Promise.race([
  203. new Promise((resolve) => {
  204. const ob = new MutationObserver(() => {
  205. resolve()
  206. ob.disconnect()
  207. })
  208.  
  209. ob.observe(
  210. shelf,
  211. {
  212. childList: true,
  213. subtree: true,
  214. attributes: true,
  215. },
  216. )
  217. }),
  218. new Promise(resolve => setTimeout(resolve, 1000)),
  219. ])
  220. // close the tab
  221. window.close()
  222. }
  223.  
  224. /**
  225. * @param {KeyboardEvent} e
  226. */
  227. const keyDownHandle = (e) => {
  228. if (e.altKey && e.key === 'x') {
  229. e.preventDefault()
  230. clickAndClose()
  231. }
  232. }
  233.  
  234. /** @type {*} */
  235. const fab = doneButton.cloneNode(true)
  236. fab.classList.remove('btn-sm')
  237. fab.classList.add('btn-hover-primary')
  238. fab.addEventListener('click', clickAndClose)
  239. Object.assign(fab.style, {
  240. position: 'fixed',
  241. right: '25px',
  242. bottom: '25px',
  243. zIndex: 999,
  244. aspectRatio: '1/1',
  245. borderRadius: '50%',
  246. })
  247.  
  248. const commentActions = document.querySelector('#partial-new-comment-form-actions')
  249. if (commentActions) {
  250. const key = 'markDoneAfterComment'
  251. const label = document.createElement('label')
  252. const input = document.createElement('input')
  253. label.classList.add('color-fg-muted')
  254. input.type = 'checkbox'
  255. input.checked = !!config[key]
  256. input.addEventListener('change', (e) => {
  257. // @ts-expect-error cast
  258. config[key] = !!e.target.checked
  259. writeConfig()
  260. })
  261. label.appendChild(input)
  262. label.appendChild(document.createTextNode(' Mark done and close after comment'))
  263. Object.assign(label.style, {
  264. display: 'flex',
  265. alignItems: 'center',
  266. justifyContent: 'end',
  267. gap: '5px',
  268. userSelect: 'none',
  269. fontWeight: '400',
  270. })
  271. const div = document.createElement('div')
  272. Object.assign(div.style, {
  273. paddingBottom: '5px',
  274. })
  275. div.appendChild(label)
  276. commentActions.parentElement.prepend(div)
  277.  
  278. const commentButton = commentActions.querySelector('button.btn-primary[type="submit"]')
  279. const closeButton = commentActions.querySelector('[name="comment_and_close"]')
  280. const buttons = [commentButton, closeButton].filter(Boolean)
  281.  
  282. for (const button of buttons) {
  283. button.addEventListener('click', async () => {
  284. if (config[key]) {
  285. await new Promise(resolve => setTimeout(resolve, 1000))
  286. clickAndClose()
  287. }
  288. })
  289. }
  290. }
  291.  
  292. const mergeMessage = document.querySelector('.merge-message')
  293. if (mergeMessage) {
  294. const key = 'markDoneAfterMerge'
  295. const label = document.createElement('label')
  296. const input = document.createElement('input')
  297. label.classList.add('color-fg-muted')
  298. input.type = 'checkbox'
  299. input.checked = !!config[key]
  300. input.addEventListener('change', (e) => {
  301. // @ts-expect-error cast
  302. config[key] = !!e.target.checked
  303. writeConfig()
  304. })
  305. label.appendChild(input)
  306. label.appendChild(document.createTextNode(' Mark done and close after merge'))
  307. Object.assign(label.style, {
  308. display: 'flex',
  309. alignItems: 'center',
  310. justifyContent: 'end',
  311. gap: '5px',
  312. userSelect: 'none',
  313. fontWeight: '400',
  314. })
  315. mergeMessage.prepend(label)
  316.  
  317. /** @type {HTMLButtonElement[]} */
  318. const buttons = Array.from(mergeMessage.querySelectorAll('.js-auto-merge-box button'))
  319. for (const button of buttons) {
  320. button.addEventListener('click', async () => {
  321. if (config[key]) {
  322. await new Promise(resolve => setTimeout(resolve, 1000))
  323. clickAndClose()
  324. }
  325. })
  326. }
  327. }
  328.  
  329. document.body.appendChild(fab)
  330. document.addEventListener('keydown', keyDownHandle)
  331. cleanups.push(() => {
  332. document.body.removeChild(fab)
  333. document.removeEventListener('keydown', keyDownHandle)
  334. })
  335.  
  336. return true
  337. }
  338.  
  339. // when first into the page, the notification shelf might not be loaded, we need to wait for it to show
  340. if (!inject()) {
  341. const observer = new MutationObserver((mutationList) => {
  342. /** @type {HTMLElement[]} */
  343. const addedNodes = /** @type {*} */ (Array.from(mutationList[0].addedNodes))
  344. const found = mutationList.some(i => i.type === 'childList' && addedNodes.some(el => el.classList.contains('js-notification-shelf')))
  345. if (found) {
  346. inject()
  347. observer.disconnect()
  348. }
  349. })
  350. observer.observe(document.querySelector('[data-turbo-body]'), { childList: true })
  351. cleanups.push(() => {
  352. observer.disconnect()
  353. })
  354. }
  355. }
  356.  
  357. function initBroadcastChannel() {
  358. bcInitTime = Date.now()
  359. bc = new BroadcastChannel('refined-github-notifications')
  360.  
  361. bc.onmessage = ({ data }) => {
  362. if (isInNotificationPage()) {
  363. console.log(`[${NAME}]`, 'Received message', data)
  364. if (data.type === 'check-dedupe') {
  365. // If the new tab is opened after the current tab, close the current tab
  366. if (data.time > bcInitTime) {
  367. window.close()
  368. location.href = 'https://close-me.netlify.app'
  369. }
  370. }
  371. }
  372. }
  373. }
  374.  
  375. function dedupeTab() {
  376. if (!bc)
  377. return
  378. bc.postMessage({ type: 'check-dedupe', time: bcInitTime, url: location.href })
  379. }
  380.  
  381. function externalize() {
  382. document.querySelectorAll('a')
  383. .forEach((r) => {
  384. if (r.href.startsWith('https://github.com/notifications'))
  385. return
  386. // try to use the same tab
  387. r.target = r.href.replace('https://github.com', '').replace(/[\\/?#-]/g, '_')
  388. })
  389. }
  390.  
  391. function initIdleListener() {
  392. // Auto refresh page on going back to the page
  393. document.addEventListener('visibilitychange', () => {
  394. if (document.visibilityState === 'visible')
  395. refresh()
  396. })
  397. }
  398.  
  399. function getIssues() {
  400. /** @type {HTMLDivElement[]} */
  401. const items = Array.from(document.querySelectorAll('.notifications-list-item'))
  402. return items.map((el) => {
  403. /** @type {HTMLLinkElement} */
  404. const linkEl = el.querySelector('a.notification-list-item-link')
  405. const url = linkEl.href
  406. const status = el.querySelector('.color-fg-open')
  407. ? 'open'
  408. : el.querySelector('.color-fg-done')
  409. ? 'done'
  410. : el.querySelector('.color-fg-closed')
  411. ? 'closed'
  412. : el.querySelector('.color-fg-muted')
  413. ? 'muted'
  414. : 'unknown'
  415.  
  416. /** @type {HTMLDivElement | undefined} */
  417. const notificationTypeEl = /** @type {*} */ (el.querySelector('.AvatarStack').nextElementSibling)
  418. if (!notificationTypeEl)
  419. return null
  420. const notificationType = notificationTypeEl.textContent.trim()
  421.  
  422. /** @type {Item} */
  423. const item = {
  424. title: el.querySelector('.markdown-title').textContent.trim(),
  425. el,
  426. url,
  427. urlBare: url.replace(/[#?].*$/, ''),
  428. read: el.classList.contains('notification-read'),
  429. starred: el.classList.contains('notification-starred'),
  430. type: notificationType,
  431. status,
  432. isClosed: ['closed', 'done', 'muted'].includes(status),
  433. markDone: () => {
  434. console.log(`[${NAME}]`, 'Mark notifications done', item)
  435. el.querySelector('button[type=submit] .octicon-check').parentElement.parentElement.click()
  436. },
  437. }
  438.  
  439. if (!el.classList.contains('enhanced-notification')) {
  440. // Colorize notification type
  441. if (notificationType === 'mention')
  442. notificationTypeEl.classList.add('color-fg-open')
  443. else if (notificationType === 'author')
  444. notificationTypeEl.style.color = 'var(--color-scale-green-5)'
  445. else if (notificationType === 'ci activity')
  446. notificationTypeEl.classList.add('color-fg-muted')
  447. else if (notificationType === 'commented')
  448. notificationTypeEl.style.color = 'var(--color-scale-blue-4)'
  449. else if (notificationType === 'subscribed')
  450. notificationTypeEl.remove()
  451. else if (notificationType === 'state change')
  452. notificationTypeEl.classList.add('color-fg-muted')
  453. else if (notificationType === 'review requested')
  454. notificationTypeEl.classList.add('color-fg-done')
  455.  
  456. // Remove plus one
  457. const plusOneEl = Array.from(el.querySelectorAll('.d-md-flex'))
  458. .find(i => i.textContent.trim().startsWith('+'))
  459. if (plusOneEl)
  460. plusOneEl.remove()
  461.  
  462. // Remove issue number
  463. if (HIDE_ISSUE_NUMBER.value) {
  464. const issueNo = linkEl.children[1]?.children?.[0]?.querySelector('.color-fg-muted')
  465. if (issueNo && issueNo.textContent.trim().startsWith('#'))
  466. issueNo.remove()
  467. }
  468.  
  469. if (SHOW_DEATAILS.value || SHOW_REACTIONS.value) {
  470. fetchDetail(item)
  471. .then((r) => {
  472. if (r) {
  473. if (SHOW_REACTIONS.value)
  474. registerReactions(item, r)
  475. if (SHOW_DEATAILS.value)
  476. registerPopup(item, r)
  477. }
  478. })
  479. }
  480. }
  481.  
  482. el.classList.add('enhanced-notification')
  483.  
  484. return item
  485. }).filter(Boolean)
  486. }
  487.  
  488. function getReasonMarkedDone(item) {
  489. if (item.isClosed && (item.read || item.type === 'subscribed'))
  490. return 'Closed / merged'
  491.  
  492. if (/(?:chore|build)\((?:deps|deps-dev)\): (?:update|bump)/.test(item.title) && (item.read || item.type === 'subscribed'))
  493. return 'Renovate bot | Dependabot'
  494.  
  495. if (item.url.match('/pull/[0-9]+/files/'))
  496. return 'New commit pushed to PR'
  497.  
  498. if (item.type === 'ci activity' && /workflow run cancell?ed/.test(item.title))
  499. return 'GH PR Audit Action workflow run cancelled, probably due to another run taking precedence'
  500. }
  501.  
  502. function isInboxView() {
  503. const query = new URLSearchParams(window.location.search).get('query')
  504. if (!query)
  505. return true
  506.  
  507. const conditions = query.split(' ')
  508. return ['is:done', 'is:saved'].every(condition => !conditions.includes(condition))
  509. }
  510.  
  511. function purgeCache() {
  512. const now = Date.now()
  513. Object.entries(detailsCache).forEach(([key, value]) => {
  514. if (now - value.lastUpdated > DETAILS_CACHE_TIMEOUT)
  515. delete detailsCache[key]
  516. })
  517. }
  518.  
  519. /**
  520. * Add reactions count when there are more than 3 reactions
  521. *
  522. * @param {Item} item
  523. * @param {Subject} subject
  524. */
  525. function registerReactions(item, subject) {
  526. if ('reactions' in subject && subject.reactions) {
  527. const reactions = Object.entries(subject.reactions)
  528. .map(([k, v]) => ({ emoji: k, count: +v }))
  529. .filter(i => i.count >= 3 && i.emoji !== 'total_count')
  530. if (reactions.length) {
  531. const reactionsEl = document.createElement('div')
  532. reactionsEl.classList.add('Label')
  533. reactionsEl.classList.add('color-fg-muted')
  534. Object.assign(reactionsEl.style, {
  535. display: 'flex',
  536. gap: '0.4em',
  537. alignItems: 'center',
  538. marginRight: '-1.5em',
  539. })
  540. reactionsEl.append(
  541. ...reactions.map((i) => {
  542. const el = document.createElement('span')
  543. el.textContent = `${reactionsMap[i.emoji]} ${i.count}`
  544. return el
  545. }),
  546. )
  547. const avatarStack = item.el.querySelector('.AvatarStack')
  548. avatarStack.parentElement.insertBefore(reactionsEl, avatarStack.nextElementSibling)
  549. }
  550. }
  551. }
  552.  
  553. /** @type {HTMLElement | undefined} */
  554. let currentPopup
  555. /** @type {Item | undefined} */
  556. let currentItem
  557.  
  558. /**
  559. * @param {Item} item
  560. * @param {Subject} subject
  561. */
  562. function registerPopup(item, subject) {
  563. if (!subject.body)
  564. return
  565.  
  566. /** @type {HTMLElement | undefined} */
  567. let popupEl
  568. /** @type {HTMLElement} */
  569. const titleEl = item.el.querySelector('.markdown-title')
  570.  
  571. async function initPopup() {
  572. const bodyHtml = await renderBody(item, subject)
  573.  
  574. popupEl = document.createElement('div')
  575. popupEl.className = 'Popover js-hovercard-content position-absolute'
  576.  
  577. const bodyBoxEl = document.createElement('div')
  578. bodyBoxEl.className = 'Popover-message Popover-message--large Box color-shadow-large Popover-message--top-right'
  579.  
  580. // @ts-expect-error assign
  581. bodyBoxEl.style = 'overflow: auto; width: 800px; max-height: 500px;'
  582.  
  583. const contentEl = document.createElement('div')
  584. contentEl.className = 'comment-body markdown-body js-comment-body'
  585.  
  586. contentEl.innerHTML = bodyHtml
  587. // @ts-expect-error assign
  588. contentEl.style = 'padding: 1rem 1rem; transform-origin: left top;'
  589.  
  590. if (subject.user) {
  591. const userAvatar = document.createElement('a')
  592. userAvatar.className = 'author text-bold Link--primary'
  593. userAvatar.style.display = 'flex'
  594. userAvatar.style.alignItems = 'center'
  595. userAvatar.style.gap = '0.4em'
  596. userAvatar.href = subject.user?.html_url
  597. userAvatar.innerHTML = `
  598. <img alt="@${subject.user?.login}" class="avatar avatar-user" height="18" src="${subject.user?.avatar_url}" width="18">
  599. <span>${subject.user.login}</span>
  600. `
  601. const time = document.createElement('relative-time')
  602. // @ts-expect-error custom element
  603. time.datetime = subject.created_at
  604. time.className = 'color-fg-muted'
  605. time.style.marginLeft = '0.4em'
  606. const p = document.createElement('p')
  607. p.style.display = 'flex'
  608. p.style.alignItems = 'center'
  609. p.style.gap = '0.25em'
  610. p.append(userAvatar)
  611. p.append(time)
  612.  
  613. contentEl.prepend(p)
  614. }
  615.  
  616. bodyBoxEl.append(contentEl)
  617. popupEl.append(bodyBoxEl)
  618.  
  619. popupEl.addEventListener('mouseenter', () => {
  620. popupShow()
  621. })
  622.  
  623. popupEl.addEventListener('mouseleave', () => {
  624. if (currentPopup === popupEl)
  625. removeCurrent()
  626. })
  627.  
  628. return popupEl
  629. }
  630.  
  631. /** @type {Promise<HTMLElement>} */
  632. let _promise
  633.  
  634. async function popupShow() {
  635. currentItem = item
  636. _promise = _promise || initPopup()
  637. await _promise
  638. removeCurrent()
  639.  
  640. const box = titleEl.getBoundingClientRect()
  641. // @ts-expect-error assign
  642. popupEl.style = `display: block; outline: none; top: ${box.top + box.height + window.scrollY + 5}px; left: ${box.left - 10}px; z-index: 100;`
  643. document.body.append(popupEl)
  644. currentPopup = popupEl
  645. }
  646.  
  647. function removeCurrent() {
  648. if (currentPopup && Array.from(document.body.children).includes(currentPopup))
  649. document.body.removeChild(currentPopup)
  650. }
  651.  
  652. titleEl.addEventListener('mouseenter', popupShow)
  653. titleEl.addEventListener('mouseleave', () => {
  654. if (currentItem === item)
  655. currentItem = undefined
  656.  
  657. setTimeout(() => {
  658. if (!currentItem)
  659. removeCurrent()
  660. }, 500)
  661. })
  662. }
  663.  
  664. /**
  665. * @param {Item[]} items
  666. */
  667. function autoMarkDone(items) {
  668. console.info(`[${NAME}] ${items.length} notifications found`)
  669. console.table(items)
  670. let count = 0
  671.  
  672. const done = []
  673.  
  674. items.forEach((i) => {
  675. // skip bookmarked notifications
  676. if (i.starred)
  677. return
  678.  
  679. const reason = getReasonMarkedDone(i)
  680. if (!reason)
  681. return
  682.  
  683. count++
  684. i.markDone()
  685. done.push({
  686. title: i.title,
  687. reason,
  688. url: i.url,
  689. })
  690. })
  691.  
  692. if (done.length) {
  693. console.log(`[${NAME}]`, `${count} notifications marked done`)
  694. console.table(done)
  695. }
  696.  
  697. // Refresh page after marking done (expand the pagination)
  698. if (count >= 5)
  699. setTimeout(() => refresh(), 200)
  700. }
  701.  
  702. function removeBotAvatars() {
  703. /** @type {HTMLLinkElement[]} */
  704. const avatars = Array.from(document.querySelectorAll('.AvatarStack-body > a'))
  705.  
  706. avatars.forEach((r) => {
  707. if (r.href.startsWith('/apps/') || r.href.startsWith('https://github.com/apps/'))
  708. r.remove()
  709. })
  710. }
  711.  
  712. /**
  713. * The "x new notifications" badge
  714. */
  715. function hasNewNotifications() {
  716. return !!document.querySelector('.js-updatable-content a[href="/notifications?query="]')
  717. }
  718.  
  719. function cleanup() {
  720. cleanups.forEach(fn => fn())
  721. cleanups.length = 0
  722. }
  723.  
  724. // Click the notification tab to do soft refresh
  725. function refresh() {
  726. if (!isInNotificationPage())
  727. return
  728. /** @type {HTMLButtonElement} */
  729. const button = document.querySelector('.filter-list a[href="/notifications"]')
  730. if (button)
  731. button.click()
  732. else
  733. location.reload()
  734. }
  735.  
  736. function isInNotificationPage() {
  737. return location.href.startsWith('https://github.com/notifications')
  738. }
  739.  
  740. function initNewNotificationsObserver() {
  741. try {
  742. const observer = new MutationObserver(() => {
  743. if (hasNewNotifications())
  744. refresh()
  745. })
  746. observer.observe(document.querySelector('.js-check-all-container').children[0], {
  747. childList: true,
  748. subtree: true,
  749. })
  750. }
  751. catch {
  752. }
  753. }
  754.  
  755. /**
  756. * @param {Item} item
  757. */
  758. async function fetchDetail(item) {
  759. if (detailsCache[item.urlBare]?.subject)
  760. return detailsCache[item.urlBare].subject
  761.  
  762. console.log(`[${NAME}]`, 'Fetching issue details', item)
  763. const apiUrl = item.urlBare
  764. .replace('https://github.com', 'https://api.github.com/repos')
  765. .replace('/pull/', '/pulls/')
  766.  
  767. if (!apiUrl.includes('/issues/') && !apiUrl.includes('/pulls/'))
  768. return
  769.  
  770. try {
  771. /** @type {Subject} */
  772. const data = await fetch(apiUrl, {
  773. headers: {
  774. 'Content-Type': 'application/vnd.github+json',
  775. 'Authorization': GITHUB_TOKEN ? `Bearer ${GITHUB_TOKEN}` : undefined,
  776. },
  777. }).then(r => r.json())
  778. detailsCache[item.urlBare] = {
  779. url: item.urlBare,
  780. lastUpdated: Date.now(),
  781. subject: data,
  782. }
  783. localStorage.setItem(STORAGE_KEY_DETAILS, JSON.stringify(detailsCache))
  784.  
  785. return data
  786. }
  787. catch (e) {
  788. console.error(`[${NAME}]`, `Failed to fetch issue details of ${item.urlBare}`, e)
  789. }
  790. }
  791.  
  792. /**
  793. * @param {Item} item
  794. * @param {Subject} subject
  795. */
  796. async function renderBody(item, subject) {
  797. if (!subject.body)
  798. return
  799. if (detailsCache[item.urlBare]?.bodyHtml)
  800. return detailsCache[item.urlBare].bodyHtml
  801.  
  802. const repoName = subject.repository?.full_name || item.urlBare.split('/').slice(3, 5).join('/')
  803.  
  804. const bodyHtml = await fetch('https://api.github.com/markdown', {
  805. method: 'POST',
  806. body: JSON.stringify({
  807. text: subject.body,
  808. mode: 'gfm',
  809. context: repoName,
  810. }),
  811. headers: {
  812. 'Content-Type': 'application/vnd.github+json',
  813. 'Authorization': GITHUB_TOKEN ? `Bearer ${GITHUB_TOKEN}` : undefined,
  814. },
  815. }).then(r => r.text())
  816.  
  817. if (detailsCache[item.urlBare]) {
  818. detailsCache[item.urlBare].bodyHtml = bodyHtml
  819.  
  820. localStorage.setItem(STORAGE_KEY_DETAILS, JSON.stringify(detailsCache))
  821. }
  822.  
  823. return bodyHtml
  824. }
  825.  
  826. ////////////////////////////////////////
  827.  
  828. let initialized = false
  829.  
  830. function run() {
  831. cleanup()
  832. if (isInNotificationPage()) {
  833. // Run only once
  834. if (!initialized) {
  835. initIdleListener()
  836. initBroadcastChannel()
  837. initNewNotificationsObserver()
  838. initialized = true
  839. }
  840.  
  841. const items = getIssues()
  842.  
  843. // Run every render
  844. dedupeTab()
  845. externalize()
  846. removeBotAvatars()
  847.  
  848. // Only mark on "Inbox" view
  849. if (isInboxView() && AUTO_MARK_DONE.value)
  850. autoMarkDone(items)
  851. }
  852. else {
  853. if (ENHANCE_NOTIFICATION_SHELF.value)
  854. enhanceNotificationShelf()
  855. }
  856. }
  857.  
  858. injectStyle()
  859. purgeCache()
  860. run()
  861.  
  862. // listen to github page loaded event
  863. document.addEventListener('pjax:end', () => run())
  864. document.addEventListener('turbo:render', () => run())
  865. })()