// ==UserScript== // @name GitHub Watcher // @version 0.2.3 // @description A userscript that can check a repo, folder, file or branch for updates // @license MIT // @author Rob Garrison // @namespace https://github.com/Mottie // @match https://github.com/* // @match https://gist.github.com/* // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163 // @require https://greasyfork.org/scripts/398877-utils-js/code/utilsjs.js?version=1079637 // @icon https://github.githubassets.com/pinned-octocat.svg // @updateURL https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-watcher.user.js // @downloadURL https://raw.githubusercontent.com/Mottie/Github-userscripts/master/github-watcher.user.js // @supportURL https://github.com/Mottie/GitHub-userscripts/issues // ==/UserScript== /* global $ $$ on make */ (() => { "use strict"; GM_addStyle(` .ghwr-list-wrap { max-height:30em; overflow-y:auto; } .ghwr-item-entry .error, .ghwr-item-status { width:14px; height:14px; color: var(--color-text-white, #111); background-image: linear-gradient(#54a3ff, #006eed); background-clip: padding-box; border: 2px solid #181818; border-radius: 50%; display:inline-block; } .ghwr-item-status { position:absolute; top:1px; left:6px; z-index:2; display:none; } .ghwr-item-entry .btn-panel { top:-10em; opacity:0; } .ghwr-item-entry .error, .ghwr-item-status.error { background-image: linear-gradient(#e00, #703); } .ghwr-item-status.unread { display:inline-block; } .ghwr-item-entry:hover, .ghwr-item-entry:focus-within, .ghwr-item-entry:hover .btn-panel, .ghwr-item-entry:focus-within .btn-panel { background-color: var(--color-state-hover-primary-bg); color:var(--color-text); } .ghwr-item-entry:hover .octicon, .ghwr-item-entry:focus-within .octicon { color:var(--color-text); } .ghwr-item-entry:hover .btn-panel, .ghwr-item-entry:focus-within .btn-panel { top:auto; opacity:1; } .ghwr-item-entry .btn-panel, .ghwr-last-checked .ghwr-right-panel { position:absolute; right:0; } .ghwr-last-checked { color:var(--color-text-primary); padding:4px 8px; } .ghwr-item-entry { position:relative; } .ghwr-item-entry:hover a, .ghwr-item-entry:focus-within a, .ghwr-item-entry:hover button, .ghwr-item-entry:focus-within button { color:var(--color-state-hover-primary-text); } .ghwr-item-entry > button:hover, .ghwr-item-entry > button:focus { color:var(--color-btn-text); } .ghwr-item-entry label { width:75%; } .ghwr-item-entry input[type="text"] { width:100%; } .ghwr-item-entry .editing { justify-content:space-between; } .ghwr-items button { border:1px solid var(--color-btn-border); padding:0; border-radius:4px; } .ghwr-items button:not(:hover):not(:focus), .ghwr-item-entry:not(.editing) button:not([aria-checked="true"]) { background:#0000; border-color:#0000; } .ghwr-items button:hover, .ghwr-items button:focus, .ghwr-last-checked a:hover, .ghwr-last-checked a:focus { border-color:var(--color-btn-focus-border); outline:none; box-shadow:var(--color-btn-focus-shadow); } .ghwr-items .btn-panel button:hover, .ghwr-items .btn-panel button:focus { border-color:#eee; box-shadow:#eee; } .ghwr-last-checked button:not([aria-checked="true"]) { border-color:#0000; background:#0000; } .ghwr-items button[data-type="check"]:not([aria-checked="true"]) { cursor:auto; } .btn-panel button { color:var(--color-state-hover-primary-text); filter:brightness(90%); } .btn-panel button:hover, .btn-panel button:focus { filter:brightness(110%); } .ghwr-items li svg { pointer-events:none; fill:currentColor; } .ghwr-items li .btn[data-type="check"][aria-checked="false"] svg { visibility:hidden; } .ghwr-settings { padding:4px 8px 4px 16px; } .ghwr-settings input[type="number"] { width:5em; } .ghwr-items { --color-tooltip-bg:#343434; } .ghwr-items { width:50vw; min-width:500px; } @media (max-width:768px) { .ghwr-items { width:100vw; min-width:unset; } .hide-small { display:none; } } @media (max-width:1260px) { .ghwr-items { width:75vw; } }` ); let token = GM_getValue("github-token"); let timer; let timer2; let focus; let options; let wrap = null; let showSettings = !token; const encodedChars = /[&"'<>\n]/g; const sanitize = text => text.replace( encodedChars, r => `&#${r.charCodeAt(0)};` ); const timeElement = time => ``; const watcherIcon = ` `; const buttonCheck = (label, index) => ` `; const buttonPanel = index => ` `; const editRow = (item, index) => ` `; const errorRow = (item, index) => ` Error: ${item.error || "Please enter a GitHub URL"} ${buttonPanel(index)} `; const itemRow = (item, index) => { const { org, repo, branch = "master", path } = getItemDataFromUrl(item); const message = sanitize(item.message?.trim() || ""); return ` ${buttonCheck("Clear this watched update", index)} Updated ${timeElement(item.date)} – ${path?.split("/").slice(-1) || `${org}/${repo}${branch === "master" ? "" : ` (${branch})`}`} – by ${item.name} – ${message} ${buttonPanel(index)}`; }; // Easier to maintain 3 separate than one big one const regexes = [ /github.com\/(?.+?)\/(?.+?)\/(.+?)\/(?.+?)\/(?.+$)/, /github.com\/(?.+?)\/(?.+?)\/(.+?)\/(?.+?)$/, /github.com\/(?.+?)\/(?.+?)$/ ]; const getItemDataFromUrl = ({ url = "" }) => { const match = regexes.reduce((match, regex) => { const test = url.match(regex); if (!match.done && test) { return { done: true, ...test }; } return match; }, { done: false }); return match?.groups || {}; }; const buildQuery = () => options.items.map((item, index) => { const { org, repo, branch, path } = getItemDataFromUrl(item); const pathQuery = (path || "") !== "" ? `, path: "${path}"` : ""; return org && repo ? `item${index}: repository(name: "${repo}", owner: "${org}") { ref(qualifiedName: "${branch || "master"}") { target { ...on Commit{ history(first:1${pathQuery}) { nodes { author { name date } message } } } } } }` : null; }); const calculatedInterval = (minutes = options.interval) => minutes * 60 * 1000; const setLastCheckedTime = () => { options.lastChecked = token && options.items.filter(item => item.url).length > 0 ? Date.now() : null; }; const setOptions = () => { GM_setValue("options", options); }; const getOptions = () => { options = Object.assign({}, { interval: 60, // check status every x minutes lastChecked: null, items: [] }, GM_getValue("options") || {}); }; const updateOptions = ({ data, errors }) => { $(".ghwr-details", wrap).classList.toggle("error", errors?.length); if (data || errors) { const emptyUrl = "Please enter a GitHub URL"; // Update stored data options.items = options.items .map((item, index) => { const { author: { name, date } = {}, message } = data[`item${index}`]?.ref?.target?.history?.nodes[0] || {}; const hasError = !name || !date ? { message: `${item.url ? `${item.url} is not a valid URL` : emptyUrl}` } : errors?.find(err => err.path[0] === `item${index}`); return { ...item, name, message, date, unread: item.unread || date !== item.date, error: hasError ? hasError.message : "", editing: false }; }) // Sort by date to make the rendered list easy to read .sort((a, b) => new Date(b.date) - new Date(a.date)); setLastCheckedTime(); setOptions(); showStatusIndicator(); } }; // item list is resorted after every update; so find row by url const findRow = (index, selector) => { const url = options.items[index]?.url return url ? `.dropdown-item[data-url="${url}"] ${selector}` : null; } const trailingSlashRegex = /\/$/g; const removeTrailingSlash = url => url.replace(trailingSlashRegex, ""); const handleClick = async ({ type, index } = {}) => { let value; if (type) { switch (type) { case "check": if (index === "all") { options.items = options.items.map(item => ( { ...item, unread: false } )); focus = "button[data-type='refresh']"; } else { options.items[index].unread = false; focus = findRow(index, "a"); } break; case "add": options.items.push({ editing: true }); break; case "delete": options.items.splice(index, 1); focus = findRow(index, "a"); break; case "cancel": options.items[index].editing = false; focus = findRow(index, "button[data-type='edit']"); break; case "edit": options.items[index].editing = true; focus = findRow(index, "input[type='text']"); break; case "save": value = $("input", $$(".ghwr-item-entry")[index])?.value; if (value && value !== options.items[index].url) { options.items[index] = { url: removeTrailingSlash(value) }; options.lastChecked = null; } options.items[index].editing = false; focus = "button[data-type='add']"; break; case "refresh": options.lastChecked = null; focus = "button[data-type='refresh']"; break; case "settings": showSettings = !showSettings; focus = "input[type='password']"; break; case "save-settings": options.error = ""; value = parseInt($("input[type='number']", wrap).value, 10); if (!isNaN(value) && value > 1 && value !== options.interval) { options.interval = value; options.lastChecked = null; } value = $("input[type='password']", wrap).value; if (value && value !== token) { token = value; GM_setValue("github-token", value); options.lastChecked = null; } showSettings = false; focus = "button[data-type='settings']"; break; case "cancel-settings": showSettings = false; focus = "button[data-type='settings']"; break; } setOptions(); renderStatus(); } } const setFocus = () => { setTimeout(() => { if (focus) { $(focus, wrap)?.focus(); focus = ""; } }); }; const getStatus = () => options.items .filter(item => item.unread) .length > 0; const setStatus = () => { $$(".ghwr-item-entry button[data-type='check']", wrap)?.forEach(btn => { const indx = btn.dataset.index; const isChecked = options.items[indx]?.unread; btn.setAttribute("aria-checked", isChecked); btn.disabled = !isChecked; // toggle tooltips btn.classList.toggle("tooltipped", isChecked); }); const isChecked = getStatus(); const lastChkBtn = $(".ghwr-last-checked button[data-type='check']", wrap); lastChkBtn.setAttribute("aria-checked", isChecked); lastChkBtn.classList.toggle("tooltipped", isChecked); lastChkBtn.disabled = !isChecked; showStatusIndicator(); }; const renderStatus = async () => { if (!$(".ghwr-details")?.open) { return; } await fetchStatus(); const list = options.items .map((item, index) => { let content = `Unknown${buttonPanel(index)}`; if (item.editing) { content = editRow(item, index); } else if (item.error || !item.url) { content = errorRow(item, index); } else if (item.url) { content = itemRow(item, index); } const rowClass = [ "d-flex flex-items-center dropdown-item ghwr-item-entry mr-2 px-2", item.editing ? " editing" : "", ].join("") return `
  • ${content}
  • `; }) .join(""); const dateTime = token && options.lastChecked ? timeElement(new Date(options.lastChecked).toISOString()) : "Unknown"; let error; if (token) { error = options.items.length ? options.error : "Please add some items to watch"; } else { error = ` Please add a GitHub personal access token `; } $(".ghwr-items", wrap).innerHTML = `
    • ${watcherIcon} GitHub Watcher
    • ${list.length ? `
      • ${list}
    • ` : ""} ${!token || showSettings ? `
    • ` : ""}
    • ${buttonCheck("Clear all watched updates", "all")} Last checked: ${dateTime} ${error && `${error}`}
    `; setStatus(); setFocus(); } const showError = error => { setLastCheckedTime(); options.error = error.errorMessage || error.message; setOptions(); } const showStatusIndicator = () => { // show unread indicator const indicator = $(".ghwr-item-status", wrap); const hasError = options.items.some(item => item.error); indicator.classList.toggle("unread", hasError || getStatus()); indicator.classList.toggle("error", hasError); }; const shouldFetch = () => { getOptions(); const hasItems = options.items.filter(item => item.url).length > 0; const needsCheck = !options.lastChecked || options.lastChecked + calculatedInterval() < Date.now(); return !!token && hasItems && needsCheck; }; const fetchStatus = () => { getOptions(); if (shouldFetch()) { return fetch( "https://api.github.com/graphql", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `bearer ${token}` }, body: JSON.stringify({ query: `{${buildQuery()}}` }), }) .then(res => res.json()) .then(res => { if (res.message) { throw new Error(res.message); } options.error = ""; return updateOptions(res); }) .catch(err => showError(err)); } return Promise.resolve(); }; const init = async () => { getOptions(); const header = $(".Header .notification-indicator"); if (header && !$(".ghwr-header-notification")) { wrap = make({ el: "div", className: "Header-item position-relative d-md-flex ghwr-header-notification", html: `
    ${watcherIcon}
    ` }); header.closest(".Header-item")?.before(wrap); on($(".ghwr-details", wrap), "toggle", () => { renderStatus(); }); on($(".ghwr-items", wrap), "click", event => { const { target } = event; if (target.type === "button") { event.preventDefault(); handleClick(target.dataset); } else if (target.type === "text") { event.preventDefault(); } }); await fetchStatus(); showStatusIndicator(); } } function setTimer() { clearTimeout(timer); clearInterval(timer2); timer = setTimeout(async () => { if (shouldFetch()) { setLastCheckedTime(); await fetchStatus(); } renderStatus(); setTimer(); }, calculatedInterval()); timer2 = setInterval(() => { // Update indicator every 5 minutes because of multiple tabs getOptions(); showStatusIndicator(); }, calculatedInterval(5)); } getOptions(); init(); setTimer(); on(document, "ghmo:container", init); })();