Old Reddit Highlight Unread Comments

On topic pages, show "X unread comments (Y total)"; on comment pages, highlight unread comments. Local storage only -- does not work across multiple computers.

// ==UserScript==
// @name           Old Reddit Highlight Unread Comments
// @description    On topic pages, show "X unread comments (Y total)"; on comment pages, highlight unread comments. Local storage only -- does not work across multiple computers.
// @author         Xiao
// @namespace      https://greasyfork.org/users/5802
// @match          https://old.reddit.com/*
// @grant          GM_setValue
// @grant          GM_getValue
// @grant          GM_listValues
// @grant          GM_deleteValue
// @version        1.0
// ==/UserScript==

/*
todo
- navegar pelo new pra não ter que usar o teclado
- selecionar os minutos quando eu der espaço passando pelo ':'
Features:
 * Replaces "10 comments" with "5 unread comments (10 total)"
 * Unread comments are highlighted for ease of skimming
 * You can navigate through unread comments with the following hotkeys:
   Alt+Q / Alt+W -or- Ctrl+Up arrow / Ctrl+Down arrow
Mix code from:
https://greasyfork.org/scripts/8937-reddit-unread-comment-helper-fork/
https://greasyfork.org/scripts/8029-reddit-highlight-new-comments/
And a little piece of:
https://addons.mozilla.org/firefox/addon/cozy-reddit/

*/
(function(){

    var DELETE_OLDER_THAN = 1000*60*60*24*30; // Items older than 30 days
    /* Shove an item into local storage */
    function getData(id) {
        var data = GM_getValue(id);
        if (data === undefined || data.substr(0, 1) != "{")
            return null;
        return JSON.parse(data);
    }
    /* Get an item out of local storage */
    function setData(id, data) {
		console.log('unread:');
		console.log(data);
        GM_setValue(id, JSON.stringify(data));
    }
    /* Delete old items out of local storage */
    function deleteOldItems() {
        var data = GM_getValue('_last_clean_time');
        if (data !== null && Date.now() - data < 1000*60*60*24) { // Cleanup every 24 hours
            return;
        }
        var row;
        for (var key in GM_listValues()) {
            data = GM_getValue(key);
            if (data === undefined || data.substr(0, 1) != "{") {
                continue;
            }
            row = JSON.parse(data);
            if (Date.now() - row.t < DELETE_OLDER_THAN) {
                continue;
            }
            GM_deleteValue(key);
        }
        GM_setValue('_last_clean_time', Date.now());
    }
	/* Cozy Reddit */
	 function calculateCommentTop (comment) {
        var curTopPos = 0;
        if (comment.offsetParent) {
            do {
                curTopPos += comment.offsetTop;
            } while (comment = comment.offsetParent);

            return [curTopPos - 10];
        }
    }
    /* Get sorted array of unread comments offsets. */
    function getCommentsOffsetTop() {
        var comments = document.getElementsByClassName('unreadHighlighted');
        var arr = new Array(comments.length);
        for (var i = 0; i < comments.length; i++) {
            arr[i] = calculateCommentTop(comments[i]);
        }
        arr.sort(function(a, b) {
            return a - b;
        });
        return arr;
    }
    /* Jump to the next new comment. */
    function jumpToNextComment() {
        var unread = getCommentsOffsetTop();
        var scrollUnread;
        var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
        for (var i = 0; i < unread.length; i++) {
            scrollUnread = unread[i];
            if (scrollUnread > scrollTop) {
                window.scrollTo(0, scrollUnread);
                break;
            }
        }
    }
    /* Jump to the previous new comment. */
    function jumpToPrevComment() {
        var unread = getCommentsOffsetTop().reverse();
        var scrollUnread;
        var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
        for (var i = 0; i < unread.length; i++) {
            scrollUnread = unread[i];
            if (scrollUnread < scrollTop) {
                window.scrollTo(0, scrollUnread);
                break;
            }
        }
    }

    /*
    Handle a list page, adding "n unread comments" links etc.
    */
    function handleListPage() {
        var snap = document.evaluate("//a" +
                "[contains(concat(' ', normalize-space(@class), ' '), ' comments ')]" +
                "[contains(@href, '/comments')]",
            document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);

        var b36tid, row, match, comments, seen, newcomments, newlink;
        for(var elm = null, i = 0; (elm = snap.snapshotItem(i)); i++) {
            match = elm.firstChild.nodeValue.match(/(\d+) (comment|comentário)/);
            // No comments; bail early.
            if (!match)
                continue;

            comments = match[1];

            // Alphanumeric base-36 id, like "1lp5".
            b36tid = elm.getAttribute("href").match(/\/comments\/([^\/]+)/)[1];
            row = getData(b36tid);

            seen = row ? row.c : 0;

            newcomments = comments - seen;
            // Can be negative if comments are deleted.
            if (newcomments < 0) newcomments = 0;

            newlink = elm.cloneNode(false);

            var cstring = "unread comment" + (newcomments != 1 ? "s" : "");

            if (newcomments > 0) {
				nc = document.createElement('span');
				nc.style.color = "#333";
				nc.textContent = newcomments + " " + cstring;
				elm.textContent = comments > newcomments ? ' (' + comments + ' total)' : '';
				elm.insertBefore(nc, elm.childNodes[0]);
			}
        }
    }

    /*
    Handle a comments page: highlight new comments, save the ID of the highest
    comment, etc.
    */
    function handleCommentsPage() {
        var url = document.location.href.split("#");
        var frag = url.length > 1 ? url[1] : false;
        var b36tid = url[0].match(/\/comments\/([^\/]+)/)[1];

        var row = getData(b36tid);

        var partida, max_cid = 0, newmax = 0;
        if (row) {
            newmax = max_cid = row.m;
			partida = row.t;
        } else {
			partida = Date.parse(document.getElementById('siteTable').getElementsByClassName('tagline')[0].getElementsByTagName('time')[document.getElementById('siteTable').getElementsByClassName('tagline')[0].getElementsByTagName('time').length - 1].getAttribute('datetime'));
		}

        var comments, i, split, b36cid, cid;
        comments = document.getElementsByClassName('comment');
        for(i=0; i<comments.length; i++) {
            split = comments[i].className.split("_");
            if(split.length == 2)
            {
                b36cid = split[1].split(' ')[0].substr(1);
                cid = parseInt(b36cid, 36);
                if (cid > max_cid) {
					comments[i].getElementsByClassName("entry")[0].parentElement.classList.add('unreadHighlighted');
					comments[i].getElementsByClassName("entry")[0].parentElement.setAttribute('style', HNC.generate_comment_style(Date.parse(comments[i].getElementsByClassName('tagline')[0].getElementsByTagName('time')[comments[i].getElementsByClassName('tagline')[0].getElementsByTagName('time').length - 1].getAttribute('datetime')), partida));
                    if (cid > newmax) {
                        newmax = cid;
                    }
                }
            }
        }

		if (getData(document.location.href.split("#")[0].match(/\/comments\/([^\/]+)/)[1]))
			ui.create_comment_highlighter(getData(document.location.href.split("#")[0].match(/\/comments\/([^\/]+)/)[1]).t, 0);
		else
			ui.create_comment_highlighter(Date.parse(document.getElementById('siteTable').getElementsByClassName('tagline')[0].getElementsByTagName('time')[document.getElementById('siteTable').getElementsByClassName('tagline')[0].getElementsByTagName('time').length - 1].getAttribute('datetime')), 1);

		if (row)
			setTimeout(jumpToNextComment, 250);

        comments = document.getElementsByClassName('comments')[0].innerHTML.match(/\b\d+\b/);
        if (comments && comments[0] > 0) {
            setData(b36tid, {"m": newmax, "c": comments, "t": Date.now()});
        }
    }

	let HNC = {
		highlight: function (since) {
			let comments = document.getElementsByClassName('comment'),
				username
			;

			if (document.body.classList.contains('loggedin')) {
				username = document.getElementsByClassName('user')[0].firstElementChild.textContent;
			}

			for (let comment of comments) {
				/* skip removed or deleted comments */
				if (comment.classList.contains('deleted') || comment.classList.contains('spam')) {
					continue;
				}

				/* skip our own comments */
				let author = comment.getElementsByClassName('author')[0].textContent;
				if (username && username == author) {
					continue;
				}

				/* select original or edited comment time */
				let times = comment.getElementsByClassName('tagline')[0].getElementsByTagName('time'),
					time  = Date.parse(times[times.length - 1].getAttribute('datetime'))
				;

				/* add styles */
				if (time > since) {
					comment.getElementsByClassName("entry")[0].parentElement.classList.add('unreadHighlighted');
					comment.getElementsByClassName("entry")[0].parentElement.setAttribute('style', this.generate_comment_style(time, since));
				}
			}
		},

		reset_highlighting: function () {
			let comments = document.getElementsByClassName('unreadHighlighted');

			for (let i = comments.length; i > 0; i--) {
				let comment = comments[i - 1];
				comment.classList.remove('unreadHighlighted');
				comment.removeAttribute('style');
			}
		},

		generate_comment_style: function (comment_time, since) {
			let style = 'background-color: %color !important;\npadding: 0 5px;';

			style = style.replace(/\s+/g, ' ');
			style = style.replace(/%color/g, this.get_color(Date.now() - comment_time, Date.now() - since));

			return style;
		},

		get_color: function (comment_age, highlighting_since) {
			let time_diff = 1 - comment_age / highlighting_since,
				color_newer = tinycolor('hsl(214, 16, 9').toHsl(), // hsl(210, 16.7%, 9.4%)
				color_older = tinycolor('hsl(214, 16, 9').toHsl()
			;

			let color_final = tinycolor({
				h: color_older.h + (color_newer.h - color_older.h) * time_diff,
				s: color_older.s + (color_newer.s - color_older.s) * time_diff,
				l: color_older.l + (color_newer.l - color_older.l) * time_diff,
			});

			return color_final.toHslString();
		},
	};

	data = {
		comment_highlighter: function () {/*
			<div class="title" style="line-height: 20px;">Highlight comments since:
				<select id="comment-visits">
					<option value="">no highlighting</option>
					<option value="custom">custom</option>
				</select>
				<input id="hnc_custom_visit" type="text" value="00:00" pattern="\d+?:\d+?" style="text-align: center; display: none;" />
			</div>
		*/},

		get: function (name) {
			return this.function_to_string(this[name]);
		},

		/* original authored by lavoiesl, at https://gist.github.com/lavoiesl/5880516*/
		function_to_string: function (func, strip_leading_whitespace) {
			if (strip_leading_whitespace === undefined) {
				strip_leading_whitespace = 1;
			}

			let matches = func.toString().match(/function[\s\w]*?\(\)\s*?\{[\S\s]*?\/\*\!?\s*?\n([\s\S]+?)\s*?\*\/\s*\}/);

			if (!matches) {
				return false;
			}

			if (strip_leading_whitespace) {
				matches[1] = matches[1].replace(/^(\t| {4})/gm, '');
			}

			return matches[1];
		}
	};

	ui = {
		create_comment_highlighter: function (visit, first) {
			/* create element */
			let highlighter = document.createElement('div');

			highlighter.innerHTML = data.get('comment_highlighter');
			highlighter.classList.add('rounded', 'gold-accent', 'comment-visits-box');

			let commentarea      = document.getElementsByClassName('commentarea')[0],
				sitetable        = commentarea.getElementsByClassName('sitetable')[0],
				comment_margin   = window.getComputedStyle(sitetable.firstChild).getPropertyValue('margin-left'),
				gold_highlighter = document.getElementsByClassName('comment-visits-box')[0]
			;

			/* remove default comment highlighter */
			if (gold_highlighter) {
				gold_highlighter.parentNode.removeChild(gold_highlighter);
			}

			/* properly place */
			highlighter.style.setProperty('margin-left', comment_margin);
			commentarea.insertBefore(highlighter, sitetable);

			/* generate visits */
			let select = document.getElementById('comment-visits');
			select.style.textAlignLast = 'center';

			let option = document.createElement('option');
			option.textContent = (Math.floor(Math.floor((Date.now() - visit) / 1000) / 3600) < 10 ? '0' : '') + Math.floor(Math.floor((Date.now() - visit) / 1000) / 3600) + ':' + (Math.floor((Math.floor((Date.now() - visit) / 1000) % 3600) / 60) < 10 ? '0' : '') + Math.floor((Math.floor((Date.now() - visit) / 1000) % 3600) / 60);
			option.value = visit;
			select.appendChild(option);
			select.children[2].setAttribute('selected', '');

			if (!first) {
				let visit0 = Date.parse(document.getElementById('siteTable').getElementsByClassName('tagline')[0].getElementsByTagName('time')[document.getElementById('siteTable').getElementsByClassName('tagline')[0].getElementsByTagName('time').length - 1].getAttribute('datetime'));
				let option2 = document.createElement('option');
				option2.textContent = (Math.floor(Math.floor((Date.now() - visit0) / 1000) / 3600) < 10 ? '0' : '') + Math.floor(Math.floor((Date.now() - visit0) / 1000) / 3600) + ':' + (Math.floor((Math.floor((Date.now() - visit0) / 1000) % 3600) / 60) < 10 ? '0' : '') + Math.floor((Math.floor((Date.now() - visit0) / 1000) % 3600) / 60);
				option2.value = visit0;
				document.getElementById('comment-visits').appendChild(option2);
			}


			// add listeners
			select.addEventListener('change', this.update_highlighting);

			let custom = document.getElementById('hnc_custom_visit');
			custom.style.setProperty('width', (select.getBoundingClientRect().width) + 'px');
			custom.addEventListener('keydown', this.custom_visit_key_monitor);
			custom.addEventListener('blur', this.set_custom_visit);

			this.custom_pos = 0;
		},

		update_highlighting: function (event) {
			/* no highlighting */
			if (event.target.value == '') {
				HNC.reset_highlighting();
			}

			/* custom */
			else if (event.target.value == 'custom') {
				document.getElementById('comment-visits').style.setProperty('display', 'none');
				let custom = document.getElementById('hnc_custom_visit');
				custom.style.removeProperty('display');
				custom.focus();
				custom.setSelectionRange(0, 2);
			}

			/* previous visit */
			else {
				HNC.reset_highlighting();
				HNC.highlight(parseInt(event.target.value, 10));
				jumpToNextComment();
			}
			event.target.blur();
		},

		custom_visit_key_monitor: function (event) {
			if (event.altKey || event.ctrlKey || (event.shiftKey && event.key != 'Tab')) {
				return;
			}
			if (event.key == 'Tab') {

				let match = event.target.value.match(/^(\d+?:)\d+?$/);

				if (match) {
					if (event.shiftKey) {
						ui.custom_pos--;
					}
					else {
						ui.custom_pos++;
					}

					if (ui.custom_pos % 2 == 0) {
						event.target.setSelectionRange(0, match[1].length - 1);
					}
					else {
						event.target.setSelectionRange(match[1].length, match[0].length);
					}

					event.preventDefault();
					event.stopPropagation();
				}
			}
			else if (event.key == 'Enter') {
				event.target.blur();
				event.preventDefault();
				event.stopPropagation();
			}
		},

		set_custom_visit: function (event) {
			let select = document.getElementById('comment-visits'),
				match  = event.target.value.match(/^(\d+?):(\d+?)$/)
			;

			if (match) {
				let option  = document.createElement('option'),
					hours   = parseInt(match[1], 10),
					minutes = parseInt(match[2], 10),
					visit   = Date.now() - (hours * 60 + minutes) * 60 * 1000
				;

				option.value = visit;
				option.textContent = (hours < 10 ? '0' : '') + hours + ':' + (minutes < 10 ? '0' : '') + minutes;

				select.add(option, 2);
				select.selectedIndex = 2;
			}
			else {
				select.selectedIndex = 0;
			}

			let change = new Event('change');
			select.dispatchEvent(change);

			event.target.value = '00:00';
			event.target.style.setProperty('display', 'none');
			select.style.removeProperty('display');
		}
	};

    if (document.location.href.match(/\/comments(\/|\?|#|$)/)) {
        if (document.location.href.match(/\/comments\/[^\/?#]+(\/([^\/?#]+\/?)?)?(\?|#|$)/)) {
            deleteOldItems();
			handleCommentsPage();

            document.addEventListener('keydown', function(e) {
                // Alt+Q
                if (e.keyCode == "Q".charCodeAt(0) && !e.shiftKey && !e.ctrlKey && e.altKey && !e.metaKey) {
                    jumpToPrevComment();
					e.preventDefault();
                    return false;
                }
                // Alt+W
                if (e.keyCode == "W".charCodeAt(0) && !e.shiftKey && !e.ctrlKey && e.altKey && !e.metaKey) {
                    jumpToNextComment();
					e.preventDefault();
                    return false;
                }

                // Ctrl+up
                if (e.keyCode == 38 && !e.shiftKey && e.ctrlKey && !e.altKey && !e.metaKey) {
                    jumpToPrevComment();
					e.preventDefault();
                    return false;
                }
                // Ctrl+down
                if (e.keyCode == 40 && !e.shiftKey && e.ctrlKey && !e.altKey && !e.metaKey) {
                    jumpToNextComment();
					e.preventDefault();
                    return false;
                }
            }, false);
        }
    } else {
        handleListPage();
    }

})();