您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances the GitHub Notifications page, making it more productive and less noisy.
// ==UserScript== // @name Refined GitHub Notifications // @namespace https://greatest.deepsurf.us/en/scripts/461320-refined-github-notifications // @version 0.6.8 // @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 (/(?:chore|build)\((?:deps|deps-dev)\): (?:update|bump)/.test(item.title) && (item.read || item.type === 'subscribed')) return 'Renovate bot | Dependabot' 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"]') if (button) button.click() else location.reload() } 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()) })()