// ==UserScript==
// @name Refined GitHub Notifications
// @namespace https://gf.zukizuki.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());
})();