// ==UserScript== // @name Highlight New Replies // @namespace http://veryroundbird.house // @version 2026-06-03 // @description highlights your threads where you're not the most recent reply. // @author Carly Smallbird // @match https://*.dreamwidth.org/* // @icon https://www.google.com/s2/favicons?sz=64&domain=dreamwidth.org // @require https://openuserjs.org/src/libs/sizzle/GM_config.js // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // ==/UserScript== (function() { 'use strict'; if (window.location.href.match(/https:\/\/[a-z\-]+\.dreamwidth.org\/(?:[0-9]+|(?:[0-9]{4}\/[0-9]{2}\/[0-9]{2}\/[a-z\-]+))\.html/)) { // checks that this is like, an actual entry // make the settings let gmc = new GM_config( { 'id': 'veryroundbirdhouse_newreplies', 'title': 'Settings', 'fields': { 'altJournals': { 'label': 'Alternate Journals', 'type': 'text', 'default': '', 'labelPos': 'above' }, 'newReplyText': { 'label': 'New Reply Text', 'type': 'text', 'default': 'โœจ NEW REPLIES โœจ', 'labelPos': 'above' }, 'markDoneText': { 'label': 'Mark Thread Done Text', 'type': 'text', 'default': 'โœ… Mark Thread Done', 'labelPos': 'above' }, 'acPassIndicator': { 'label': 'AC Passing Thread Indicator', 'type': 'text', 'default': '๐ŸŒŸ', 'labelPos': 'above' }, 'highlightColor': { 'label': 'Highlight Color', 'type': 'text', 'default': 'yellow', 'labelPos': 'above' }, 'showACInfo': { 'label': 'Show AC Info?', 'type': 'checkbox', 'default': true }, 'threadLengthThreshold': { 'label': 'Thread Length Threshold for AC', 'type': 'int', 'min': 0, 'default': 15, 'labelPos': 'above' }, 'threadLengthCountType': { 'label': 'How to Count Comments for AC', 'type': 'radio', 'options': ['Total Comments', 'Comments from Me'], 'default': 'Total Comments' }, 'showTeamThreads': { 'label': 'Show Team Thread Info?', 'type': 'checkbox', 'default': true }, 'teammateUsernames': { 'label': 'Teammate Usernames', 'type': 'text', 'default': '', 'labelPos': 'above' } }, 'events': { 'init': () => { main(); updateUI(); }, 'save': () => { updateUI(); } }, 'css': ` #veryroundbirdhouse_newreplies { font-size: 16px; font-family: 'Atkinson Hyperlegible', sans-serif; } #veryroundbirdhouse_newreplies .config_var { display: flex; gap: 10px; } #veryroundbirdhouse_newreplies .config_var br { display: none; } #veryroundbirdhouse_newreplies .config_var label { min-width: 200px; font-size: 14px; line-height: 16px; } #veryroundbirdhouse_newreplies label { display: block; white-space: nowrap; } #veryroundbirdhouse_newreplies [type="radio"] + label { display: inline-block; } #veryroundbirdhouse_newreplies input { font-family: 'Atkinson Hyperlegible', sans-serif; } ` }); // set up globals let readReplies = []; let linkList = []; let teamList = []; let passList = []; // get username from account links const username = document.querySelector('#account-links-text .ljuser').getAttribute('lj:user'); // create elements for jump links menu const jumpLinks = document.createElement('div'); const jumpDetails = document.createElement('details'); const jumpSummary = document.createElement('summary'); const jumpLinksOl = document.createElement('ol'); const jumpPassingDetails = document.createElement('details'); const jumpPassingSummary = document.createElement('summary'); const jumpPassingOl = document.createElement('ol'); const jumpTeamDetails = document.createElement('details'); const jumpTeamSummary = document.createElement('summary'); const jumpTeamOl = document.createElement('ol'); const settingsLink = document.createElement('a'); jumpSummary.textContent = "๐Ÿงต New Comments"; jumpDetails.append(jumpSummary); jumpDetails.append(jumpLinksOl); jumpLinks.append(jumpDetails); jumpPassingDetails.id = 'jump-ac-passing'; jumpPassingSummary.textContent = "โœ… AC Length OK"; jumpPassingDetails.append(jumpPassingSummary); jumpPassingDetails.append(jumpPassingOl); jumpTeamDetails.id = 'jump-team-threads'; jumpTeamSummary.textContent = "๐Ÿ‘ฏ Team Threads"; jumpTeamDetails.append(jumpTeamSummary); jumpTeamDetails.append(jumpTeamOl); jumpTeamDetails.style.display = "none"; jumpLinks.append(jumpPassingDetails); jumpLinks.append(jumpTeamDetails); settingsLink.textContent = "โš™๏ธ Settings"; settingsLink.href = "#"; settingsLink.addEventListener('click', (e) => { e.preventDefault(); gmc.open(); }); jumpLinks.append(settingsLink); jumpLinks.id = 'jump-links'; document.body.append(jumpLinks); const updateUI = () => { if (gmc.get('showTeamThreads', true)) { document.getElementById('jump-team-threads').style.display = "block"; } else { document.getElementById('jump-team-threads').style.display = "none"; } if (gmc.get('showACInfo', true)) { document.getElementById('jump-ac-passing').style.display = "block"; } else { document.getElementById('jump-ac-passing').style.display = "none"; } } const sliceNodeList = (id1, id2) => { let id1idx = null; let id2idx = null; const nodes = Array.from(document.querySelectorAll('.comment-thread')); for (let i = 0; i < nodes.length; i++) { if (!id1idx && nodes[i].querySelector('.dwexpcomment').id === `cmt${id1}`) id1idx = i; if (!id2idx && nodes[i].querySelector('.dwexpcomment').id === `cmt${id2}`) id2idx = i; if (id1idx !== null && id2idx !== null) break; } return nodes.slice(id1idx, id2idx+1); } const getCommentCount = (thread) => { return thread.filter((elt) => { if (elt.querySelector('.ljuser').textContent === username) return true; return false; }).length } const getParticipantList = (thread) => { const usernames = thread.map((elt) => { return elt.querySelector('.ljuser').textContent; }) return usernames.filter((un, i) => { if (usernames.indexOf(un) === i) return true; return false; }); } const isTeamThread = (thread) => { const participants = getParticipantList(thread); const teammates = getTeammates(); for (let i = 0; i < participants.length; i++) { if (teammates.indexOf(participants[i]) !== -1) { return true; } } return false; } const passesAc = (thread) => { if (gmc.get("threadLengthCountType", "Comments from Me") === "Comments from Me") { const myComments = thread.filter((elt) => { if (elt.querySelector('.ljuser').textContent === username) return true; return false; }); if (myComments.length >= gmc.get("threadLengthThreshold", 20)) return true; return false; } else if (gmc.get("threadLengthCountType", "Comments from Me") === "Total Comments") { if (thread.length >= gmc.get("threadLengthThreshold", 20)) return true; return false; } return false; } const checkThread = (cmt, topOfThread, prevComment) => { const startId = parseInt(topOfThread.querySelector('.comment').id.replace("comment-cmt", "")); const endId = prevComment ? parseInt(prevComment.querySelector('.comment').id.replace("comment-cmt", "")) : null; const threadComments = sliceNodeList(startId, endId); const commentCount = getCommentCount(threadComments); const participants = getParticipantList(threadComments); if (isTeamThread(threadComments)) teamList.push({id: startId, count: commentCount}); if (passesAc(threadComments)) { topOfThread.classList.add("ac-pass"); passList.push({id: startId, count: commentCount}); } // if this comment isn't ours and it's not in our ignored threads, highlight it if (prevComment && !prevComment.querySelector('.comment-wrapper').classList.contains(`poster-${username}`) && readReplies.indexOf(startId) === -1 && cmt !== topOfThread) { highlightCmt(topOfThread); linkList.push(startId); } } // add an item to the list of threads we're ignoring const addReadReply = (id) => { if (readReplies.indexOf(parseInt(id)) === -1) { readReplies.push(parseInt(id)); localStorage.setItem("readComments", JSON.stringify(readReplies)); } }; // adds UI elements to highlighted comments const highlightCmt = (top) => { const id = parseInt(top.querySelector('.comment').id.replace("comment-cmt", "")); const info = top.querySelector('.comment-info'); const markReadBtn = document.createElement('button'); const newCmtMarker = document.createElement('span'); let expandListener = null; markReadBtn.setAttribute('type', 'button'); markReadBtn.classList.add("markRead"); markReadBtn.textContent = gmc.get("markDoneText", "โœ… Mark Thread Done"); newCmtMarker.textContent = gmc.get("newReplyText", "โœจ NEW REPLIES โœจ"); newCmtMarker.classList.add('newComment'); top.classList.add('highlight'); const readListener = (e) => { e.preventDefault(); addReadReply(id); top.classList.remove("highlight"); newCmtMarker.remove(); markReadBtn.remove(); jumpLinks.querySelector(`a[href="#cmt${id}"]`).parentNode.remove(); if (expandListener) { removeEventListener('click', expandListener); } }; markReadBtn.addEventListener('click', readListener); if (top.querySelector('.partial')) { top.querySelector('.comment .inner').append(markReadBtn); expandListener = top.querySelector('a[onclick]').addEventListener('click', (_e) => { newCmtMarker.remove(); markReadBtn.remove(); const waitForExpansion = setTimeout(() => { if (top.querySelector('.comment-info h4')) { top.querySelector('.comment-info h4').append(newCmtMarker); top.querySelector('.comment-info h4').append(markReadBtn); markReadBtn.addEventListener('click', readListener); clearInterval(waitForExpansion); } }, 1000); }); } else { top.querySelector('.comment-info h4').append(markReadBtn); } }; const getTeammates = () => { const teammateUsernames = gmc.get('teammateUsernames', ''); return teammateUsernames !== '' ? teammateUsernames.split(",").map(x => x.trim()) : []; } const getAltJournals = () => { const altJournals = gmc.get('altJournals', ''); return altJournals !== '' ? altJournals.split(",").map(x => x.trim()) : []; } const getHighlightColor = () => gmc.get('highlightColor', ''); const getAcPassIndicator = () => gmc.get('acPassIndicator', ''); const main = async () => { // create styles for stuff we're adding to the UI const newStyles = document.createElement('style'); const teammates = getTeammates(); console.log(teammates); const altjournals = getAltJournals(); const highlightColor = getHighlightColor(); const acPassIndicator = getAcPassIndicator(); newStyles.setAttribute('type', 'text/css'); newStyles.id = 'new-replies-styles'; newStyles.textContent = ` .highlight .partial > .comment, .highlight .visible .comment-info { background-color: ${highlightColor} !important; } .ac-pass .partial > .comment .inner:before { content: "${acPassIndicator}"; margin-right: 5px; } .ac-pass .visible .comment-info { position: relative; } .ac-pass .visible .comment-info:after { content: "${acPassIndicator}"; position: absolute; top: 5px; right: 5px; font-size: 1.4em; } button.markRead { padding: 2px; margin-bottom: 0; font-size: .8em; } .comment .inner > button.markRead { margin-left: 5px; } #jump-links { position: fixed; bottom: 10px; left: 10px; padding: 5px; border: 1px gray solid; background-color: #FFFFFF66; max-height: 150px; max-width: 200px; overflow: auto; opacity: .5; z-index: 99; } #jump-links summary { cursor: pointer; list-style: none; } #jump-links > a { text-decoration: none; color: inherit; } #jump-links ol { font-size: .8em; margin-bottom: 0; } #jump-links:hover, #jump-links:focus-within { opacity: 1; } `; if (document.getElementById('new-replies-styles')) { document.getElementById('new-replies-styles').innerHTML = newStyles; } else { document.head.append(newStyles); } readReplies = localStorage.getItem("readComments") ? JSON.parse(localStorage.getItem("readComments")) : []; let prevDepth = 1; let topOfThread = null; let topOfThreadDepth = null; let prevComment = null; // loop over all comments document.querySelectorAll('.comment-thread').forEach((cmt) => { // get the depth from the comment classes const depth = parseInt(Array.from(cmt.classList).filter(cl => cl.match(/comment\-depth\-[0-9]+/))[0].replace("comment-depth-", "")); // if we're not currently in a thread, check if this is the top of a thread (either belongs to us or is a toplevel) if (!topOfThread || depth === 1) { if (!cmt.querySelector('.comment-wrapper').classList.contains(`poster-${username}`)) { prevComment = null; return; } topOfThread = cmt; topOfThreadDepth = parseInt(Array.from(topOfThread.classList).filter(cl => cl.match(/comment\-depth\-[0-9]+/))[0].replace("comment-depth-", "")); prevComment = cmt; } // if it's just a reply in the same thread but not the end keep going if (depth >= prevDepth) { prevComment = cmt; prevDepth = depth; return; } // if the depth is lower than it was in the last comment, the thread has ended if (depth < prevDepth || prevDepth !== topOfThreadDepth) { checkThread(cmt, topOfThread, prevComment); // regardless, unset the thread variables topOfThread = null; prevComment = null; prevDepth = 1; return; } // set variables for next loop prevComment = cmt; prevDepth = depth; }); if (topOfThread) { const startId = parseInt(topOfThread.querySelector('.comment').id.replace("comment-cmt", "")); if (prevComment && !prevComment.querySelector('.comment-wrapper').classList.contains(`poster-${username}`) && readReplies.indexOf(startId) === -1 && prevComment !== topOfThread) { checkThread(prevComment, topOfThread, prevComment); } } // add comments to the floating bottom left menu linkList.forEach((id) => { const newLi = document.createElement('li'); const newA = document.createElement('a'); newA.href = `#cmt${id}`; newA.textContent = `#${id}`; newLi.append(newA); jumpLinksOl.append(newLi); }); jumpSummary.append(` (${linkList.length})`); passList.forEach((p) => { const newLi = document.createElement('li'); const newA = document.createElement('a'); newA.href = `#cmt${p.id}`; newA.textContent = `#${p.id} (${p.count})`; newLi.append(newA); jumpPassingOl.append(newLi); }); jumpPassingSummary.append(` (${passList.length})`); teamList.forEach((t) => { const newLi = document.createElement('li'); const newA = document.createElement('a'); newA.href = `#cmt${t.id}`; newA.textContent = `#${t.id} (${t.count})`; newLi.append(newA); jumpTeamOl.append(newLi); }); jumpTeamSummary.append(` (${teamList.length})`); }; } })();