wormboy / YouTube-Pin-Comments

// ==UserScript==
// @name                YouTube-Pin-Comments
// @description         Move comments by specific users to the top of the comments section.
// @version             1.0.3
// @author              wormboy
// @license             MIT
// @namespace           patchmonkey
// @match               https://www.youtube.com/*
// @require             https://openuserjs.org/src/libs/wormboy/conduitjs.js
// @grant               GM.getValue
// @grant               GM.setValue
// @run-at              document-idle
// @noframes
// ==/UserScript==

const SLOT_NAME = 'pinned'

const ENDPOINT = [
  'ytd-comment-renderer',
  '#body',
  '#author-thumbnail',
  'a.yt-simple-endpoint',
]

const TOOLBAR = [
  'ytd-comment-renderer',
  '#body',
  '#main',
  'ytd-comment-action-buttons-renderer',
  '#toolbar',
]

const PIN = `
  <style>
  #pin {
    width: 32px;
    height: 32px;
    margin: 0 8px 0 -8px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  #pin.active {
    --top-color: hsl(212, 100%, 57%);
    --mid-color: hsl(212, 82%, 46%);
  }
  #top {
    fill: var(--top-color, #ff5722);
  }
  #mid {
    fill: var(--mid-color, #d84315);
  }
  </style>
  <div id="pin">
    <svg viewBox="0 0 48 48" width="16" height="16">
      <polygon transform="matrix(1.92,0,0,1.92,-24.96,-19.2)" points="35.84 15.761 26.889 27.593 20.407 21.111 32.239 12.16" id="mid"/>
      <path d="m18.23 25.342-2.7053 2.7053c-4.4352 4.4352-13.438 13.692-13.438 13.692l-2.087 6.2611 6.2611-2.087s9.2563-9.0029 13.692-13.44l2.7053-2.7053z" fill="#90a4ae" stroke-width="1.92"/>
      <path d="m5.76 21.333 1.9546-1.9546c2.7014-2.7014 7.0771-2.7014 9.7786 0l11.343 11.13c2.6995 2.7014 2.6995 7.0752 0 9.7766l-1.9565 1.9546zm27.155-21.333-2.5133 2.5133c-1.3882 1.3882-1.3882 3.6384 0 5.0285l10.055 10.055c1.3882 1.3882 3.6403 1.3882 5.0285 0l2.5152-2.5133z" id="top" stroke-width="1.92"/>
    </svg>
  </div>
  <slot></slot>
`

const commentsByElement = new Map()

let blacklist = []

function getBlackList() {
  blacklist = GM.getValue('blacklist', [])
}

async function setBlackList(entry) {
  const list = await blacklist
  const index = list.indexOf(entry)
  const toggle = index != -1
  if (toggle) list.splice(index, 1); else list.push(entry)
  await GM.setValue('blacklist', list)
  blacklist = list
  return !toggle
}

async function togglePin(pin) {
  const { href } = pin
  const pinned = await setBlackList(href)
  for (const comment of commentsByElement.values()) {
    if (comment.href == href) {
      comment.pin(pinned)
    }
  }
}

const PinnedComment = {
  async processLink(link) {
    const list = await blacklist
    this.href = link.getAttribute('href')
    this.pin(list.includes(this.href))
  },

  processToolbar(toolbar) {
    this.button = toolbar.shadowRoot.querySelector('#pin')
    this.button.onclick = () => togglePin(this)
    this.classify()
  },

  pin(pinned) {
    this.element.slot = pinned ? SLOT_NAME : ''
    this.classify()
  },

  classify() {
    if (this.button) {
      this.button.classList.toggle('active', this.element.slot == SLOT_NAME)
    }
  },

  observe(element) {
    this.href = ''
    this.button = null
    this.element = element
    this.observer = conduit.observe(element)
    this.observer.follow(ENDPOINT).attribute('href').each(n => this.processLink(n))
    this.observer.follow(TOOLBAR).shadow(PIN).each(n => this.processToolbar(n))
  },

  disconnect() {
    this.observer.disconnect()
  }
}

function processAddedComment(element) {
  if (!commentsByElement.has(element)) {
    const comment = Object.create(PinnedComment)
    commentsByElement.set(element, comment)
    comment.observe(element)
  }
}

function processRemovedComment(element) {
  if (commentsByElement.has(element)) {
    commentsByElement.get(element).disconnect()
    commentsByElement.delete(element)
  }
}

conduit.define('shadow', function(html) {
  return conduit.junction(function(element, details) {
    if (details.type == 'match') {
      if (!element.shadowRoot) element.attachShadow({ mode: 'open' })
      element.shadowRoot.innerHTML = html
    }
    this.matched(element, details)
  })
})

conduit
  .observe(document.body)
  .follow([
    'ytd-app',
    '#content',
    'ytd-page-manager',
    'ytd-watch-flexy',
    '#columns',
    '#primary',
    '#primary-inner',
    'ytd-comments',
    'ytd-item-section-renderer',
    '#contents',
  ])
  .shadow(`
    <div id="pins">
      <slot name="${SLOT_NAME}"></slot>
    </div>
    <slot></slot>
  `)
  .follow([
    'ytd-comment-thread-renderer'
  ])
  .each(function(element, details) {
    if (details.type == 'match') processAddedComment(element)
    else processRemovedComment(element)
  })

window.addEventListener('yt-comments-loaded', getBlackList)

getBlackList()