HN: style unread content

Save hashes of displayed comments locally and mark new ones when displayed for the first time

  1. // ==UserScript==
  2. // @name HN: style unread content
  3. // @description Save hashes of displayed comments locally and mark new ones when displayed for the first time
  4. // @namespace myfonj
  5. // @match https://news.ycombinator.com/*
  6. // @grant none
  7. // @version 1.1.3
  8. // @author myfonj
  9. // @run-at document end
  10. // ==/UserScript==
  11.  
  12. // https://greatest.deepsurf.us/en/scripts/423969/versions/new
  13.  
  14. /*
  15. * Changelog
  16. * 1.1.3 (2024-10-31) Age title lost the "Z" timezone, and gained some raw timestamp after space. Strange.
  17. * 1.1.2 (2024-10-03) Age title got the "Z" timezone, no need to imply it anymore.
  18. * 1.1.1 (2023-10-22) visualise age (=offset) original post date (= dotted line) and points × comments
  19. */
  20.  
  21. // kinda sorta configuration
  22. const WATCHED_ELEMENTS_SELECTOR = '.commtext, .storylink, .titlelink, .titleline > a';
  23. const VIEWPORT_EXPOSITION_DURATION_UNTIL_READ = 900;
  24. const CSS_CLASSES = {
  25. unread: 'new',
  26. read: 'read',
  27. old: 'old'
  28. };
  29. // Styling. Very lame for now.
  30. const CSS_STR = `
  31. .${CSS_CLASSES.unread} {
  32. border-right: 2px solid #3F36;
  33. display: block;
  34. padding-right: 1em;
  35. }
  36. .${CSS_CLASSES.read} {
  37. border-right: 2px solid #0F03;
  38. display: block;
  39. padding-right: 1em;
  40. }
  41. .${CSS_CLASSES.old} {
  42. opacity: 0.9;
  43. }
  44. /*
  45. visualise age (=offset) original post date (= dotted line) and points × comments
  46. */
  47. .subline {
  48. position: relative;
  49. }
  50. .subline::before ,
  51. .subline::after {
  52. position: absolute;
  53. right: 100%;
  54. bottom: 0;
  55. content: '';
  56. background-color: lime;
  57. width: calc(var(--points) * 1px);
  58. height: calc(var(--comments) * 1px);
  59. z-index: 1000000;
  60. /* 0min = 1 */
  61. /* 1min = .9 */
  62. /* 10min = .8 */
  63. --_minutes_old: calc(var(--age) / 1000 / 60);
  64. --_minutes_old2: calc(var(--age2) / 1000 / 60);
  65. --_o: calc( var(--_minutes_old) / 10 );
  66. opacity: .5;
  67. border: 1px solid red;
  68. transform: scale(.2) translatex(calc(var(--_minutes_old2) * -1px));
  69. transform-origin: bottom right;
  70. }
  71. .subline::after {
  72. height: 0px;
  73. width: calc(( var(--_minutes_old) - var(--_minutes_old2) )* 1px);
  74. border: none;
  75. border-bottom: 10px dotted lime;
  76. background-color: transparent;
  77. }
  78. `;
  79. // base64 'SHA-1' digest hash = 28 characters;
  80. // most probably ending with '=' always(?)
  81. const HASH_DIGEST_ALGO = 'SHA-1';
  82.  
  83. // local storage key
  84. const LS_KEY = 'displayed_hashes_' + HASH_DIGEST_ALGO;
  85.  
  86. // actual code, yo
  87. document.head.appendChild(document.createElement('style'))
  88. .textContent = CSS_STR;
  89. // TODO mutation observer for client-side rendered pages
  90. // not case for HN, but it will make this truly universal
  91.  
  92. const ELS_TO_WATCH = document.querySelectorAll(WATCHED_ELEMENTS_SELECTOR);
  93. /**
  94. * watched DOM node -> it's text content digest
  95. */
  96. const MAP_EL_HASH = new WeakMap();
  97. /**
  98. * watched DOM node -> time counter ID
  99. */
  100. const MAP_EL_TIMEOUT_ID = new WeakMap();
  101. /**
  102. * get all "read" digests
  103. */
  104. const GET_SEEN_HASHES_SET =
  105. () => new Set((localStorage.getItem(LS_KEY) || '').split(','));
  106. /**
  107. * intersection observer callback
  108. */
  109. const VIEWPORT_ENTRY_CHECKER = (entry) => {
  110. const TGT = entry.target;
  111. if (entry.isIntersecting) {
  112. // entered viewport
  113. if (MAP_EL_TIMEOUT_ID.get(TGT)) {
  114. // already measuring - quick re-entry
  115. return
  116. }
  117. // measure time in viewport
  118. MAP_EL_TIMEOUT_ID.set(
  119. TGT,
  120. window.setTimeout(
  121. processVisibleEntry,
  122. VIEWPORT_EXPOSITION_DURATION_UNTIL_READ
  123. )
  124. );
  125. } else {
  126. // left viewport
  127. MAP_EL_TIMEOUT_ID.delete(TGT);
  128. }
  129. function processVisibleEntry() {
  130. if (MAP_EL_TIMEOUT_ID.get(TGT)) {
  131. // HA! STILL in viewport!
  132. // mark as read
  133. TGT.classList.remove(CSS_CLASSES.unread);
  134. TGT.classList.add(CSS_CLASSES.read);
  135. const NEW_SET = GET_SEEN_HASHES_SET();
  136. NEW_SET.add(MAP_EL_HASH.get(TGT))
  137. MAP_EL_TIMEOUT_ID.delete(TGT);
  138. // not interested in this element anymore
  139. VIEWPORT_OBSERVER.unobserve(TGT);
  140. // TODO move the persistence to window unload and/or blur event for fewer LS writes
  141. localStorage.setItem(
  142. LS_KEY,
  143. Array.from(NEW_SET).join(',')
  144. );
  145. }
  146. }
  147. }
  148.  
  149. /**
  150. * just a single observer for all watched elements
  151. */
  152. const VIEWPORT_OBSERVER = new IntersectionObserver(
  153. (entries, observer) => {
  154. entries.forEach(_ => VIEWPORT_ENTRY_CHECKER(_));
  155. },
  156. {
  157. root: null,
  158. rootMargin: "-9%", // TODO use computed "lines" height here instead?
  159. threshold: 0
  160. }
  161. );
  162.  
  163. const SEEN_ON_LOAD = GET_SEEN_HASHES_SET();
  164.  
  165. // compute hash, look into list and mark and observe "new" items
  166.  
  167. ELS_TO_WATCH.forEach(async el => {
  168. const hash = await makeHash(el.textContent);
  169. if (SEEN_ON_LOAD.has(hash)) {
  170. el.classList.add(CSS_CLASSES.old);
  171. return;
  172. }
  173. el.classList.add(CSS_CLASSES.unread);
  174. MAP_EL_HASH.set(el, hash);
  175. VIEWPORT_OBSERVER.observe(el);
  176. });
  177.  
  178. /**
  179. * string to base64 hash digest using native Crypto API
  180. * @param {string} input
  181. */
  182. async function makeHash (input) {
  183. return btoa(
  184. String.fromCharCode.apply(
  185. null,
  186. new Uint8Array(
  187. await crypto.subtle.digest(
  188. HASH_DIGEST_ALGO,
  189. (new TextEncoder()).encode(input)
  190. )
  191. )
  192. )
  193. );
  194. };
  195.  
  196.  
  197. // unrelated add some links along "threads"
  198.  
  199. const threadsLink = document.querySelector('a[href^="threads"]');
  200. const addLink = (key) => {
  201. const l = threadsLink.cloneNode(true);
  202. l.setAttribute('href', l.getAttribute('href').replace('threads', key));
  203. l.textContent = key;
  204. threadsLink.parentNode.insertBefore(l,threadsLink);
  205. }
  206. ['upvoted','favorites'].forEach(addLink);
  207.  
  208. /*
  209. // also not closely related: create velocity rectangles
  210. // this time tailored to HN structure
  211. tr[id="<numbers>"]
  212. tr
  213. td[colspan="2"]
  214. td[class="subtext"]
  215. span[class="subline"]
  216. span[class="score"][id="score_<numbers>"] "<##> points"
  217. a[class="hnuser"]
  218. span[class="age"][title="<isodate>"]
  219. ...
  220. a[href="item?id=<numbers>"] "<##> comments"
  221. */
  222. const now = Date.now();
  223. Array.from(document.querySelectorAll('.subline'))
  224. .forEach(subline=>{
  225. const tr = subline.closest('tr');
  226. const age_el = subline.querySelector('.age');
  227. const age_title = age_el.getAttribute('title');
  228. const age_text = age_el.textContent;
  229. const age_ms = now - (new Date(age_title.split(' ')[0] + 'Z')).getTime();
  230. const age2_ms = textAgeToMS(age_text);
  231. subline.querySelector('.age').textContent += ' (' + (age_ms / 1000 / 60 / 60).toFixed(2) + 'h)';
  232. const points_count = (subline.querySelector('.score')?.textContent?.match(/\d+/)||['1'])[0] * 1;
  233. const comments_count = (subline.querySelector('& > a:last-child')?.textContent?.match(/\d+/)||['1'])[0] * 1;
  234. subline.style.setProperty('--age', age_ms);
  235. subline.style.setProperty('--age2', age2_ms);
  236. subline.style.setProperty('--points', points_count);
  237. subline.style.setProperty('--comments', comments_count);
  238. })
  239.  
  240.  
  241. function textAgeToMS(text) {
  242. const rx = /^([0-9]+)\s+(\S+)/;
  243. const match = text.match(rx);
  244. const secondsDurationsNames = {
  245. second: 1,
  246. minute: 1 * 60,
  247. hour: 1 * 60 * 60,
  248. day: 1 * 60 * 60 * 24,
  249. month: 1 * 60 * 60 * 24 * 30,
  250. year: 1 * 60 * 60 * 24 * 30 * 365,
  251. };
  252. const amount = Number(match[1]);
  253. const unit = match[2].replace(/s$/,'');
  254. const secondsPerUnit = secondsDurationsNames[unit];
  255. return 1000 * amount * secondsPerUnit;
  256. }
  257. function msToHours(ms) {
  258. return ms / 1000 / 60 / 60;
  259. }