HN: style unread content

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

Stan na 07-10-2021. Zobacz najnowsza wersja.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name        HN: style unread content 
// @description Save hashes of displayed comments locally and mark new ones when displayed for the first time
// @namespace   myfonj
// @match       https://news.ycombinator.com/*
// @grant       none
// @version     1.0.5
// @author      myfonj
// ==/UserScript==

// https://greasyfork.org/en/scripts/423969/versions/new

// kinda sorta configuration
const SELECTOR = '.commtext, .storylink';
const VIEWPORT_EXPOSITION_DURATION_UNTIL_READ = 900;
const CSS_CLASSES = { unread: 'new', read: 'read', old: 'old' };
// Styling. Very lame for now.
const CSS_STR = `
.${CSS_CLASSES.unread}  { border-right: 2px solid #3F36; display: block; padding-right: 1em; }
.${CSS_CLASSES.read} { border-right: 2px solid #0F03; display: block; padding-right: 1em; }
.${CSS_CLASSES.old}  { /* nothing */ }
`;
// base64 'SHA-1' digest hash = 28 characters; most probably ending with '=' always (?)
// could be used for splitting perhaps, but not used for it now.
const HASH_DIGEST_ALGO = 'SHA-1';
// localstorage key
const LS_KEY = 'displayed_hashes_' + HASH_DIGEST_ALGO;

// actual code, yo
document.head.appendChild(document.createElement('style')).textContent = CSS_STR;
// TODO mutation observer for client-side rendered pages
// not case for HN, but it will make this truly universal
const ELS_TO_WATCH = document.querySelectorAll(SELECTOR);
const MAP_EL_HASH = new WeakMap();
const MAP_EL_TIMEOUT = new WeakMap();
const SEEN_HASH_LIST = ()=>new Set((localStorage.getItem(LS_KEY) || '').split(','));
const VIEWPORT_ENTRY_CHECKER = (entry) => {
  const TGT = entry.target;
  if (entry.isIntersecting) {
    // entered viewport
    if (MAP_EL_TIMEOUT.get(TGT)) {
      // already measuring - quick re-entry
      return
    }
    // measure time in viewport
    MAP_EL_TIMEOUT.set(
      TGT,
      window.setTimeout(processVisibleEntry, VIEWPORT_EXPOSITION_DURATION_UNTIL_READ)
    );
  } else {
    // left viewport
    MAP_EL_TIMEOUT.delete(TGT);
  }
  function processVisibleEntry() {
    if (MAP_EL_TIMEOUT.get(TGT)) {
      // HA! STILL in viewport!
      // mark as read
      TGT.classList.remove(CSS_CLASSES.unread);
      TGT.classList.add(CSS_CLASSES.read);
      const NEW_LIST = SEEN_HASH_LIST();
      NEW_LIST.add(MAP_EL_HASH.get(TGT))
      MAP_EL_TIMEOUT.delete(TGT);
      // not interested in this element anymore
      VIEWPORT_OBSERVER.unobserve(TGT);
      // TODO move the persistence to window unload and/or blur event for fewer LS writes
      localStorage.setItem(
        LS_KEY,
        Array.from(NEW_LIST).join(',')
      );
    }
  }
}

// initialize single observer
const VIEWPORT_OBSERVER = new IntersectionObserver(
  (entries, observer) => {
    entries.forEach(_ => VIEWPORT_ENTRY_CHECKER(_));
  },
  {
    root: null,
    rootMargin: "-9%", // TODO use computed "lines" height here instead?
    threshold: 0
  }
);

const SEEN_ON_LOAD = SEEN_HASH_LIST();

// compute hash, look into list and mark and observe "new" items

ELS_TO_WATCH.forEach(async el => {
  const hash = await makeHash(el.textContent);
  if (SEEN_ON_LOAD.has(hash)) {
    el.classList.add(CSS_CLASSES.old);
    return;
  }
  el.classList.add(CSS_CLASSES.unread);
  MAP_EL_HASH.set(el, hash);
  VIEWPORT_OBSERVER.observe(el);
});

// string to base54 hash digest using native Crypto API
async function makeHash (input) {
  return btoa(
    String.fromCharCode.apply(
      null,
      new Uint8Array(
        await crypto.subtle.digest(
          HASH_DIGEST_ALGO,
          (new TextEncoder()).encode(input)
        )
      )
    )
  );
};


// add some links along "threads"

const threadsLink = document.querySelector('a[href^="threads"]');
const addLink = (key) => {
  const l = threadsLink.cloneNode(true);
  l.setAttribute('href', l.getAttribute('href').replace('threads', key));
  l.textContent = key;
  threadsLink.parentNode.insertBefore(l,threadsLink);
}
['upvoted','favorites'].forEach(addLink);