// ==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 = `
`;
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: `
`
});
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);
})();