Refined GitHub Notifications

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

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