Refined GitHub Notifications

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

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

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