Greasy Fork is available in English.

YouTube LiveChat Enhancer

enhance livechat on youtube

// ==UserScript==
// @name         YouTube LiveChat Enhancer
// @namespace    http://james0x57.com/
// @version      0.2
// @description  enhance livechat on youtube
// @author       James0x57
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
  'use strict';

  //## Begin Code I didn't write
    // credit: https://stackoverflow.com/a/6969486/1527109
    function escapeRegExp(str) {
      return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
    }
  //## End Code I didn't write

  var fcs = function(fn) { //functionalCommentString
    /*Function By James Atherton - http://geckocodes.org/?hacker=James0x57 */
    /*You are free to copy and alter however you'd like so long as you leave the credit intact! =)*/
    return fn.toString().replace(/^(\r?\n|[^\/])*\/\*!?(\r?\n)*|\s*\*\/(\r|\n|.)*$/gi,"");
  };

  function addCSS(css) {
    var el = document.createElement('div');
    el.innerHTML = '<b>CSS</b><style type="text/css">' + css + '</style>';
    el = el.childNodes[1];
    if (el) document.getElementsByTagName('head')[0].appendChild(el);
    return el;
  }

  // https://gist.github.com/James0x57/da84cc2bb6087db5f041387b0a586e6c
  //## Begin Selector Observation Code
    var selectors = [];
    (new MutationObserver(
      function (mutationsList) {
        var s, selector, nodeMatches
        var slen = selectors.length
        for (s = 0; s < slen; s++) {
          selector = selectors[s]
          nodeMatches = node => node.nodeType === 1 && node.matches(selector.childSelector)

          mutationsList.forEach(mu => {
            if (mu.type === "childList" && mu.target.matches(selector.parentSelector)) {
              var addedMatches = Array.prototype.filter.call(mu.addedNodes, nodeMatches)
              var removedMatches = Array.prototype.filter.call(mu.removedNodes, nodeMatches)
              addedMatches.length && selector.inserted.call(null, addedMatches)
              removedMatches.length && selector.removed.call(null, removedMatches)
            }
          })
        }
      }
    )).observe(document.documentElement, {
      childList: true,
      subtree: true
    })

    // watch the parentSelector for the specific children to be added or removed,
    // call inserted as that parentSelector > children are added and they match childSelector
    // call removed as that parentSelector > children are removed and they match childSelector (note they won't be in the dom any more)
    // inserted and removed callbacks are called with the matching elements passed in as the only parameter (is an array)
    var onParentChildSelectors = function (opts) {
      var nullFn = () => {}
      selectors.push(Object.assign({
        parentSelector: "",
        childSelector: "",
        inserted: nullFn,
        removed: nullFn
      }, opts))
    }

    // remove all watch selectors:
    // offSelector({ parentSelector }) -> matching that selector
    // offSelector({ parentSelector, childSelector }) -> matching that selector
    // offSelector({ parentSelector, inserted }) -> matching that parentSelector && matching that inserted function
    // offSelector({ parentSelector, childSelector, inserted }) -> matching that selector && matching that inserted function
    // offSelector({ parentSelector, childSelector, removed }) -> matching that selector && matching that removed function
    // offSelector({ parentSelector, childSelector, inserted, removed }) -> matching that selector, inserted function, and removed function
    var offSelector = function (opts) {
      for (let s = 0; s < selectors.length; s++) {
        let selectorObj = selectors[s]
        let comp = Object.assign({}, selectorObj, opts)
        let matchingProps = ["parentSelector", "childSelector", "inserted", "removed"].filter(prop => selectorObj[prop] === comp[prop])
        if (matchingProps.length === 4) {
          selectors.splice(s, 1)
          s--
        }
      }
    }
  //## End Selector Observation Code

  addCSS(fcs(function() {/*!
    @keyframes jca_highlight {
      0% {
        background: #88ccff;
      }
      100% {
        background: transparent;
      }
    }

    .jca-jump-highlight {
      animation: jca_highlight 2s;
    }

    .jca-user-ref {
      background-color: rgba(128, 128, 128, 0.15);
      cursor: pointer;
    }
    .jca-user-ref:before {
      content: "@";
    }
  */}))

  var userInfo = {}

  var userRefClickFn = function () {
    var userRefSpan = this
    var referencedMessageEl = document.querySelector('yt-live-chat-renderer #chat #items #' + userRefSpan.getAttribute("data-last-id"))
    if (referencedMessageEl) {
      referencedMessageEl.scrollIntoView({
        behavior: "smooth",
        block: "nearest",
        inline: "center"
      })
      referencedMessageEl.classList.add("jca-jump-highlight")
      setTimeout(() => referencedMessageEl.classList.remove("jca-jump-highlight"), 1000)
    }
  }

  var createSpanAndReferenceUsers = function (text) {
    var span = document.createElement("span")
    // sort with longer names first because if somebody's name is a subset of another user's. eg "Amber S" and "Amber" then we'll get the right one
    var users = Object.keys(userInfo).sort((a, b) => b.length - a.length || a.localeCompare(b));
    users.forEach(user => {
      let info = userInfo[user]
      text = text.replace(info.userNameRx, `<span class="jca-user-ref" data-last-id="${info.escapedId}">${user}</span>`)
    })
    span.className = "jca-text-node-into-span"
    span.innerHTML = text
    span.querySelectorAll(".jca-user-ref").forEach(function (userSpan) {
      userSpan.addEventListener("click", userRefClickFn.bind(userSpan), false)
    })
    return span
  }

  var decorateMessage = function (messageEl) {
    // console.log(messageEl.textContent)
    var messageParts = messageEl.childNodes || []

    messageParts.forEach(node => {
      if (node.nodeType === 3) { // textNode
        let text = node.textContent
        let hasUserRef = /@/.test(text)
        if (hasUserRef) {
          let span = createSpanAndReferenceUsers(text)
          messageEl.replaceChild(span, node)
        }
      }
    })
  }

  var handleNewChat = function (chatEl) {
    var escapedId = chatEl.id.replace(/%/g, "\\%")
    var user = chatEl.querySelector("#author-name").textContent.trim()
    var messageEl = chatEl.querySelector("#message")
    var message = messageEl && messageEl.textContent.trim()
    userInfo[user] = {
      user,
      userNameRx: new RegExp("@" + escapeRegExp(user) + "\\b", "gi"),
      lastId: chatEl.id,
      escapedId,
      lastMessage: message
    }
    messageEl && decorateMessage(messageEl)
  }

  onParentChildSelectors({
    parentSelector: "yt-live-chat-renderer #chat #items",
    childSelector: "yt-live-chat-text-message-renderer, yt-live-chat-paid-message-renderer",
    inserted: addedChatEls => addedChatEls.forEach(handleNewChat)
    // removed
  })
})()