Refined GitHub Notifications

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

// ==UserScript==
// @name         Refined GitHub Notifications
// @namespace    https://greasyfork.org/en/scripts/461320-refined-github-notifications
// @version      0.6.6
// @description  Enhances the GitHub Notifications page, making it more productive and less noisy.
// @author       Anthony Fu (https://github.com/antfu)
// @license      MIT
// @homepageURL  https://github.com/antfu/refined-github-notifications
// @supportURL   https://github.com/antfu/refined-github-notifications
// @match        https://github.com/**
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        window.close
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// ==/UserScript==

// @ts-check
/* eslint-disable no-console */

/**
 * @typedef {import('./index.d').NotificationItem} Item
 * @typedef {import('./index.d').Subject} Subject
 * @typedef {import('./index.d').DetailsCache} DetailsCache
 */

(function () {
  'use strict'

  // Fix the archive link
  if (location.pathname === '/notifications/beta/archive')
    location.pathname = '/notifications'

  /**
   * list of functions to be cleared on page change
   * @type {(() => void)[]}
   */
  const cleanups = []

  const NAME = 'Refined GitHub Notifications'
  const STORAGE_KEY = 'refined-github-notifications'
  const STORAGE_KEY_DETAILS = 'refined-github-notifications:details-cache'
  const DETAILS_CACHE_TIMEOUT = 1000 * 60 * 60 * 6 // 6 hours

  const AUTO_MARK_DONE = useOption('rgn_auto_mark_done', 'Auto mark done', true)
  const HIDE_CHECKBOX = useOption('rgn_hide_checkbox', 'Hide checkbox', true)
  const HIDE_ISSUE_NUMBER = useOption('rgn_hide_issue_number', 'Hide issue number', true)
  const HIDE_EMPTY_INBOX_IMAGE = useOption('rgn_hide_empty_inbox_image', 'Hide empty inbox image', true)
  const ENHANCE_NOTIFICATION_SHELF = useOption('rgn_enhance_notification_shelf', 'Enhance notification shelf', true)
  const SHOW_DEATAILS = useOption('rgn_show_details', 'Detail Preview', false)
  const SHOW_REACTIONS = useOption('rgn_show_reactions', 'Reactions Preview', false)

  const GITHUB_TOKEN = localStorage.getItem('github_token') || ''

  const config = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
  /**
   * @type {Record<string, DetailsCache>}
   */
  const detailsCache = JSON.parse(localStorage.getItem(STORAGE_KEY_DETAILS) || '{}')

  let bc
  let bcInitTime = 0

  const reactionsMap = {
    '+1': '👍',
    '-1': '👎',
    'laugh': '😄',
    'hooray': '🎉',
    'confused': '😕',
    'heart': '❤️',
    'rocket': '🚀',
    'eyes': '👀',
  }

  function writeConfig() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
  }

  function injectStyle() {
    const style = document.createElement('style')
    style.innerHTML = [
      `
/* Hide blue dot on notification icon */
.mail-status.unread {
  display: none !important;
}
/* Hide blue dot on notification with the new navigration */
.AppHeader .AppHeader-button.AppHeader-button--hasIndicator::before {
  display: none !important;
}
/* Limit notification container width on large screen for better readability */
.notifications-v2 .js-check-all-container {
  max-width: 1000px;
  margin: 0 auto;
}
/* Hide sidebar earlier, override the breakpoints */
@media (min-width: 768px) {
  .js-notifications-container {
    flex-direction: column !important;
  }
  .js-notifications-container > .d-none.d-md-flex {
    display: none !important;
  }
  .js-notifications-container > .col-md-9 {
    width: 100% !important;
  }
}
@media (min-width: 1268px) {
  .js-notifications-container {
    flex-direction: row !important;
  }
  .js-notifications-container > .d-none.d-md-flex {
    display: flex !important;
  }
}
`,
      HIDE_CHECKBOX.value && `
/* Hide check box on notification list */
.notifications-list-item > *:first-child label {
  opacity: 0 !important;
  width: 0 !important;
  margin-right: -10px !important;
}`,
      ENHANCE_NOTIFICATION_SHELF.value && `
/* Hide the notification shelf and add a FAB */
.js-notification-shelf {
  display: none !important;
}
.btn-hover-primary {
  transform: scale(1.2);
  transition: all .3s ease-in-out;
}
.btn-hover-primary:hover {
  color: var(--color-btn-primary-text);
  background-color: var(--color-btn-primary-bg);
  border-color: var(--color-btn-primary-border);
  box-shadow: var(--color-btn-primary-shadow),var(--color-btn-primary-inset-shadow);
}`,
      HIDE_EMPTY_INBOX_IMAGE.value && `/* Hide the image on zero-inbox */
.js-notifications-blankslate picture {
  display: none !important;
}`,
    ]
      .filter(Boolean)
      .join('\n')
    document.head.appendChild(style)
  }

  /**
   * Create UI for the options
   * @template T
   * @param {string} key
   * @param {string} title
   * @param {T} defaultValue
   * @returns {{ value: T }} return
   */
  function useOption(key, title, defaultValue) {
    if (typeof GM_getValue === 'undefined') {
      return {
        value: defaultValue,
      }
    }

    let value = GM_getValue(key, defaultValue)
    const ref = {
      get value() {
        return value
      },
      set value(v) {
        value = v
        GM_setValue(key, v)
        location.reload()
      },
    }

    GM_registerMenuCommand(`${title}: ${value ? '✅' : '❌'}`, () => {
      ref.value = !value
    })

    return ref
  }

  /**
   * To have a FAB button to close current issue,
   * where you can mark done and then close the tab automatically
   */
  function enhanceNotificationShelf() {
    function inject() {
      const shelf = document.querySelector('.js-notification-shelf')
      if (!shelf)
        return false

      /** @type {HTMLButtonElement} */
      const doneButton = shelf.querySelector('button[aria-label="Done"]')
      if (!doneButton)
        return false

      const clickAndClose = async () => {
        doneButton.click()
        // wait for the notification shelf to be updated
        await Promise.race([
          new Promise((resolve) => {
            const ob = new MutationObserver(() => {
              resolve()
              ob.disconnect()
            })

            ob.observe(
              shelf,
              {
                childList: true,
                subtree: true,
                attributes: true,
              },
            )
          }),
          new Promise(resolve => setTimeout(resolve, 1000)),
        ])
        // close the tab
        window.close()
      }

      /**
       * @param {KeyboardEvent} e
       */
      const keyDownHandle = (e) => {
        if (e.altKey && e.key === 'x') {
          e.preventDefault()
          clickAndClose()
        }
      }

      /** @type {*} */
      const fab = doneButton.cloneNode(true)
      fab.classList.remove('btn-sm')
      fab.classList.add('btn-hover-primary')
      fab.addEventListener('click', clickAndClose)
      Object.assign(fab.style, {
        position: 'fixed',
        right: '25px',
        bottom: '25px',
        zIndex: 999,
        aspectRatio: '1/1',
        borderRadius: '50%',
      })

      const commentActions = document.querySelector('#partial-new-comment-form-actions')
      if (commentActions) {
        const key = 'markDoneAfterComment'
        const label = document.createElement('label')
        const input = document.createElement('input')
        label.classList.add('color-fg-muted')
        input.type = 'checkbox'
        input.checked = !!config[key]
        input.addEventListener('change', (e) => {
          // @ts-expect-error cast
          config[key] = !!e.target.checked
          writeConfig()
        })
        label.appendChild(input)
        label.appendChild(document.createTextNode(' Mark done and close after comment'))
        Object.assign(label.style, {
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'end',
          gap: '5px',
          userSelect: 'none',
          fontWeight: '400',
        })
        const div = document.createElement('div')
        Object.assign(div.style, {
          paddingBottom: '5px',
        })
        div.appendChild(label)
        commentActions.parentElement.prepend(div)

        const commentButton = commentActions.querySelector('button.btn-primary[type="submit"]')
        const closeButton = commentActions.querySelector('[name="comment_and_close"]')
        const buttons = [commentButton, closeButton].filter(Boolean)

        for (const button of buttons) {
          button.addEventListener('click', async () => {
            if (config[key]) {
              await new Promise(resolve => setTimeout(resolve, 1000))
              clickAndClose()
            }
          })
        }
      }

      const mergeMessage = document.querySelector('.merge-message')
      if (mergeMessage) {
        const key = 'markDoneAfterMerge'
        const label = document.createElement('label')
        const input = document.createElement('input')
        label.classList.add('color-fg-muted')
        input.type = 'checkbox'
        input.checked = !!config[key]
        input.addEventListener('change', (e) => {
          // @ts-expect-error cast
          config[key] = !!e.target.checked
          writeConfig()
        })
        label.appendChild(input)
        label.appendChild(document.createTextNode(' Mark done and close after merge'))
        Object.assign(label.style, {
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'end',
          gap: '5px',
          userSelect: 'none',
          fontWeight: '400',
        })
        mergeMessage.prepend(label)

        /** @type {HTMLButtonElement[]} */
        const buttons = Array.from(mergeMessage.querySelectorAll('.js-auto-merge-box button'))
        for (const button of buttons) {
          button.addEventListener('click', async () => {
            if (config[key]) {
              await new Promise(resolve => setTimeout(resolve, 1000))
              clickAndClose()
            }
          })
        }
      }

      document.body.appendChild(fab)
      document.addEventListener('keydown', keyDownHandle)
      cleanups.push(() => {
        document.body.removeChild(fab)
        document.removeEventListener('keydown', keyDownHandle)
      })

      return true
    }

    // when first into the page, the notification shelf might not be loaded, we need to wait for it to show
    if (!inject()) {
      const observer = new MutationObserver((mutationList) => {
        /** @type {HTMLElement[]} */
        const addedNodes = /** @type {*} */ (Array.from(mutationList[0].addedNodes))
        const found = mutationList.some(i => i.type === 'childList' && addedNodes.some(el => el.classList.contains('js-notification-shelf')))
        if (found) {
          inject()
          observer.disconnect()
        }
      })
      observer.observe(document.querySelector('[data-turbo-body]'), { childList: true })
      cleanups.push(() => {
        observer.disconnect()
      })
    }
  }

  function initBroadcastChannel() {
    bcInitTime = Date.now()
    bc = new BroadcastChannel('refined-github-notifications')

    bc.onmessage = ({ data }) => {
      if (isInNotificationPage()) {
        console.log(`[${NAME}]`, 'Received message', data)
        if (data.type === 'check-dedupe') {
          // If the new tab is opened after the current tab, close the current tab
          if (data.time > bcInitTime) {
            window.close()
            location.href = 'https://close-me.netlify.app'
          }
        }
      }
    }
  }

  function dedupeTab() {
    if (!bc)
      return
    bc.postMessage({ type: 'check-dedupe', time: bcInitTime, url: location.href })
  }

  function externalize() {
    document.querySelectorAll('a')
      .forEach((r) => {
        if (r.href.startsWith('https://github.com/notifications'))
          return
        // try to use the same tab
        r.target = r.href.replace('https://github.com', '').replace(/[\\/?#-]/g, '_')
      })
  }

  function initIdleListener() {
    // Auto refresh page on going back to the page
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible')
        refresh()
    })
  }

  function getIssues() {
    /** @type {HTMLDivElement[]} */
    const items = Array.from(document.querySelectorAll('.notifications-list-item'))
    return items.map((el) => {
      /** @type {HTMLLinkElement} */
      const linkEl = el.querySelector('a.notification-list-item-link')
      const url = linkEl.href
      const status = el.querySelector('.color-fg-open')
        ? 'open'
        : el.querySelector('.color-fg-done')
          ? 'done'
          : el.querySelector('.color-fg-closed')
            ? 'closed'
            : el.querySelector('.color-fg-muted')
              ? 'muted'
              : 'unknown'

      /** @type {HTMLDivElement | undefined} */
      const notificationTypeEl = /** @type {*} */ (el.querySelector('.AvatarStack').nextElementSibling)
      if (!notificationTypeEl)
        return null
      const notificationType = notificationTypeEl.textContent.trim()

      /** @type {Item} */
      const item = {
        title: el.querySelector('.markdown-title').textContent.trim(),
        el,
        url,
        urlBare: url.replace(/[#?].*$/, ''),
        read: el.classList.contains('notification-read'),
        starred: el.classList.contains('notification-starred'),
        type: notificationType,
        status,
        isClosed: ['closed', 'done', 'muted'].includes(status),
        markDone: () => {
          console.log(`[${NAME}]`, 'Mark notifications done', item)
          el.querySelector('button[type=submit] .octicon-check').parentElement.parentElement.click()
        },
      }

      if (!el.classList.contains('enhanced-notification')) {
        // Colorize notification type
        if (notificationType === 'mention')
          notificationTypeEl.classList.add('color-fg-open')
        else if (notificationType === 'author')
          notificationTypeEl.style.color = 'var(--color-scale-green-5)'
        else if (notificationType === 'ci activity')
          notificationTypeEl.classList.add('color-fg-muted')
        else if (notificationType === 'commented')
          notificationTypeEl.style.color = 'var(--color-scale-blue-4)'
        else if (notificationType === 'subscribed')
          notificationTypeEl.remove()
        else if (notificationType === 'state change')
          notificationTypeEl.classList.add('color-fg-muted')
        else if (notificationType === 'review requested')
          notificationTypeEl.classList.add('color-fg-done')

        // Remove plus one
        const plusOneEl = Array.from(el.querySelectorAll('.d-md-flex'))
          .find(i => i.textContent.trim().startsWith('+'))
        if (plusOneEl)
          plusOneEl.remove()

        // Remove issue number
        if (HIDE_ISSUE_NUMBER.value) {
          const issueNo = linkEl.children[1]?.children?.[0]?.querySelector('.color-fg-muted')
          if (issueNo && issueNo.textContent.trim().startsWith('#'))
            issueNo.remove()
        }

        if (SHOW_DEATAILS.value || SHOW_REACTIONS.value) {
          fetchDetail(item)
            .then((r) => {
              if (r) {
                if (SHOW_REACTIONS.value)
                  registerReactions(item, r)
                if (SHOW_DEATAILS.value)
                  registerPopup(item, r)
              }
            })
        }
      }

      el.classList.add('enhanced-notification')

      return item
    }).filter(Boolean)
  }

  function getReasonMarkedDone(item) {
    if (item.isClosed && (item.read || item.type === 'subscribed'))
      return 'Closed / merged'

    if (item.title.startsWith('chore(deps): update ') && (item.read || item.type === 'subscribed'))
      return 'Renovate bot'

    if (item.url.match('/pull/[0-9]+/files/'))
      return 'New commit pushed to PR'

    if (item.type === 'ci activity' && /workflow run cancell?ed/.test(item.title))
      return 'GH PR Audit Action workflow run cancelled, probably due to another run taking precedence'
  }

  function isInboxView() {
    const query = new URLSearchParams(window.location.search).get('query')
    if (!query)
      return true

    const conditions = query.split(' ')
    return ['is:done', 'is:saved'].every(condition => !conditions.includes(condition))
  }

  function purgeCache() {
    const now = Date.now()
    Object.entries(detailsCache).forEach(([key, value]) => {
      if (now - value.lastUpdated > DETAILS_CACHE_TIMEOUT)
        delete detailsCache[key]
    })
  }

  /**
   * Add reactions count when there are more than 3 reactions
   *
   * @param {Item} item
   * @param {Subject} subject
   */
  function registerReactions(item, subject) {
    if ('reactions' in subject && subject.reactions) {
      const reactions = Object.entries(subject.reactions)
        .map(([k, v]) => ({ emoji: k, count: +v }))
        .filter(i => i.count >= 3 && i.emoji !== 'total_count')
      if (reactions.length) {
        const reactionsEl = document.createElement('div')
        reactionsEl.classList.add('Label')
        reactionsEl.classList.add('color-fg-muted')
        Object.assign(reactionsEl.style, {
          display: 'flex',
          gap: '0.4em',
          alignItems: 'center',
          marginRight: '-1.5em',
        })
        reactionsEl.append(
          ...reactions.map((i) => {
            const el = document.createElement('span')
            el.textContent = `${reactionsMap[i.emoji]} ${i.count}`
            return el
          }),
        )
        const avatarStack = item.el.querySelector('.AvatarStack')
        avatarStack.parentElement.insertBefore(reactionsEl, avatarStack.nextElementSibling)
      }
    }
  }

  /** @type {HTMLElement | undefined} */
  let currentPopup
  /** @type {Item | undefined} */
  let currentItem

  /**
   * @param {Item} item
   * @param {Subject} subject
   */
  function registerPopup(item, subject) {
    if (!subject.body)
      return

    /** @type {HTMLElement | undefined} */
    let popupEl
    /** @type {HTMLElement} */
    const titleEl = item.el.querySelector('.markdown-title')

    async function initPopup() {
      const bodyHtml = await renderBody(item, subject)

      popupEl = document.createElement('div')
      popupEl.className = 'Popover js-hovercard-content position-absolute'

      const bodyBoxEl = document.createElement('div')
      bodyBoxEl.className = 'Popover-message Popover-message--large Box color-shadow-large Popover-message--top-right'

      // @ts-expect-error assign
      bodyBoxEl.style = 'overflow: auto; width: 800px; max-height: 500px;'

      const contentEl = document.createElement('div')
      contentEl.className = 'comment-body markdown-body js-comment-body'

      contentEl.innerHTML = bodyHtml
      // @ts-expect-error assign
      contentEl.style = 'padding: 1rem 1rem; transform-origin: left top;'

      if (subject.user) {
        const userAvatar = document.createElement('a')
        userAvatar.className = 'author text-bold Link--primary'
        userAvatar.style.display = 'flex'
        userAvatar.style.alignItems = 'center'
        userAvatar.style.gap = '0.4em'
        userAvatar.href = subject.user?.html_url
        userAvatar.innerHTML = `
          <img alt="@${subject.user?.login}" class="avatar avatar-user" height="18" src="${subject.user?.avatar_url}" width="18">
          <span>${subject.user.login}</span>
        `
        const time = document.createElement('relative-time')
        // @ts-expect-error custom element
        time.datetime = subject.created_at
        time.className = 'color-fg-muted'
        time.style.marginLeft = '0.4em'
        const p = document.createElement('p')
        p.style.display = 'flex'
        p.style.alignItems = 'center'
        p.style.gap = '0.25em'
        p.append(userAvatar)
        p.append(time)

        contentEl.prepend(p)
      }

      bodyBoxEl.append(contentEl)
      popupEl.append(bodyBoxEl)

      popupEl.addEventListener('mouseenter', () => {
        popupShow()
      })

      popupEl.addEventListener('mouseleave', () => {
        if (currentPopup === popupEl)
          removeCurrent()
      })

      return popupEl
    }

    /** @type {Promise<HTMLElement>} */
    let _promise

    async function popupShow() {
      currentItem = item
      _promise = _promise || initPopup()
      await _promise
      removeCurrent()

      const box = titleEl.getBoundingClientRect()
      // @ts-expect-error assign
      popupEl.style = `display: block; outline: none; top: ${box.top + box.height + window.scrollY + 5}px; left: ${box.left - 10}px; z-index: 100;`
      document.body.append(popupEl)
      currentPopup = popupEl
    }

    function removeCurrent() {
      if (currentPopup && Array.from(document.body.children).includes(currentPopup))
        document.body.removeChild(currentPopup)
    }

    titleEl.addEventListener('mouseenter', popupShow)
    titleEl.addEventListener('mouseleave', () => {
      if (currentItem === item)
        currentItem = undefined

      setTimeout(() => {
        if (!currentItem)
          removeCurrent()
      }, 500)
    })
  }

  /**
   * @param {Item[]} items
   */
  function autoMarkDone(items) {
    console.info(`[${NAME}] ${items.length} notifications found`)
    console.table(items)
    let count = 0

    const done = []

    items.forEach((i) => {
      // skip bookmarked notifications
      if (i.starred)
        return

      const reason = getReasonMarkedDone(i)
      if (!reason)
        return

      count++
      i.markDone()
      done.push({
        title: i.title,
        reason,
        url: i.url,
      })
    })

    if (done.length) {
      console.log(`[${NAME}]`, `${count} notifications marked done`)
      console.table(done)
    }

    // Refresh page after marking done (expand the pagination)
    if (count >= 5)
      setTimeout(() => refresh(), 200)
  }

  function removeBotAvatars() {
    /** @type {HTMLLinkElement[]} */
    const avatars = Array.from(document.querySelectorAll('.AvatarStack-body > a'))

    avatars.forEach((r) => {
      if (r.href.startsWith('/apps/') || r.href.startsWith('https://github.com/apps/'))
        r.remove()
    })
  }

  /**
   * The "x new notifications" badge
   */
  function hasNewNotifications() {
    return !!document.querySelector('.js-updatable-content a[href="/notifications?query="]')
  }

  function cleanup() {
    cleanups.forEach(fn => fn())
    cleanups.length = 0
  }

  // Click the notification tab to do soft refresh
  function refresh() {
    if (!isInNotificationPage())
      return
    /** @type {HTMLButtonElement} */
    const button = document.querySelector('.filter-list a[href="/notifications"]')
      ?? document.querySelector('.AppHeader-context a[href="/notifications"]')
    button.click()
  }

  function isInNotificationPage() {
    return location.href.startsWith('https://github.com/notifications')
  }

  function initNewNotificationsObserver() {
    try {
      const observer = new MutationObserver(() => {
        if (hasNewNotifications())
          refresh()
      })
      observer.observe(document.querySelector('.js-check-all-container').children[0], {
        childList: true,
        subtree: true,
      })
    }
    catch {
    }
  }

  /**
   * @param {Item} item
   */
  async function fetchDetail(item) {
    if (detailsCache[item.urlBare]?.subject)
      return detailsCache[item.urlBare].subject

    console.log(`[${NAME}]`, 'Fetching issue details', item)
    const apiUrl = item.urlBare
      .replace('https://github.com', 'https://api.github.com/repos')
      .replace('/pull/', '/pulls/')

    if (!apiUrl.includes('/issues/') && !apiUrl.includes('/pulls/'))
      return

    try {
      /** @type {Subject} */
      const data = await fetch(apiUrl, {
        headers: {
          'Content-Type': 'application/vnd.github+json',
          'Authorization': GITHUB_TOKEN ? `Bearer ${GITHUB_TOKEN}` : undefined,
        },
      }).then(r => r.json())
      detailsCache[item.urlBare] = {
        url: item.urlBare,
        lastUpdated: Date.now(),
        subject: data,
      }
      localStorage.setItem(STORAGE_KEY_DETAILS, JSON.stringify(detailsCache))

      return data
    }
    catch (e) {
      console.error(`[${NAME}]`, `Failed to fetch issue details of ${item.urlBare}`, e)
    }
  }

  /**
   * @param {Item} item
   * @param {Subject} subject
   */
  async function renderBody(item, subject) {
    if (!subject.body)
      return
    if (detailsCache[item.urlBare]?.bodyHtml)
      return detailsCache[item.urlBare].bodyHtml

    const repoName = subject.repository?.full_name || item.urlBare.split('/').slice(3, 5).join('/')

    const bodyHtml = await fetch('https://api.github.com/markdown', {
      method: 'POST',
      body: JSON.stringify({
        text: subject.body,
        mode: 'gfm',
        context: repoName,
      }),
      headers: {
        'Content-Type': 'application/vnd.github+json',
        'Authorization': GITHUB_TOKEN ? `Bearer ${GITHUB_TOKEN}` : undefined,
      },
    }).then(r => r.text())

    if (detailsCache[item.urlBare]) {
      detailsCache[item.urlBare].bodyHtml = bodyHtml

      localStorage.setItem(STORAGE_KEY_DETAILS, JSON.stringify(detailsCache))
    }

    return bodyHtml
  }

  ////////////////////////////////////////

  let initialized = false

  function run() {
    cleanup()
    if (isInNotificationPage()) {
      // Run only once
      if (!initialized) {
        initIdleListener()
        initBroadcastChannel()
        initNewNotificationsObserver()
        initialized = true
      }

      const items = getIssues()

      // Run every render
      dedupeTab()
      externalize()
      removeBotAvatars()

      // Only mark on "Inbox" view
      if (isInboxView() && AUTO_MARK_DONE.value)
        autoMarkDone(items)
    }
    else {
      if (ENHANCE_NOTIFICATION_SHELF.value)
        enhanceNotificationShelf()
    }
  }

  injectStyle()
  purgeCache()
  run()

  // listen to github page loaded event
  document.addEventListener('pjax:end', () => run())
  document.addEventListener('turbo:render', () => run())
})()