您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances the GitHub Notifications page, making it more productive and less noisy.
// ==UserScript== // @name Refined GitHub Notifications // @namespace https://greasyfork.org/ // @version 0.0.1 // @description Enhances the GitHub Notifications page, making it more productive and less noisy. // @author Hunter Johnston (https://github.com/huntabyte) // @license MIT // @homepageURL https://github.com/huntabyte/refined-github-notifications // @supportURL https://github.com/huntabyte/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; } /* 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 }} */ function useOption(key, title, defaultValue) { if (typeof GM_getValue === "undefined") { return { value: defaultValue }; } // eslint-disable-next-line no-undef let value = GM_getValue(key, defaultValue); const ref = { get value() { return value; }, set value(v) { value = v; // eslint-disable-next-line no-undef GM_setValue(key, v); location.reload(); } }; // eslint-disable-next-line no-undef 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"]'); 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 (e) { // } } /** * @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()); })();