AO3: Badge for Unread Inbox Messages

puts a little notification badge in the menu for unread messages in your AO3 inbox

  1. // ==UserScript==
  2. // @name AO3: Badge for Unread Inbox Messages
  3. // @namespace https://gf.qytechs.cn/en/users/906106-escctrl
  4. // @version 3.0
  5. // @description puts a little notification badge in the menu for unread messages in your AO3 inbox
  6. // @author escctrl
  7. // @match https://*.archiveofourown.org/*
  8. // @license MIT
  9. // @require https://cdn.jsdelivr.net/npm/webix@11.1.0/webix.min.js
  10. // @require https://update.gf.qytechs.cn/scripts/491888/1355841/Light%20or%20Dark.js
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. /* global webix, $$, lightOrDark */
  15.  
  16. (async function() {
  17. 'use strict';
  18.  
  19. // utility to reduce verboseness
  20. const qs = (selector, node=document) => node.querySelector(selector);
  21. const qa = (selector, node=document) => node.querySelectorAll(selector);
  22.  
  23. const cfg = 'unread_inbox'; // name of dialog and localstorage used throughout
  24.  
  25. const defaults = [{key: "badgeInterval", val: 12},
  26. {key: "badgeColor", val: '#FFD700'},
  27. {key: "badgeIcon", val: 1},
  28. {key: "dashFilter", val: 1}];
  29. const storedConfig = getConfig('stored'); // returns object with key: value pairs
  30.  
  31. // first question: is the user logged in? if not, don't bother with any of this
  32. let linkDash = qs("#greeting p.icon a").href || "";
  33. if (linkDash === "") {
  34. localStorage.removeItem(cfg+'_count');
  35. localStorage.removeItem(cfg+'_date');
  36. return;
  37. }
  38. if ( linkDash.includes('?')) linkDash = linkDash.slice(0, linkDash.indexOf('?')); // fix on FAQ pages containing a searchParam
  39.  
  40. qs("head").insertAdjacentHTML('beforeend', `<style type="text/css"> a#inboxbadge .iconify { width: 1em; height: 1em; display: inline-block; vertical-align: -0.125em; }
  41. a#inboxbadge { display: block; padding: .25em .75em !important; text-align: center; float: left; margin: 0 1em; line-height: 1.286; height: 1.286em; }
  42. p.icon a { float: right; } #greeting #inboxbadge { background-color: ${storedConfig.badgeColor}; border-radius: .25em; } </style>`);
  43.  
  44. // build a new inbox link (filtered to unread)
  45. const linkInbox = linkDash + "/inbox?filters[read]=false&filters[replied_to]=all&filters[date]=desc&commit=Filter";
  46.  
  47. // the fun begins: on a page where we're seeing the unread msgs, we simply set the value
  48. let count = 0;
  49. let pageURL = new String(window.location);
  50. if (pageURL.includes(linkDash)) {
  51.  
  52. // grab unread msgs # from the sidebar
  53. count = (pageURL.includes("/inbox")) ? qs("div#dashboard li span.current").innerHTML : qs("div#dashboard a[href$='inbox']").innerHTML;
  54. count = count.match(/\d+/)[0];
  55.  
  56. // change sidebar inbox link as well to filtered
  57. if (storedConfig.dashFilter === 1 && !pageURL.includes("/inbox")) qs("div#dashboard a[href$='inbox']").href = linkInbox;
  58. }
  59. // on other pages, we check if the stored value is recent enough, otherwise we load it again
  60. else {
  61.  
  62. var timeStored = new Date(localStorage.getItem("unread_inbox_date") || '1970'); // the date when the storage was last refreshed
  63. var timeNow = createDate(0, 0, storedConfig.badgeInterval*-1, 0, 0, 0); // hours before that's max allowed
  64.  
  65. // if not recent enough, we have to start a background load; otherwise we use what was stored
  66. count = (timeStored < timeNow) ? await getUnreadCount(linkDash) : (localStorage.getItem('unread_inbox_count') || 0);
  67. }
  68.  
  69. // store the current value with the current date
  70. localStorage.setItem(cfg+'_count', count);
  71. localStorage.setItem(cfg+'_date', new Date());
  72.  
  73. // add a little round badge to the user icon in the menu (if there are unread emails)
  74. // icon SVGs from https://heroicons.com (MIT license Copyright (c) Tailwind Labs, Inc. https://github.com/tailwindlabs/heroicons/blob/master/LICENSE)
  75. const displaytext = (storedConfig.badgeIcon === 1) ? `<span class="iconify">
  76. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
  77. <path d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z" />
  78. <path d="M22.5 6.908V6.75a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z" /></svg></span>&nbsp;&nbsp;${count}`
  79. : `Inbox (${count})`;
  80. if (count != "0") qs("#greeting p.icon").insertAdjacentHTML('afterbegin', `<a id="inboxbadge" href="${linkInbox}" title="You have unread messages in you inbox">${displaytext}</a>`);
  81.  
  82. // function to grab the count of unread inbox messages if we're viewing a page that doesn't have a dashboard
  83. async function getUnreadCount(url) {
  84. try {
  85. let response = await fetch(url);
  86. if (!response.ok) throw new Error(`HTTP error: ${response.status}`); // the response has hit an error eg. 429 retry later
  87. else {
  88. let txt = await response.text();
  89. let parser = new DOMParser(); // Initialize the DOM parser
  90. let unread = qs("div#dashboard a[href$='inbox']", parser.parseFromString(txt, "text/html")); // Parse the text into HTML and grab the unread count
  91. if (!unread) throw new Error(`response didn't contain inbox count\n${txt}`); // the response has hit a different page e.g. a CF prompt
  92. else {
  93. unread = unread.innerHTML;
  94. return unread.match(/\d+/)[0];
  95. }
  96. }
  97. }
  98. catch(error) {
  99. // in case of any other JS errors
  100. console.log("[script] Badge for Unread Inbox Messages encountered an error", error.message);
  101. return '[ERROR]';
  102. }
  103. }
  104.  
  105. /***************** CONFIG DIALOG *****************/
  106.  
  107. // if no other script has created it yet, write out a "Userscripts" option to the main navigation
  108. if (qa('#scriptconfig').length === 0) {
  109. qa('#header nav[aria-label="Site"] li.search')[0] // insert as last li before search
  110. .insertAdjacentHTML('beforebegin', `<li class="dropdown" id="scriptconfig">
  111. <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
  112. <ul class="menu dropdown-menu"></ul></li>`);
  113. }
  114.  
  115. // then add this script's config option to navigation dropdown
  116. qs('#scriptconfig .dropdown-menu').insertAdjacentHTML('beforeend', `<li><a href="javascript:void(0);" id="opencfg_${cfg}">Unread Inbox Messages</a></li>`);
  117.  
  118. // NOTE: we try to not have to run through all the config dialog logic on every page load. it rarely gets opened once you have the config down
  119. // we initialize the configuration dialog only on first click (part of initialization is adding a listener for subsequent clicks)
  120. qs("#opencfg_"+cfg).addEventListener("click", createDialog, { once: true });
  121.  
  122. function createDialog(e) {
  123. // setting up the GUI CSS
  124. qs("head").insertAdjacentHTML('beforeend',`<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/webix@11.1.0/webix.min.css" type="text/css">`);
  125. qs("head").insertAdjacentHTML('beforeend',`<style type="text/css">/* webix stuff that's messed up by AO3 default skins */
  126. .webix_view {
  127. label { margin-right: unset; }
  128. button { box-shadow: unset; }
  129. }</style>`);
  130.  
  131. // if the background is dark, use a dark UI theme to match
  132. let dialogtheme = lightOrDark(getComputedStyle(qs("body")).getPropertyValue("background-color")) === "dark" ? "darkmode" : "";
  133. if (dialogtheme === "darkmode") qs("head").insertAdjacentHTML('beforeend',`<style type="text/css">/* switching webix colors to a dark mode if AO3 is dark */
  134. .webix_view.darkmode[view_id="${cfg}"],
  135. .webix_view.darkmode[view_id="${defaults[1].key+"Picker"}"] {
  136. --text-on-dark: #ddd;
  137. --handles-on-dark: #bbb;
  138. --highlight-on-dark: #0c6a82;
  139. --background-dark: #222;
  140. --border-on-dark: #555;
  141. --no-border: transparent;
  142. --button-dark: #333;
  143.  
  144. background-color: var(--background-dark);
  145. color: var(--text-on-dark);
  146. border-color: var(--border-on-dark);
  147.  
  148. &.webix_popup { border: 1px solid var(--border-on-dark); }
  149. .webix_win_head { border-bottom-color: var(--border-on-dark); }
  150. .webix_icon_button:hover::before { background-color: var(--highlight-on-dark); }
  151.  
  152. .webix_view.webix_form, .webix_view.webix_header, .webix_win_body>.webix_view { background-color: var(--background-dark); }
  153. .webix_secondary .webix_button, .webix_slider_box .webix_slider_right, .webix_el_colorpicker .webix_inp_static, .webix_color_out_text, .webix_switch_box { background-color: var(--button-dark); }
  154. .webix_primary .webix_button, .webix_slider_box .webix_slider_left, .webix_switch_box.webix_switch_on { background-color: var(--highlight-on-dark); }
  155. .webix_switch_handle, .webix_slider_box .webix_slider_handle { background-color: var(--handles-on-dark); }
  156. .webix_el_colorpicker .webix_inp_static, .webix_color_out_block, .webix_color_out_text,
  157. .webix_switch_handle, .webix_slider_box .webix_slider_handle { border-color: var(--border-on-dark); }
  158. .webix_switch_box, .webix_slider_box .webix_slider_left, .webix_slider_box .webix_slider_right { border-color: var(--no-border); }
  159. * { color: var(--text-on-dark); }
  160. }</style>`);
  161.  
  162. let dialogwidth = parseInt(getComputedStyle(qs("body")).getPropertyValue("width")); // parseInt ignores letters (px)
  163.  
  164. webix.ui({
  165. view: "window",
  166. id: cfg,
  167. css: dialogtheme,
  168. width: dialogwidth > 500 ? 500 : dialogwidth * 0.9,
  169. position: "top",
  170. head: "Unread Inbox Messages",
  171. close: true,
  172. move: true,
  173. body: {
  174. view:"form", id:cfg+"_form",
  175. elements:[ // alias for rows
  176. { // interval slider
  177. view: "slider", value:storedConfig.badgeInterval, min:1, max: 24, name:defaults[0].key, id:defaults[0].key,
  178. label:"Check for new messages every", labelWidth: "auto", labelPosition:"top",
  179. title: webix.template("#value# hours")
  180. },
  181. {},
  182. { // colorpicker
  183. view:"colorpicker", value:storedConfig.badgeColor, name:defaults[1].key, id:defaults[1].key, clear: true,
  184. label:"Pick your badge background color:", labelWidth: "auto",
  185. suggest: { type:"colorselect", body: { button:true }, id:defaults[1].key+"Picker", css: dialogtheme }
  186. },
  187. {},
  188. { // icon toggle
  189. view: "switch", value:storedConfig.badgeIcon, name:defaults[2].key, id:defaults[2].key,
  190. labelRight:"Show envelope icon on badge", labelWidth: "auto"
  191. },
  192. { // auto filter toggle
  193. view: "switch", value:storedConfig.dashFilter, name:defaults[3].key, id:defaults[3].key,
  194. labelRight:"Inbox link always filters to unread messages", labelWidth: "auto"
  195. },
  196. { cols:[ // buttonbar
  197. {
  198. view:"button", value:"Reset",
  199. click: function() { // revert all values to the default in the GUI and delete the stored config
  200. $$(cfg+"_form").setValues(getConfig('default'));
  201. localStorage.removeItem(cfg+'_conf');
  202. if (qs('#inboxbadge')) qs('#inboxbadge').style.background = defaults[1].val; // update the badge color without page reload
  203. $$(cfg).hide(); // close the dialog
  204. }
  205. },
  206. {
  207. view:"button", value:"Cancel",
  208. click: function() { $$(cfg).hide(); } // close the dialog
  209. },
  210. {
  211. view:"button", value:"Save", css:"webix_primary",
  212. click: function() {
  213. let selected = $$(cfg+"_form").getValues();
  214. localStorage.setItem(cfg+'_conf', JSON.stringify(selected));
  215. if (qs('#inboxbadge')) qs('#inboxbadge').style.background = selected.badgeColor; // update the badge color without page reload
  216. $$(cfg).hide(); // close the dialog
  217. }
  218. }
  219. ]}
  220. ]
  221. }
  222. }).show();
  223.  
  224. e.target.addEventListener("click", function(e) { $$(cfg).show(); }); // add a new event listener for reopening the dialog on subsequent clicks
  225. }
  226.  
  227. /****************** CONFIGURATION STORAGE and DEFAULTS ******************/
  228.  
  229. function getConfig(type) {
  230. let def = {
  231. [defaults[0].key]: defaults[0].val,
  232. [defaults[1].key]: defaults[1].val,
  233. [defaults[2].key]: defaults[2].val,
  234. [defaults[3].key]: defaults[3].val
  235. };
  236. if (type === 'default') return def;
  237. else if (type === 'stored') return JSON.parse(localStorage.getItem(cfg+'_conf')) ?? def;
  238. else return false;
  239. }
  240.  
  241. })();
  242.  
  243. // convenience function to be able to pass minus values into a Date, so JS will automatically shift correctly over month/year boundaries
  244. // thanks to Phil on Stackoverflow for the code snippet https://stackoverflow.com/a/37003268
  245. function createDate(secs, mins, hours, days, months, years) {
  246. var date = new Date();
  247. date.setFullYear(date.getFullYear() + years);
  248. date.setMonth(date.getMonth() + months);
  249. date.setDate(date.getDate() + days);
  250. date.setHours(date.getHours() + hours);
  251. date.setMinutes(date.getMinutes() + mins);
  252. date.setSeconds(date.getSeconds() + secs);
  253. return date;
  254. }

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址