Hackernews Modern

Improved mobile usability and modern styling for Hackernews

  1. // ==UserScript==
  2. // @name Hackernews Modern
  3. // @namespace sagiegurari
  4. // @version 1.9
  5. // @author Sagie Gur-Ari
  6. // @description Improved mobile usability and modern styling for Hackernews
  7. // @homepage https://github.com/sagiegurari/userscripts-hackernews
  8. // @supportURL https://github.com/sagiegurari/userscripts-hackernews/issues
  9. // @match https://news.ycombinator.com/*
  10. // @match https://hckrnews.com/*
  11. // @match https://hackerweb.app/*
  12. // @grant none
  13. // @license MIT License
  14. // ==/UserScript==
  15.  
  16. (function run() {
  17. 'use strict';
  18.  
  19. const isAndroid = navigator.userAgent.toLowerCase('android') !== -1;
  20. const isEmulator = isAndroid && !navigator.userAgentData.mobile;
  21. const mobileOrEmulator = isEmulator || navigator.userAgentData.mobile;
  22.  
  23. const isDebug = isEmulator;
  24. const logDebug = param => {
  25. console.log('[DEBUG]', param);
  26. };
  27.  
  28. const element = document.createElement('style');
  29. element.type = 'text/css';
  30. document.head.appendChild(element);
  31. const styleSheet = element.sheet;
  32.  
  33. const ycombinatorDomain = window.location.hostname.indexOf('.ycombinator.com') !== -1;
  34. const hckrnewsDomain = !ycombinatorDomain && window.location.hostname.indexOf('hckrnews.com') !== -1;
  35. const hackerwebDomain = !ycombinatorDomain && window.location.hostname.indexOf('hackerweb.app') !== -1;
  36.  
  37. const articlePage = (ycombinatorDomain && window.location.search.indexOf('id=') !== -1) || (hackerwebDomain && window.location.href.indexOf('/#/item/') !== -1);
  38.  
  39. logDebug({
  40. platform: {
  41. isAndroid,
  42. isEmulator,
  43. mobileOrEmulator
  44. },
  45. isDebug,
  46. articlePage,
  47. domain: {
  48. ycombinatorDomain,
  49. hckrnewsDomain,
  50. hackerwebDomain
  51. }
  52. });
  53.  
  54. const addRules = (rules) => {
  55. rules.forEach(cssRule => {
  56. styleSheet.insertRule(cssRule, styleSheet.cssRules.length);
  57. });
  58. };
  59.  
  60. const cssRules = [
  61. // defaults
  62. '.subtext .age a[href^="item"] { color: #828282; }',
  63.  
  64. // colors
  65. '#hnmain tr:first-child td, .comment-tree { background-color: #333; }',
  66. 'html, body, #hnmain, #hnmain table.itemList tr:first-child td { background-color: #222; }',
  67. 'a:link, .subtext a[href^="item"]:not(:visited), a:link.togg.clicky, .commtext, .comment-tree a[rel="nofollow"], .comment-tree .reply a { color: #eee; }',
  68. '.visited a.titlelink, .visited a:link, .visited .subtext a[href^="item"] { color: #888; }',
  69. '.morelink { text-align: center; display: block; margin: 10px 40px 10px 0; background-color: #af4000; font-weight: bold; padding: 10px; }',
  70. ];
  71.  
  72. // if mobile or emulator
  73. if (mobileOrEmulator) {
  74. cssRules.push(...[
  75. // styles
  76. '.pagetop { font-size: 16pt; }',
  77. '.title { font-size: 14pt; }',
  78. '.comhead { font-size: 12pt; }',
  79. '.subtext { font-size: 0; padding: 5px 0; }',
  80. '.subtext span { padding: 0 2px; }',
  81. '.subtext span, .subtext a:not([href^="item"]), .subtext .age a[href^="item"] { font-size: 12pt; text-decoration: none; }',
  82. '.subtext a[href^="item"] { font-size: 14pt; text-decoration: underline; }',
  83. '.subtext a[href^="hide"] { display: none; }',
  84. '.default { font-size: 12pt }',
  85. ]);
  86. }
  87.  
  88. if (hckrnewsDomain) {
  89. cssRules.push(...[
  90. 'body, a:hover, a, .points, .comments { color: #eee; }',
  91. 'body .entries a:hover, body .nav > li > a:hover { background-color: #333; }',
  92. '.form-actions { background-color: #222 }',
  93. ]);
  94. } else if (hackerwebDomain && articlePage) {
  95. cssRules.push(...[
  96. '.view > header, body header, body .grouped-tableview, .post-content, body .view.view.view section { background-color: #333; }',
  97. 'body .view .post-content pre, body .view section.comments pre { background-color: #222 }',
  98. 'body .view .post-content header h1, body p, body pre, .view section.comments button.comments-toggle, body li { color: #eee; }',
  99. '.view section.comments button.comments-toggle, .view section.comments button.comments-toggle:hover { background-color: #555 }',
  100. ]);
  101. }
  102.  
  103. addRules(cssRules);
  104.  
  105. if (articlePage) {
  106. // collapse non top comments
  107. document.querySelectorAll('.ind:not([indent="0"])').forEach(topCommentIndent => {
  108. topCommentIndent.parentElement.querySelectorAll('.togg.clicky').forEach(toggle => toggle.click());
  109. });
  110.  
  111. // remove root/next/prev links
  112. addRules([
  113. '.navs .clicky:not(.togg) { display: none; }',
  114. ]);
  115. } else {
  116. const storage = window.localStorage;
  117. if (storage && typeof storage.getItem === 'function') {
  118. const KEY = 'hn-cache-visited';
  119. const CACHE_LIMIT = 1000;
  120.  
  121. const readFromCache = () => {
  122. const listStr = storage.getItem(KEY);
  123.  
  124. if (!listStr) {
  125. return [];
  126. }
  127.  
  128. return listStr.split(',');
  129. };
  130. const writeToCache = (ids) => {
  131. if (!ids || !Array.isArray(ids) || !ids.length) {
  132. return;
  133. }
  134.  
  135. // add to start
  136. cache.unshift(...ids);
  137.  
  138. // remove duplicates
  139. const seen = {};
  140. cache = cache.filter(function (item) {
  141. if (seen[item]) {
  142. return false;
  143. }
  144.  
  145. seen[item] = true;
  146. return true;
  147. });
  148.  
  149. // trim
  150. const extraCount = cache.length - CACHE_LIMIT;
  151. if (extraCount) {
  152. cache.splice(cache.length - extraCount, extraCount);
  153. }
  154.  
  155. storage.setItem(KEY, cache.join(','));
  156. };
  157.  
  158. let cache = readFromCache();
  159.  
  160. const entryRowSelector = ycombinatorDomain ? 'tr.athing' : '.entry.row';
  161. const linkSelector = ycombinatorDomain ? 'tr.visited + tr' : '.entry.row .link.story';
  162.  
  163. // mark visited
  164. const markVisited = () => {
  165. document.querySelectorAll(entryRowSelector).forEach(element => {
  166. if (cache.indexOf(element.id) !== -1) {
  167. element.classList.add('visited');
  168. }
  169. });
  170.  
  171. document.querySelectorAll(linkSelector).forEach(element => {
  172. element.classList.add('visited');
  173. });
  174. };
  175. markVisited();
  176.  
  177. // listen to scroll and add to cache
  178. const markVisibleAsVisited = () => {
  179. const elements = document.querySelectorAll(`${entryRowSelector}:not(.visited)`);
  180.  
  181. let started = false;
  182. const ids = [];
  183. for (let index = 0; index < elements.length; index++) {
  184. const element = elements[index];
  185. const bounding = element.getBoundingClientRect();
  186. if (bounding.top >= 0 &&
  187. bounding.bottom <= window.innerHeight) {
  188. started = true;
  189. ids.push(element.id);
  190. } else if (started) {
  191. break;
  192. }
  193. }
  194.  
  195. if (ids.length) {
  196. writeToCache(ids);
  197. }
  198. };
  199. let timeoutID = null;
  200. document.addEventListener('scroll', () => {
  201. clearTimeout(timeoutID);
  202. timeoutID = setTimeout(markVisibleAsVisited, 25);
  203. }, {
  204. passive: true
  205. });
  206.  
  207. markVisibleAsVisited();
  208. }
  209. }
  210. }());