Last active 1 month ago

sometimes when a post is really big trying to manage it via inbox is futile. now also checks your threads for AC viability + teammate threads

newcomments.user.js Raw
1// ==UserScript==
2// @name Highlight New Replies
3// @namespace http://veryroundbird.house
4// @version 2026-06-03
5// @description highlights your threads where you're not the most recent reply.
6// @author Carly Smallbird
7// @match https://*.dreamwidth.org/*
8// @icon https://www.google.com/s2/favicons?sz=64&domain=dreamwidth.org
9// @require https://openuserjs.org/src/libs/sizzle/GM_config.js
10// @grant GM_getValue
11// @grant GM_setValue
12// @grant GM.getValue
13// @grant GM.setValue
14// ==/UserScript==
15
16(function() {
17 'use strict';
18 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
19 // make the settings
20 let gmc = new GM_config(
21 {
22 'id': 'veryroundbirdhouse_newreplies',
23 'title': 'Settings',
24 'fields':
25 {
26 'altJournals':
27 {
28 'label': 'Alternate Journals',
29 'type': 'text',
30 'default': '',
31 'labelPos': 'above'
32 },
33 'newReplyText':
34 {
35 'label': 'New Reply Text',
36 'type': 'text',
37 'default': '✨ NEW REPLIES ✨',
38 'labelPos': 'above'
39 },
40 'markDoneText':
41 {
42 'label': 'Mark Thread Done Text',
43 'type': 'text',
44 'default': '✅ Mark Thread Done',
45 'labelPos': 'above'
46 },
47 'acPassIndicator':
48 {
49 'label': 'AC Passing Thread Indicator',
50 'type': 'text',
51 'default': '🌟',
52 'labelPos': 'above'
53 },
54 'highlightColor':
55 {
56 'label': 'Highlight Color',
57 'type': 'text',
58 'default': 'yellow',
59 'labelPos': 'above'
60 },
61 'showACInfo':
62 {
63 'label': 'Show AC Info?',
64 'type': 'checkbox',
65 'default': true
66 },
67 'threadLengthThreshold':
68 {
69 'label': 'Thread Length Threshold for AC',
70 'type': 'int',
71 'min': 0,
72 'default': 15,
73 'labelPos': 'above'
74 },
75 'threadLengthCountType':
76 {
77 'label': 'How to Count Comments for AC',
78 'type': 'radio',
79 'options': ['Total Comments', 'Comments from Me'],
80 'default': 'Total Comments'
81 },
82 'showTeamThreads':
83 {
84 'label': 'Show Team Thread Info?',
85 'type': 'checkbox',
86 'default': true
87 },
88 'teammateUsernames':
89 {
90 'label': 'Teammate Usernames',
91 'type': 'text',
92 'default': '',
93 'labelPos': 'above'
94 }
95 },
96 'events': {
97 'init': () => {
98 main();
99 updateUI();
100 },
101 'save': () => {
102 updateUI();
103 }
104 },
105 'css': `
106 #veryroundbirdhouse_newreplies {
107 font-size: 16px;
108 font-family: 'Atkinson Hyperlegible', sans-serif;
109 }
110
111 #veryroundbirdhouse_newreplies .config_var {
112 display: flex;
113 gap: 10px;
114 }
115
116 #veryroundbirdhouse_newreplies .config_var br {
117 display: none;
118 }
119
120 #veryroundbirdhouse_newreplies .config_var label {
121 min-width: 200px;
122 font-size: 14px;
123 line-height: 16px;
124 }
125
126 #veryroundbirdhouse_newreplies label {
127 display: block;
128 white-space: nowrap;
129 }
130
131 #veryroundbirdhouse_newreplies [type="radio"] + label {
132 display: inline-block;
133 }
134
135 #veryroundbirdhouse_newreplies input {
136 font-family: 'Atkinson Hyperlegible', sans-serif;
137 }
138 `
139 });
140
141 // set up globals
142 let readReplies = [];
143 let linkList = [];
144 let teamList = [];
145 let passList = [];
146
147 // get username from account links
148 const username = document.querySelector('#account-links-text .ljuser').getAttribute('lj:user');
149
150 // create elements for jump links menu
151 const jumpLinks = document.createElement('div');
152 const jumpDetails = document.createElement('details');
153 const jumpSummary = document.createElement('summary');
154 const jumpLinksOl = document.createElement('ol');
155 const jumpPassingDetails = document.createElement('details');
156 const jumpPassingSummary = document.createElement('summary');
157 const jumpPassingOl = document.createElement('ol');
158 const jumpTeamDetails = document.createElement('details');
159 const jumpTeamSummary = document.createElement('summary');
160 const jumpTeamOl = document.createElement('ol');
161 const settingsLink = document.createElement('a');
162 jumpSummary.textContent = "🧵 New Comments";
163 jumpDetails.append(jumpSummary);
164 jumpDetails.append(jumpLinksOl);
165 jumpLinks.append(jumpDetails);
166 jumpPassingDetails.id = 'jump-ac-passing';
167 jumpPassingSummary.textContent = "✅ AC Length OK";
168 jumpPassingDetails.append(jumpPassingSummary);
169 jumpPassingDetails.append(jumpPassingOl);
170 jumpTeamDetails.id = 'jump-team-threads';
171 jumpTeamSummary.textContent = "👯 Team Threads";
172 jumpTeamDetails.append(jumpTeamSummary);
173 jumpTeamDetails.append(jumpTeamOl);
174 jumpTeamDetails.style.display = "none";
175 jumpLinks.append(jumpPassingDetails);
176 jumpLinks.append(jumpTeamDetails);
177 settingsLink.textContent = "⚙️ Settings";
178 settingsLink.href = "#";
179 settingsLink.addEventListener('click', (e) => {
180 e.preventDefault();
181 gmc.open();
182 });
183 jumpLinks.append(settingsLink);
184 jumpLinks.id = 'jump-links';
185 document.body.append(jumpLinks);
186
187 const updateUI = () => {
188 if (gmc.get('showTeamThreads', true)) {
189 document.getElementById('jump-team-threads').style.display = "block";
190 } else {
191 document.getElementById('jump-team-threads').style.display = "none";
192 }
193
194 if (gmc.get('showACInfo', true)) {
195 document.getElementById('jump-ac-passing').style.display = "block";
196 } else {
197 document.getElementById('jump-ac-passing').style.display = "none";
198 }
199 }
200
201 const sliceNodeList = (id1, id2) => {
202 let id1idx = null;
203 let id2idx = null;
204 const nodes = Array.from(document.querySelectorAll('.comment-thread'));
205 for (let i = 0; i < nodes.length; i++) {
206 if (!id1idx && nodes[i].querySelector('.dwexpcomment').id === `cmt${id1}`) id1idx = i;
207 if (!id2idx && nodes[i].querySelector('.dwexpcomment').id === `cmt${id2}`) id2idx = i;
208 if (id1idx !== null && id2idx !== null) break;
209 }
210 return nodes.slice(id1idx, id2idx+1);
211 }
212
213 const getCommentCount = (thread) => {
214 return thread.filter((elt) => {
215 if (elt.querySelector('.ljuser').textContent === username) return true;
216 return false;
217 }).length
218 }
219
220 const getParticipantList = (thread) => {
221 const usernames = thread.map((elt) => {
222 return elt.querySelector('.ljuser').textContent;
223 })
224 return usernames.filter((un, i) => {
225 if (usernames.indexOf(un) === i) return true;
226 return false;
227 });
228 }
229
230 const isTeamThread = (thread) => {
231 const participants = getParticipantList(thread);
232 const teammates = getTeammates();
233 for (let i = 0; i < participants.length; i++) {
234 if (teammates.indexOf(participants[i]) !== -1) {
235 return true;
236 }
237 }
238 return false;
239 }
240
241 const passesAc = (thread) => {
242 if (gmc.get("threadLengthCountType", "Comments from Me") === "Comments from Me") {
243 const myComments = thread.filter((elt) => {
244 if (elt.querySelector('.ljuser').textContent === username) return true;
245 return false;
246 });
247 if (myComments.length >= gmc.get("threadLengthThreshold", 20)) return true;
248 return false;
249 } else if (gmc.get("threadLengthCountType", "Comments from Me") === "Total Comments") {
250 if (thread.length >= gmc.get("threadLengthThreshold", 20)) return true;
251 return false;
252 }
253 return false;
254 }
255
256 const checkThread = (cmt, topOfThread, prevComment) => {
257 const startId = parseInt(topOfThread.querySelector('.comment').id.replace("comment-cmt", ""));
258 const endId = prevComment ? parseInt(prevComment.querySelector('.comment').id.replace("comment-cmt", "")) : null;
259 const threadComments = sliceNodeList(startId, endId);
260 const commentCount = getCommentCount(threadComments);
261 const participants = getParticipantList(threadComments);
262
263 if (isTeamThread(threadComments)) teamList.push({id: startId, count: commentCount});
264 if (passesAc(threadComments)) {
265 topOfThread.classList.add("ac-pass");
266 passList.push({id: startId, count: commentCount});
267 }
268
269 // if this comment isn't ours and it's not in our ignored threads, highlight it
270 if (prevComment && !prevComment.querySelector('.comment-wrapper').classList.contains(`poster-${username}`) && readReplies.indexOf(startId) === -1 && cmt !== topOfThread) {
271 highlightCmt(topOfThread);
272 linkList.push(startId);
273 }
274 }
275
276 // add an item to the list of threads we're ignoring
277 const addReadReply = (id) => {
278 if (readReplies.indexOf(parseInt(id)) === -1) {
279 readReplies.push(parseInt(id));
280 localStorage.setItem("readComments", JSON.stringify(readReplies));
281 }
282 };
283
284 // adds UI elements to highlighted comments
285 const highlightCmt = (top) => {
286 const id = parseInt(top.querySelector('.comment').id.replace("comment-cmt", ""));
287 const info = top.querySelector('.comment-info');
288 const markReadBtn = document.createElement('button');
289 const newCmtMarker = document.createElement('span');
290 let expandListener = null;
291 markReadBtn.setAttribute('type', 'button');
292 markReadBtn.classList.add("markRead");
293 markReadBtn.textContent = gmc.get("markDoneText", "✅ Mark Thread Done");
294 newCmtMarker.textContent = gmc.get("newReplyText", "✨ NEW REPLIES ✨");
295 newCmtMarker.classList.add('newComment');
296 top.classList.add('highlight');
297
298 const readListener = (e) => {
299 e.preventDefault();
300 addReadReply(id);
301 top.classList.remove("highlight");
302 newCmtMarker.remove();
303 markReadBtn.remove();
304 jumpLinks.querySelector(`a[href="#cmt${id}"]`).parentNode.remove();
305 if (expandListener) {
306 removeEventListener('click', expandListener);
307 }
308 };
309
310 markReadBtn.addEventListener('click', readListener);
311
312 if (top.querySelector('.partial')) {
313 top.querySelector('.comment .inner').append(markReadBtn);
314 expandListener = top.querySelector('a[onclick]').addEventListener('click', (_e) => {
315 newCmtMarker.remove();
316 markReadBtn.remove();
317 const waitForExpansion = setTimeout(() => {
318 if (top.querySelector('.comment-info h4')) {
319 top.querySelector('.comment-info h4').append(newCmtMarker);
320 top.querySelector('.comment-info h4').append(markReadBtn);
321 markReadBtn.addEventListener('click', readListener);
322 clearInterval(waitForExpansion);
323 }
324 }, 1000);
325 });
326 } else {
327 top.querySelector('.comment-info h4').append(markReadBtn);
328 }
329 };
330
331 const getTeammates = () => {
332 const teammateUsernames = gmc.get('teammateUsernames', '');
333 return teammateUsernames !== '' ? teammateUsernames.split(",").map(x => x.trim()) : [];
334 }
335
336 const getAltJournals = () => {
337 const altJournals = gmc.get('altJournals', '');
338 return altJournals !== '' ? altJournals.split(",").map(x => x.trim()) : [];
339 }
340
341 const getHighlightColor = () => gmc.get('highlightColor', '');
342 const getAcPassIndicator = () => gmc.get('acPassIndicator', '');
343
344 const main = async () => {
345 // create styles for stuff we're adding to the UI
346 const newStyles = document.createElement('style');
347 const teammates = getTeammates();
348 console.log(teammates);
349 const altjournals = getAltJournals();
350 const highlightColor = getHighlightColor();
351 const acPassIndicator = getAcPassIndicator();
352 newStyles.setAttribute('type', 'text/css');
353 newStyles.id = 'new-replies-styles';
354 newStyles.textContent = `
355 .highlight .partial > .comment,
356 .highlight .visible .comment-info {
357 background-color: ${highlightColor} !important;
358 }
359
360 .ac-pass .partial > .comment .inner:before {
361 content: "${acPassIndicator}";
362 margin-right: 5px;
363 }
364
365 .ac-pass .visible .comment-info {
366 position: relative;
367 }
368
369 .ac-pass .visible .comment-info:after {
370 content: "${acPassIndicator}";
371 position: absolute;
372 top: 5px;
373 right: 5px;
374 font-size: 1.4em;
375 }
376
377 button.markRead {
378 padding: 2px;
379 margin-bottom: 0;
380 font-size: .8em;
381 }
382
383 .comment .inner > button.markRead {
384 margin-left: 5px;
385 }
386
387 #jump-links {
388 position: fixed;
389 bottom: 10px;
390 left: 10px;
391 padding: 5px;
392 border: 1px gray solid;
393 background-color: #FFFFFF66;
394 max-height: 150px;
395 max-width: 200px;
396 overflow: auto;
397 opacity: .5;
398 z-index: 99;
399 }
400
401 #jump-links summary {
402 cursor: pointer;
403 list-style: none;
404 }
405
406 #jump-links > a {
407 text-decoration: none;
408 color: inherit;
409 }
410
411 #jump-links ol {
412 font-size: .8em;
413 margin-bottom: 0;
414 }
415
416 #jump-links:hover,
417 #jump-links:focus-within {
418 opacity: 1;
419 }
420 `;
421 if (document.getElementById('new-replies-styles')) {
422 document.getElementById('new-replies-styles').innerHTML = newStyles;
423 } else {
424 document.head.append(newStyles);
425 }
426
427 readReplies = localStorage.getItem("readComments") ? JSON.parse(localStorage.getItem("readComments")) : [];
428
429 let prevDepth = 1;
430 let topOfThread = null;
431 let topOfThreadDepth = null;
432 let prevComment = null;
433
434 // loop over all comments
435 document.querySelectorAll('.comment-thread').forEach((cmt) => {
436 // get the depth from the comment classes
437 const depth = parseInt(Array.from(cmt.classList).filter(cl => cl.match(/comment\-depth\-[0-9]+/))[0].replace("comment-depth-", ""));
438
439 // 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)
440 if (!topOfThread || depth === 1) {
441 if (!cmt.querySelector('.comment-wrapper').classList.contains(`poster-${username}`)) {
442 prevComment = null;
443 return;
444 }
445 topOfThread = cmt;
446 topOfThreadDepth = parseInt(Array.from(topOfThread.classList).filter(cl => cl.match(/comment\-depth\-[0-9]+/))[0].replace("comment-depth-", ""));
447 prevComment = cmt;
448 }
449
450 // if it's just a reply in the same thread but not the end keep going
451 if (depth >= prevDepth) {
452 prevComment = cmt;
453 prevDepth = depth;
454 return;
455 }
456
457 // if the depth is lower than it was in the last comment, the thread has ended
458 if (depth < prevDepth || prevDepth !== topOfThreadDepth) {
459 checkThread(cmt, topOfThread, prevComment);
460
461 // regardless, unset the thread variables
462 topOfThread = null;
463 prevComment = null;
464 prevDepth = 1;
465 return;
466 }
467
468 // set variables for next loop
469 prevComment = cmt;
470 prevDepth = depth;
471 });
472
473 if (topOfThread) {
474 const startId = parseInt(topOfThread.querySelector('.comment').id.replace("comment-cmt", ""));
475 if (prevComment && !prevComment.querySelector('.comment-wrapper').classList.contains(`poster-${username}`) && readReplies.indexOf(startId) === -1 && prevComment !== topOfThread) {
476 checkThread(prevComment, topOfThread, prevComment);
477 }
478 }
479
480 // add comments to the floating bottom left menu
481 linkList.forEach((id) => {
482 const newLi = document.createElement('li');
483 const newA = document.createElement('a');
484 newA.href = `#cmt${id}`;
485 newA.textContent = `#${id}`;
486 newLi.append(newA);
487 jumpLinksOl.append(newLi);
488 });
489 jumpSummary.append(` (${linkList.length})`);
490 passList.forEach((p) => {
491 const newLi = document.createElement('li');
492 const newA = document.createElement('a');
493 newA.href = `#cmt${p.id}`;
494 newA.textContent = `#${p.id} (${p.count})`;
495 newLi.append(newA);
496 jumpPassingOl.append(newLi);
497 });
498 jumpPassingSummary.append(` (${passList.length})`);
499 teamList.forEach((t) => {
500 const newLi = document.createElement('li');
501 const newA = document.createElement('a');
502 newA.href = `#cmt${t.id}`;
503 newA.textContent = `#${t.id} (${t.count})`;
504 newLi.append(newA);
505 jumpTeamOl.append(newLi);
506 });
507 jumpTeamSummary.append(` (${teamList.length})`);
508 };
509 }
510})();