GitHub Watcher

A userscript that can check a repo, folder, file or branch for updates

当前为 2021-11-30 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Watcher
  3. // @version 0.2.2
  4. // @description A userscript that can check a repo, folder, file or branch for updates
  5. // @license MIT
  6. // @author Rob Garrison
  7. // @namespace https://github.com/Mottie
  8. // @include https://github.com/*
  9. // @include https://gist.github.com/*
  10. // @run-at document-idle
  11. // @grant GM_addStyle
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=952601
  15. // @require https://greasyfork.org/scripts/398877-utils-js/code/utilsjs.js?version=952600
  16. // @icon https://github.githubassets.com/pinned-octocat.svg
  17. // @supportURL https://github.com/Mottie/GitHub-userscripts/issues
  18. // ==/UserScript==
  19. /* global $ $$ on make */
  20. (() => {
  21. "use strict";
  22.  
  23. GM_addStyle(`
  24. .ghwr-list-wrap { max-height:30em; overflow-y:auto; }
  25. .ghwr-item-entry .error, .ghwr-item-status {
  26. width:14px; height:14px; color: var(--color-text-white, #111);
  27. background-image: linear-gradient(#54a3ff, #006eed);
  28. background-clip: padding-box; border: 2px solid #181818;
  29. border-radius: 50%; display:inline-block; }
  30. .ghwr-item-status { position:absolute; top:1px; left:6px; z-index:2;
  31. display:none; }
  32. .ghwr-item-entry .btn-panel { top:-10em; opacity:0; }
  33. .ghwr-item-entry .error, .ghwr-item-status.error {
  34. background-image: linear-gradient(#e00, #703); }
  35. .ghwr-item-status.unread { display:inline-block; }
  36. .ghwr-item-entry:hover, .ghwr-item-entry:focus-within,
  37. .ghwr-item-entry:hover .btn-panel, .ghwr-item-entry:focus-within .btn-panel {
  38. background-color: var(--color-state-hover-primary-bg);
  39. color:var(--color-text); }
  40. .ghwr-item-entry:hover .octicon, .ghwr-item-entry:focus-within .octicon {
  41. color:var(--color-text); }
  42. .ghwr-item-entry:hover .btn-panel, .ghwr-item-entry:focus-within .btn-panel {
  43. top:auto; opacity:1; }
  44. .ghwr-item-entry .btn-panel, .ghwr-last-checked .ghwr-right-panel {
  45. position:absolute; right:0; }
  46. .ghwr-last-checked { color:var(--color-text-primary); padding:4px 8px; }
  47. .ghwr-item-entry { position:relative; }
  48. .ghwr-item-entry:hover a, .ghwr-item-entry:focus-within a,
  49. .ghwr-item-entry:hover button, .ghwr-item-entry:focus-within button {
  50. color:var(--color-state-hover-primary-text); }
  51. .ghwr-item-entry > button:hover, .ghwr-item-entry > button:focus {
  52. color:var(--color-btn-text); }
  53. .ghwr-item-entry label { width:75%; }
  54. .ghwr-item-entry input[type="text"] { width:100%; }
  55. .ghwr-item-entry .editing { justify-content:space-between; }
  56. .ghwr-items button { border:1px solid var(--color-btn-border); padding:0;
  57. border-radius:4px; }
  58. .ghwr-items button:not(:hover):not(:focus),
  59. .ghwr-item-entry:not(.editing) button:not([aria-checked="true"]) {
  60. background:#0000; border-color:#0000; }
  61. .ghwr-items button:hover, .ghwr-items button:focus, .ghwr-last-checked a:hover,
  62. .ghwr-last-checked a:focus { border-color:var(--color-btn-focus-border);
  63. outline:none; box-shadow:var(--color-btn-focus-shadow); }
  64. .ghwr-items .btn-panel button:hover, .ghwr-items .btn-panel button:focus {
  65. border-color:#eee; box-shadow:#eee; }
  66. .ghwr-last-checked button:not([aria-checked="true"]) { border-color:#0000;
  67. background:#0000; }
  68. .ghwr-items button[data-type="check"]:not([aria-checked="true"]) {
  69. cursor:auto; }
  70. .btn-panel button { color:var(--color-state-hover-primary-text);
  71. filter:brightness(90%); }
  72. .btn-panel button:hover, .btn-panel button:focus { filter:brightness(110%); }
  73. .ghwr-items li svg { pointer-events:none; fill:currentColor; }
  74. .ghwr-items li .btn[data-type="check"][aria-checked="false"] svg {
  75. visibility:hidden; }
  76. .ghwr-settings { padding:4px 8px 4px 16px; }
  77. .ghwr-settings input[type="number"] { width:5em; }
  78. .ghwr-items { --color-tooltip-bg:#343434; }
  79. .ghwr-items { width:50vw; min-width:500px; }
  80. @media (max-width:768px) { .ghwr-items { width:100vw; min-width:unset; }
  81. .hide-small { display:none; } }
  82. @media (max-width:1260px) { .ghwr-items { width:75vw; } }`
  83. );
  84.  
  85. let token = GM_getValue("github-token");
  86. let timer;
  87. let timer2;
  88. let focus;
  89. let options;
  90. let wrap = null;
  91. let showSettings = !token;
  92.  
  93. const encodedChars = /[&"'<>\n]/g;
  94. const sanitize = text => text.replace(
  95. encodedChars,
  96. r => `&#${r.charCodeAt(0)};`
  97. );
  98.  
  99. const timeElement = time =>
  100. `<relative-time datetime="${time}" class="no-wrap"></relative-time>`;
  101.  
  102. const watcherIcon = `
  103. <svg class="octicon octicon-spy" width="20" height="20" viewBox="0 0 24 24">
  104. <path d="M14.8 4c-1.3-.2-2.5 1.2-3.8.5-1-.6-2.9-.8-3.7.4C6.2 6.3 6.2 8.3 5.6 10c-.5 1.1-2 .3-2.8 1-1 .6-.5 2 .3 2.5.6.6 1.7.5 2 1.2.3.4 1.1 1.4.4 1.6l-4.1 2 3.1 5.3 1-.6-2.6-4.3 3.3-1.5c.9 2.1 2 4.2 4 5.5 1.6 1 3.7.2 4.8-1.1a12 12 0 002.5-4.2l3.7 1.6-2.7 4.3.9.6 3.4-5.4-5-2c.5-.6.3-1.8 1-2 1.2-.3 2.6-1 2.8-2.3 0-1.5-1.7-1.3-2.7-1.5-.5-1-.6-2-1-3-1-2.3-1.7-3.6-3-3.7zm.5 1c1.3 1 1.5 2.5 1.9 3.9.3 1 1 2.4-.6 2.4-2 .7-4.2.9-6.3.7-1.3-.2-2.6-.7-3.8-1.3.7-1.8.6-3.9 1.9-5.4 1-.7 2.1.5 3.2.5 1.4.3 2.4-1 3.7-.7zm-9.4 6.5c1.8 1.3 4 1.8 6.3 1.7 1.2 0 2.5-.3 3.7-.5 1.1-.3 2.2-1 3.4-.8.8-.3 1.8.4.7 1-1.3.7-3 1-4.4 1.3-4 .7-8.1.1-11.8-1.4-.8-.4-.4-1 .4-1.1.5 0 1.1 0 1.7-.2zM7 14.8c1.1.3 3 .4 4.1.7-.9 1.1-3.3.7-4-.7zm9.6.1c-1 .8-2.1 2-3.8.6 1.2 0 2.7-.3 3.8-.6zm-10 .8c1 .8 2.1 2.2 5.3.4 1.7 1.5 3.1.8 4.9 0-.8 2.2-1.8 4.7-4.1 5.8-1.3.6-2.7-.3-3.4-1.3-1.2-1.5-2-3.2-2.7-5z"/>
  105. </svg>`;
  106.  
  107. const buttonCheck = (label, index) => `
  108. <button type="button" data-type="check" data-index="${index}" class="btn btn-sm tooltipped-e" aria-label="${label}">
  109. <svg class="octicon octicon-check" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
  110. <path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path>
  111. </svg>
  112. </button>`;
  113.  
  114. const buttonPanel = index => `
  115. <span class="btn-panel px-2">
  116. <button type="button" data-type="edit" data-index="${index}" class="ml-1" aria-label="Edit">
  117. <svg class="octicon octicon-pencil" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
  118. <path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path>
  119. </svg>
  120. </button>
  121. <button type="button" data-type="delete" data-index="${index}" class="ml-1 color-text-danger" aria-label="Delete">
  122. <svg class="octicon octicon-trashcan" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
  123. <path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>
  124. </svg>
  125. </button>
  126. </span>`;
  127.  
  128. const editRow = (item, index) => `
  129. <label class="d-flex ml-3 pl-1 pr-2">
  130. URL:&nbsp;
  131. <input type="text" class="form-control input-sm" value="${item.url || ""}" autofocus />
  132. </label>
  133. <button type="button" data-type="save" data-index="${index}" class="btn px-2">
  134. Save
  135. </button>
  136. <button type="button" data-type="cancel" data-index="${index}" class="btn px-2 ml-2">
  137. Cancel
  138. </button>`;
  139.  
  140. const errorRow = (item, index) => `
  141. <span role="alert" class="d-flex flex-items-center">
  142. <span class="error mr-1" aria-hidden="true"></span>
  143. Error: ${item.error || "Please enter a GitHub URL"}
  144. ${buttonPanel(index)}
  145. </span>`;
  146.  
  147. const itemRow = (item, index) => {
  148. const { org, repo, branch = "master", path } = getItemDataFromUrl(item);
  149. const message = sanitize(item.message?.trim() || "");
  150. return `
  151. ${buttonCheck("Clear this watched update", index)}
  152. <span title="${org}/${repo}...${path || ""}\n\n${message}">
  153. <span class="hide-small">Updated </span>
  154. ${timeElement(item.date)}
  155. &ndash;
  156. <a href="${item.url}">
  157. ${path?.split("/").slice(-1) ||
  158. `${org}/${repo}${branch === "master" ? "" : ` (${branch})`}`}
  159. </a>
  160. &ndash; by ${item.name} &ndash;
  161. ${message}
  162. </span>
  163. ${buttonPanel(index)}`;
  164. };
  165.  
  166. // Easier to maintain 3 separate than one big one
  167. const regexes = [
  168. /github.com\/(?<org>.+?)\/(?<repo>.+?)\/(.+?)\/(?<branch>.+?)\/(?<path>.+$)/,
  169. /github.com\/(?<org>.+?)\/(?<repo>.+?)\/(.+?)\/(?<branch>.+?)$/,
  170. /github.com\/(?<org>.+?)\/(?<repo>.+?)$/
  171. ];
  172. const getItemDataFromUrl = ({ url = "" }) => {
  173. const match = regexes.reduce((match, regex) => {
  174. const test = url.match(regex);
  175. if (!match.done && test) {
  176. return { done: true, ...test };
  177. }
  178. return match;
  179. }, { done: false });
  180. return match?.groups || {};
  181. };
  182.  
  183. const buildQuery = () => options.items.map((item, index) => {
  184. const { org, repo, branch, path } = getItemDataFromUrl(item);
  185. const pathQuery = (path || "") !== "" ? `, path: "${path}"` : "";
  186. return org && repo
  187. ? `item${index}: repository(name: "${repo}", owner: "${org}") {
  188. ref(qualifiedName: "${branch || "master"}") {
  189. target {
  190. ...on Commit{
  191. history(first:1${pathQuery}) {
  192. nodes {
  193. author {
  194. name
  195. date
  196. }
  197. message
  198. }
  199. }
  200. }
  201. }
  202. }
  203. }` : null;
  204. });
  205.  
  206. const calculatedInterval = (minutes = options.interval) => minutes * 60 * 1000;
  207.  
  208. const setLastCheckedTime = () => {
  209. options.lastChecked =
  210. token && options.items.filter(item => item.url).length > 0
  211. ? Date.now()
  212. : null;
  213. };
  214.  
  215. const setOptions = () => {
  216. GM_setValue("options", options);
  217. };
  218.  
  219. const getOptions = () => {
  220. options = Object.assign({}, {
  221. interval: 60, // check status every x minutes
  222. lastChecked: null,
  223. items: []
  224. }, GM_getValue("options") || {});
  225. };
  226.  
  227. const updateOptions = ({ data, errors }) => {
  228. $(".ghwr-details", wrap).classList.toggle("error", errors?.length);
  229. if (data || errors) {
  230. const emptyUrl = "Please enter a GitHub URL";
  231. // Update stored data
  232. options.items = options.items
  233. .map((item, index) => {
  234. const { author: { name, date } = {}, message } =
  235. data[`item${index}`]?.ref?.target?.history?.nodes[0] || {};
  236. const hasError = !name || !date
  237. ? { message: `${item.url ? `${item.url} is not a valid URL` : emptyUrl}` }
  238. : errors?.find(err => err.path[0] === `item${index}`);
  239.  
  240. return {
  241. ...item,
  242. name,
  243. message,
  244. date,
  245. unread: item.unread || date !== item.date,
  246. error: hasError ? hasError.message : "",
  247. editing: false
  248. };
  249. })
  250. // Sort by date to make the rendered list easy to read
  251. .sort((a, b) => new Date(b.date) - new Date(a.date));
  252. setLastCheckedTime();
  253. setOptions();
  254. showStatusIndicator();
  255. }
  256. };
  257.  
  258. // item list is resorted after every update; so find row by url
  259. const findRow = (index, selector) => {
  260. const url = options.items[index]?.url
  261. return url
  262. ? `.dropdown-item[data-url="${url}"] ${selector}`
  263. : null;
  264. }
  265.  
  266. const trailingSlashRegex = /\/$/g;
  267. const removeTrailingSlash = url => url.replace(trailingSlashRegex, "");
  268.  
  269. const handleClick = async ({ type, index } = {}) => {
  270. let value;
  271. if (type) {
  272. switch (type) {
  273. case "check":
  274. if (index === "all") {
  275. options.items = options.items.map(item => (
  276. { ...item, unread: false }
  277. ));
  278. focus = "button[data-type='refresh']";
  279. } else {
  280. options.items[index].unread = false;
  281. focus = findRow(index, "a");
  282. }
  283. break;
  284. case "add":
  285. options.items.push({ editing: true });
  286. break;
  287. case "delete":
  288. options.items.splice(index, 1);
  289. focus = findRow(index, "a");
  290. break;
  291. case "cancel":
  292. options.items[index].editing = false;
  293. focus = findRow(index, "button[data-type='edit']");
  294. break;
  295. case "edit":
  296. options.items[index].editing = true;
  297. focus = findRow(index, "input[type='text']");
  298. break;
  299. case "save":
  300. value = $("input", $$(".ghwr-item-entry")[index])?.value;
  301. if (value && value !== options.items[index].url) {
  302. options.items[index] = { url: removeTrailingSlash(value) };
  303. options.lastChecked = null;
  304. }
  305. options.items[index].editing = false;
  306. focus = "button[data-type='add']";
  307. break;
  308. case "refresh":
  309. options.lastChecked = null;
  310. focus = "button[data-type='refresh']";
  311. break;
  312. case "settings":
  313. showSettings = !showSettings;
  314. focus = "input[type='password']";
  315. break;
  316. case "save-settings":
  317. options.error = "";
  318. value = parseInt($("input[type='number']", wrap).value, 10);
  319. if (!isNaN(value) && value > 1 && value !== options.interval) {
  320. options.interval = value;
  321. options.lastChecked = null;
  322. }
  323. value = $("input[type='password']", wrap).value;
  324. if (value && value !== token) {
  325. token = value;
  326. GM_setValue("github-token", value);
  327. options.lastChecked = null;
  328. }
  329. showSettings = false;
  330. focus = "button[data-type='settings']";
  331. break;
  332. case "cancel-settings":
  333. showSettings = false;
  334. focus = "button[data-type='settings']";
  335. break;
  336. }
  337. setOptions();
  338. renderStatus();
  339. }
  340. }
  341.  
  342. const setFocus = () => {
  343. setTimeout(() => {
  344. if (focus) {
  345. $(focus, wrap)?.focus();
  346. focus = "";
  347. }
  348. });
  349. };
  350.  
  351. const getStatus = () => options.items
  352. .filter(item => item.unread)
  353. .length > 0;
  354.  
  355. const setStatus = () => {
  356. $$(".ghwr-item-entry button[data-type='check']", wrap)?.forEach(btn => {
  357. const indx = btn.dataset.index;
  358. const isChecked = options.items[indx]?.unread;
  359. btn.setAttribute("aria-checked", isChecked);
  360. btn.disabled = !isChecked;
  361. // toggle tooltips
  362. btn.classList.toggle("tooltipped", isChecked);
  363. });
  364. const isChecked = getStatus();
  365. const lastChkBtn = $(".ghwr-last-checked button[data-type='check']", wrap);
  366. lastChkBtn.setAttribute("aria-checked", isChecked);
  367. lastChkBtn.classList.toggle("tooltipped", isChecked);
  368. lastChkBtn.disabled = !isChecked;
  369. showStatusIndicator();
  370. };
  371.  
  372. const renderStatus = async () => {
  373. if (!$(".ghwr-details")?.open) {
  374. return;
  375. }
  376.  
  377. await fetchStatus();
  378. const list = options.items
  379. .map((item, index) => {
  380. let content = `<span class="ml-4">Unknown</span>${buttonPanel(index)}`;
  381. if (item.editing) {
  382. content = editRow(item, index);
  383. } else if (item.error || !item.url) {
  384. content = errorRow(item, index);
  385. } else if (item.url) {
  386. content = itemRow(item, index);
  387. }
  388. const rowClass = [
  389. "d-flex flex-items-center dropdown-item ghwr-item-entry mr-2 px-2",
  390. item.editing ? " editing" : "",
  391. ].join("")
  392. return `<li class="${rowClass}" data-url="${item.url}">${content}</li>`;
  393. })
  394. .join("");
  395.  
  396. const dateTime = token && options.lastChecked
  397. ? timeElement(new Date(options.lastChecked).toISOString())
  398. : "Unknown";
  399.  
  400. let error;
  401. if (token) {
  402. error = options.items.length
  403. ? options.error
  404. : "Please add some items to watch";
  405. } else {
  406. error = `
  407. <span class='text-gray-light'>
  408. Please
  409. <a href='https://github.com/settings/tokens/new'>
  410. add a GitHub personal access token
  411. </a>
  412. </span>`;
  413. }
  414.  
  415. $(".ghwr-items", wrap).innerHTML = `
  416. <ul>
  417. <li class="ghwr-header ml-2 text-gray-light">${watcherIcon} GitHub Watcher</li>
  418. <li class="dropdown-divider" role="separator"></li>
  419. ${list.length ? `
  420. <li class="ghwr-list-wrap"><ul>${list}</ul></li>
  421. <li class='dropdown-divider' role='separator'></li>` : ""}
  422. ${!token || showSettings ? `
  423. <li class="ghwr-settings text-gray-light d-flex flex-items-center border-bottom pr-4">
  424. <label class="ml-3 pl-1 pr-2">
  425. GitHub Token: <input type="password" class="form-control input-sm" value="${token || ""}" />
  426. </label>
  427. <label class="pl-4 pr-2" aria-label="Check interval in minutes">
  428. Interval (min):
  429. <input type="number" class="form-control input-sm" value="${options.interval}" min="5" max="9999" step="1" />
  430. </label>
  431. <button type="button" data-type="save-settings" class="btn px-2 ml-2">Save</button>
  432. <button type="button" data-type="cancel-settings" class="btn px-2 ml-2">Cancel</button>
  433. </li>` : ""}
  434. <li class="ghwr-last-checked text-gray-light d-flex flex-items-center flex-justify-between">
  435. <span class="d-flex flex-items-center flex-start">
  436. ${buttonCheck("Clear all watched updates", "all")}
  437. Last checked:&nbsp;${dateTime}
  438. <button type="button" data-type="refresh" class="btn px-2 ml-1 tooltipped tooltipped-e" aria-label="Check again now">
  439. <svg class="octicon octicon-sync" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
  440. <path d="M3.38 8A9.502 9.502 0 0112 2.5a9.502 9.502 0 019.215 7.182.75.75 0 101.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 00-9.5 5.452V4.75a.75.75 0 00-1.5 0V8.5a1 1 0 001 1h3.75a.75.75 0 000-1.5H3.38zm-.595 6.318a.75.75 0 00-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 001.5 0V15.5a1 1 0 00-1-1h-3.75a.75.75 0 000 1.5h2.37A9.502 9.502 0 0112 21.5c-4.446 0-8.181-3.055-9.215-7.182z"/>
  441. </svg>
  442. </button>
  443. <button type="button" data-type="settings" class="btn px-2 ml-1 tooltipped tooltipped-e" aria-label="Settings">
  444. <svg class="octicon octicon-gear" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
  445. <path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.075.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.09-.3.071L3.27 2.776c-.644-.177-1.392.02-1.82.63a7.977 7.977 0 00-.704 1.217c-.315.675-.111 1.422.363 1.891l.815.806c.05.048.098.147.088.294a6.084 6.084 0 000 .772c.01.147-.038.246-.088.294l-.815.806c-.474.469-.678 1.216-.363 1.891.2.428.436.835.704 1.218.428.609 1.176.806 1.82.63l1.103-.303c.066-.019.176-.011.299.071.213.143.436.272.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.09.3-.071l1.102.302c.644.177 1.392-.02 1.82-.63.268-.382.505-.789.704-1.217.315-.675.111-1.422-.364-1.891l-.814-.806c-.05-.048-.098-.147-.088-.294a6.1 6.1 0 000-.772c-.01-.147.039-.246.088-.294l.814-.806c.475-.469.679-1.216.364-1.891a7.992 7.992 0 00-.704-1.218c-.428-.609-1.176-.806-1.82-.63l-1.103.303c-.066.019-.176.011-.299-.071a5.991 5.991 0 00-.668-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z"></path>
  446. </svg>
  447. </button>
  448. ${error && `<span class="ghwr-api-error ml-2 color-text-danger" role="alert">${error}</span>`}
  449. </span>
  450. <span class="d-flex flex-items-center flex-end">
  451. <a href="https://github.com/Mottie/GitHub-userscripts/wiki/GitHub-watcher" class="px-2 border-0 tooltipped tooltipped-w" aria-label="Click to learn how to use GitHub watcher">
  452. <svg class="octicon" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="16" viewBox="0 0 14 16">
  453. <path d="M6 10h2v2H6V10z m4-3.5c0 2.14-2 2.5-2 2.5H6c0-0.55 0.45-1 1-1h0.5c0.28 0 0.5-0.22 0.5-0.5v-1c0-0.28-0.22-0.5-0.5-0.5h-1c-0.28 0-0.5 0.22-0.5 0.5v0.5H4c0-1.5 1.5-3 3-3s3 1 3 2.5zM7 2.3c3.14 0 5.7 2.56 5.7 5.7S10.14 13.7 7 13.7 1.3 11.14 1.3 8s2.56-5.7 5.7-5.7m0-1.3C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7S10.86 1 7 1z"></path>
  454. </svg>
  455. </a>
  456. <button type="button" data-type="add" class="btn px-2 ml-1 tooltipped tooltipped-w" aria-label="Add new watched item">
  457. <svg class="octicon octicon-plus" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
  458. <path fill-rule="evenodd" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 110 1.5H8.5v4.25a.75.75 0 11-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"></path>
  459. </svg>
  460. </button>
  461. </span>
  462. </li>
  463. </ul>`;
  464.  
  465. setStatus();
  466. setFocus();
  467. }
  468.  
  469. const showError = error => {
  470. setLastCheckedTime();
  471. options.error = error.errorMessage || error.message;
  472. setOptions();
  473. }
  474.  
  475. const showStatusIndicator = () => {
  476. // show unread indicator
  477. const indicator = $(".ghwr-item-status", wrap);
  478. const hasError = options.items.some(item => item.error);
  479. indicator.classList.toggle("unread", hasError || getStatus());
  480. indicator.classList.toggle("error", hasError);
  481. };
  482.  
  483. const shouldFetch = () => {
  484. getOptions();
  485. const hasItems = options.items.filter(item => item.url).length > 0;
  486. const needsCheck = !options.lastChecked ||
  487. options.lastChecked + calculatedInterval() < Date.now();
  488. return !!token && hasItems && needsCheck;
  489. };
  490.  
  491. const fetchStatus = () => {
  492. getOptions();
  493. if (shouldFetch()) {
  494. return fetch(
  495. "https://api.github.com/graphql",
  496. {
  497. method: "POST",
  498. headers: {
  499. "Content-Type": "application/json",
  500. "Authorization": `bearer ${token}`
  501. },
  502. body: JSON.stringify({ query: `{${buildQuery()}}` }),
  503. })
  504. .then(res => res.json())
  505. .then(res => {
  506. if (res.message) {
  507. throw new Error(res.message);
  508. }
  509. options.error = "";
  510. return updateOptions(res);
  511. })
  512. .catch(err => showError(err));
  513. }
  514. return Promise.resolve();
  515. };
  516.  
  517. const init = async () => {
  518. getOptions();
  519. const header = $(".Header .notification-indicator");
  520. if (header && !$(".ghwr-header-notification")) {
  521. wrap = make({
  522. el: "div",
  523. className: "Header-item position-relative d-md-flex ghwr-header-notification",
  524. html: `
  525. <details class="details-overlay details-reset ghwr-details">
  526. <summary class="Header-link" aria-label="see watched items" aria-haspopup="menu" role="button">
  527. <span class="ghwr-item-status"></span>
  528. ${watcherIcon}
  529. <span class="dropdown-caret"></span>
  530. </summary>
  531. <details-menu class="dropdown-menu dropdown-menu-sw ghwr-items" role="menu">
  532. <div class="d-flex flex-justify-center py-md-2">
  533. <img src="https://github.githubassets.com/images/spinners/octocat-spinner-32.gif" width="32" alt="">
  534. </div>
  535. </details-menu>
  536. </details>`
  537. });
  538. header.closest(".Header-item")?.before(wrap);
  539. on($(".ghwr-details", wrap), "toggle", () => {
  540. renderStatus();
  541. });
  542. on($(".ghwr-items", wrap), "click", event => {
  543. const { target } = event;
  544. if (target.type === "button") {
  545. event.preventDefault();
  546. handleClick(target.dataset);
  547. } else if (target.type === "text") {
  548. event.preventDefault();
  549. }
  550. });
  551. await fetchStatus();
  552. showStatusIndicator();
  553. }
  554. }
  555.  
  556. function setTimer() {
  557. clearTimeout(timer);
  558. clearInterval(timer2);
  559. timer = setTimeout(async () => {
  560. if (shouldFetch()) {
  561. setLastCheckedTime();
  562. await fetchStatus();
  563. }
  564. renderStatus();
  565. setTimer();
  566. }, calculatedInterval());
  567. timer2 = setInterval(() => {
  568. // Update indicator every 5 minutes because of multiple tabs
  569. getOptions();
  570. showStatusIndicator();
  571. }, calculatedInterval(5));
  572. }
  573.  
  574. getOptions();
  575. init();
  576. setTimer();
  577.  
  578. on(document, "ghmo:container", init);
  579.  
  580. })();