// ==UserScript==
// @name Highlight YouTube Commenter Mentions
// @description Highlights usernames of previous commenters at the start of replies, so that they are clearly distinguishable from the actual response. Mentions of the OP and replies by them are highlighted in a special way. Mentions of users that changed their display names cannot be recognized. (For full description, see beginning of userscript.)
//
// @namespace https://github.com/h-h-h-h
// @version 2018.11.22.3
//
// @match https://www.youtube.com/watch?*
// @grant none
//
// @author Henrik Hank
// @copyright 2018, Henrik Hank (https://github.com/h-h-h-h)
// @license Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
//
// @homepageURL https://gf.qytechs.cn/scripts/374664-highlight-youtube-commenter-mentions
// @supportURL https://gf.qytechs.cn/scripts/374664-highlight-youtube-commenter-mentions/feedback
// ==/UserScript==
//i This userscript requires a modern browser that supports ES6.
//i
//i FULL DESCRIPTION: Highlights usernames of previous commenters of a thread at the start of the plain reply texts, so that they are clearly distinguishable from the actual response. Furthermore, mentions of the OP and replies by them are highlighted in a special way, with the exception of mentions that are already linked by YouTube. Those mentions always get a different highlighting than plain-text mentions. Unfortunately, if a user mentioned with plain text changes their display name, there is no way for this userscript to highlight the old username. (False positives are conceivable where a username, using regular words, matches the beginning of an actual comment text that is not supposed to mention anyone. Also, when a username starts with the username of another commenter in the same thread, inaccurate highlighting may occur.)
void function userscript() {
"use strict";
const SEL_COMMENT_SECTION = "ytd-comments#comments > ytd-item-section-renderer#sections.ytd-comments > div#contents.ytd-item-section-renderer";
const SEL_THREAD = "#contents.ytd-item-section-renderer > ytd-comment-thread-renderer.ytd-item-section-renderer";
const SEL_TOP_LEVEL_COMMENT = "ytd-comment-renderer#comment.ytd-comment-thread-renderer";
const SEL_REPLY = "ytd-comment-renderer[is-reply]";
const SEL_COMMENT_USERNAME = "a#author-text.ytd-comment-renderer";
const SEL_COMMENT_TEXT = "yt-formatted-string#content-text.ytd-comment-renderer";
let gCommentSectionExistsTimer = null;
let gTopLevelObserver = new MutationObserver(onCommentSectionMutation);
let gCommentObserver = new MutationObserver(onCommentMutation);
void function main() {
gCommentSectionExistsTimer = setInterval(onCommentSectionExistsTimer, 200 /*ms*/);
//i This doesn't run a long time (e.g., while playing video), because it only refers to the top-level-comments container that is created earlier on and not just when the user scrolls down.
}();
function onCommentSectionExistsTimer() {
let commentSection = document.querySelector(SEL_COMMENT_SECTION);
if (! commentSection) { return; }
clearInterval(gCommentSectionExistsTimer);
gTopLevelObserver.observe(commentSection, { childList: true });
createStyles();
}
function createStyles() {
let el = document.createElement("style");
el.innerHTML = `
.user-script-username-highlighting {
background-color: hsla(214, 95%, 43%, 0.14);
/*i Color taken from YouTube's link color. */
}
/* Already linked user mentions. (No OP signalling provided.) */
${SEL_REPLY} a[href^="/channel/"].yt-simple-endpoint.yt-formatted-string,
${SEL_REPLY} a[href^="/user/" ].yt-simple-endpoint.yt-formatted-string {
background-color: hsla(0, 0%, 50%, 0.2);
}
/*i Using translucent colors accommodates light *and* dark themes. */
`;
document.body.appendChild(el);
}
function onCommentSectionMutation(mutations, observer) {
for (let i = 0; i < mutations.length; i++) {
let nodes = mutations[i].addedNodes;
for (let j = 0; j < nodes.length; j++) {
let node = nodes[j];
if (node instanceof Element &&
node.matches(SEL_THREAD))
{
let el = node.querySelector("#loaded-replies");
if (el) { // Replies present.
gCommentObserver.observe(el, { childList: true });
}
}
}
}
}
function onCommentMutation(mutations, observer) {
for (let i = 0; i < mutations.length; i++) {
let nodes = mutations[i].addedNodes;
// Find topmost element of those added.
let parent = mutations[i].target;
let siblings = parent.children;
let smallestReplyIndex = siblings.length - 1;
console.assert(smallestReplyIndex >= 0);
for (let j = 0; j < nodes.length; j++) {
let node = nodes[j];
if (node instanceof Element &&
node.matches(SEL_REPLY))
{
let index = Array.prototype.indexOf.call(siblings, node);
if (index > -1 && index < smallestReplyIndex) {
smallestReplyIndex = index;
}
}
}
//
highlightReplies(parent, smallestReplyIndex);
}
}
function highlightReplies(repliesContainer, startIndex) {
// Find top-level comment for its username.
let threadElement = repliesContainer;
while (threadElement !== null && ! threadElement.matches(SEL_THREAD)) {
threadElement = threadElement.parentElement;
}
console.assert(threadElement !== null);
let topLevelComment = threadElement.querySelector(`:scope > ${SEL_TOP_LEVEL_COMMENT}`);
console.assert(topLevelComment !== null);
// Gather usernames and highlight them at the beginning of comment texts.
let usernames = [ getAdjustUsernameOfComment(topLevelComment, false) ];
let elements = repliesContainer.children;
for (let i = 0; i < elements.length; i++) {
let comment = elements[i];
if (! comment.matches(SEL_REPLY)) { continue; }
// Highlight.
let maySignalOp = false;
if (i >= startIndex) { // The above replies have been highlighted already.
let el = comment.querySelector(SEL_COMMENT_TEXT);
console.assert(el !== null);
let firstNode = el.firstChild;
if (firstNode && firstNode.nodeType === Node.TEXT_NODE) {
let text = firstNode.nodeValue;
for (let j = 0; j < usernames.length; j++) {
let startLength = startsWith(
text,
[ "@ ", "@", "+", "" ],
[ usernames[j] ],
null, // Highlight till this point.
[ " ", "\n", "\r", ": ", ", ", ". ", "...", "\xa0" /*(no-break space; actually seen)*/ ]
);
if (startLength > 0) {
highlightFirstChars(firstNode, startLength, /*is OP:*/ j === 0);
break;
}
}
}
maySignalOp = true;
}
// Gather and possibly signal OP.
usernames.push(
getAdjustUsernameOfComment(comment, maySignalOp ? usernames[0] : null)
);
}
}
function getAdjustUsernameOfComment(commentElement, usernameToSignalAsOp /*or null*/) {
let usernameElement = commentElement.querySelector(SEL_COMMENT_USERNAME);
console.assert(usernameElement !== null);
let span = usernameElement.querySelector(":scope > span");
console.assert(span !== null);
let username = span.textContent.trim();
if (username === usernameToSignalAsOp) {
span.style.fontStyle = "italic";
}
return username;
}
/**
* `tokenArrays`: Pass `null` at the position you want to stop counting the string length. In case of a successfully matched token, the algorithm will proceed with the next array and no backtracking will occur. An empty string will invalidate the following items and must therefore be the last element.
* Returns the length of the string that `text` starts with.
*/
function startsWith(text, ...tokenArrays) {
if (tokenArrays.length === 0) { return 0; }
let arrayIndex = 0;
let tokens = tokenArrays[arrayIndex];
let tokenIndex = 0;
let textIndex = 0;
let startLength = 0;
let hasStoppedCounting = false;
while (true) {
let token = tokens[tokenIndex];
if (text.startsWith(token, textIndex)) {
// Partial success. Carry on or finish.
if (! hasStoppedCounting) {
startLength += token.length;
}
textIndex += token.length;
arrayIndex++;
if (arrayIndex >= tokenArrays.length) {
return startLength;
}
while ( (tokens = tokenArrays[arrayIndex]) === null ) {
hasStoppedCounting = true;
arrayIndex++;
}
tokenIndex = 0;
} else {
// Token not present. Not finding any one of a given array means failure.
tokenIndex++;
if (tokenIndex >= tokens.length) {
return 0;
}
}
}
}
function highlightFirstChars(textNode, numberOfChars, mustSignalAsOp) {
let el = document.createElement("span");
let text = textNode.nodeValue;
let left = text.substr(0, numberOfChars);
let right = text.substr(numberOfChars);
el.innerHTML =
`<span class="user-script-username-highlighting">` +
`${mustSignalAsOp ? "🔝 " : ""}` +
`${left}` +
`</span>` +
`${right}`
;
textNode.replaceWith(el);
}
}();