Refined GitHub Notifications

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

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

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