QQ/SB/SV Color Thread Replies

Visually separate story posts from replies, tinted background and quotes with per-theme color.

// ==UserScript==
// @name        QQ/SB/SV Color Thread Replies
// @description Visually separate story posts from replies, tinted background and quotes with per-theme color.
// @author      C89sd
// @version     1.7
// @match       https://questionablequesting.com/*
// @match       https://forum.questionablequesting.com/*
// @match       https://forums.spacebattles.com/*
// @match       https://forums.sufficientvelocity.com/*
// @grant       GM_addStyle
// @namespace   https://greasyfork.org/users/1376767
// @noframes
// ==/UserScript==

'use strict';

const url = document.URL;
const IS_THREAD = url.includes('/threads/');
if (!IS_THREAD) return;

const IS_SB = url.includes('spacebattles.com');
const IS_SV = url.includes('sufficientvelocity.com');
const IS_QQ = url.includes('questionablequesting.com');

GM_addStyle(`
@media (max-width: 650px) {
  .mobile-only {
    background-color: transparent !important;
  }
}
`);

const styleChooser = document.querySelector('.p-footer-linkList a[href="/misc/style"]');
let styleName;
if (styleChooser) {
  const title = styleChooser.getAttribute('title');
  if (title?.startsWith('Style: ')) {
    styleName = title.replace(/^Style:\s*/, '').trim();
  } else {
    styleName = styleChooser.innerText.trim();
  }
}
// console.log(`title="${styleChooser.getAttribute('title')}", inner="${styleChooser.innerText.trim()}" -> extracted="${styleName}"`, styleChooser)

const DEFAULT_DARK  = 'rgb(25, 45, 27)';
const DEFAULT_LIGHT = 'rgb(254, 255, 225)';
// Old: dark "#152E18", light "#F5F6CE", gray "#424242"
const COLOR_BY_THEME = {
  "Default"                 : ['rgb(25, 45, 27)',     50],

  // SpaceBattles.com[
  "SpaceBattles"            : ['rgb(25, 45, 27)',     50], // rgb(21, 46, 24)
  "SpaceBattles - Light"    : ['rgb(254, 255, 225)', 160],

  // SufficientVelocity.com[
  "Neptune"                 : ['rgb(12, 41, 39)',     0],
  "Starscape"               : ['rgb(12, 41, 39)',     0],
  "Sunlight"                : ['rgb(254, 255, 225)', 140],
  "Industrial"              : ['rgb(26, 47, 28)',     60],

  // QuestionableQuesting.com
  "Xenforo Default"         : ['rgb(254, 255, 225)', 140],
  "Light"                   : ['rgb(235, 236, 192)',  60],
  "Dark"                    : ['rgb(36, 55, 38)',    100],
  "Blackened"               : ['rgb(70, 34, 34)',    200],
  "Blackened Green"         : ['rgb(25, 45, 27)',     20],
  "Blackened Blue"          : ['rgb(35, 44, 66)',    180], // rgb(31, 41, 66)
  "Blackened Purple"        : ['rgb(49, 37, 66)',    200],
  "Blackened High Contrast" : ['rgb(11, 32, 14)',      0],
  "Lightened"               : ['rgb(237, 238, 214)',  60],
};

let [base, quoteBias] = COLOR_BY_THEME[styleName];
if (!base) {
  console.error(`${styleName} not in COLOR_BY_THEME!`);
  const DM = IS_QQ && window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128;
  base = DM ? DEFAULT_DARK : DEFAULT_LIGHT;
  quoteBias = DM ? 255 : 0;
}
// console.log(styleName, COLOR_BY_THEME[styleName], base)

const darken = (hex, factor) => {
  const [r, g, b] = hex.replace('#', '').match(/\w\w/g).map(c => parseInt(c, 16)); // Remove #, to hex
  const toHex = c => Math.min(255, Math.max(0, Math.round(c * factor))).toString(16).padStart(2, '0');
  return '#' + toHex(r) + toHex(g) + toHex(b);
};
const rgbToHex = rgb => '#' + rgb.match(/\d+/g).map(c => (+c).toString(16).padStart(2, '0')).join('');

base = base.startsWith('#') ? base : rgbToHex(base); // convert to hex, applyBgTint() depends on it
const darker  = darken(base, 0.4);
const lighter = darken(base, 2.3);
const quoteBg = `rgba(${quoteBias}, ${quoteBias}, ${quoteBias}, 0.05)` // opaque lighten or darken, cant read color to target

function rgbToHsl(r, g, b) {
  r /= 255, g /= 255, b /= 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h, s, l = (max + min) / 2;
  if (max == min) {
    h = s = 0; // achromatic
  } else {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2; break;
      case b: h = (r - g) / d + 4; break;
    }
    h /= 6;
  }
  return [ h, s, l ];
}
function hslToRgb(h, s, l) {
  let r, g, b;
  if (s == 0) {
    r = g = b = l; // achromatic
  } else {
    function hue2rgb(p, q, t) {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1/6) return p + (q - p) * 6 * t;
      if (t < 1/2) return q;
      if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
      return p;
    }
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
    r = hue2rgb(p, q, h + 1/3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1/3);
  }
  return [ r * 255, g * 255, b * 255 ];
}

const bgTintCache = new Map();

function applyBgTint(node) {
  const rgb = getComputedStyle(node).backgroundColor;

  if (bgTintCache.has(rgb)) {
    node.style.backgroundColor = bgTintCache.get(rgb);
    return;
  }

  const hex = base;
  const [r1, g1, b1] = rgb.match(/\d+/g).map(Number);
  const hexVal = hex.startsWith('#') ? hex.slice(1) : hex;
  const r2 = parseInt(hexVal.slice(0, 2), 16);
  const g2 = parseInt(hexVal.slice(2, 4), 16);
  const b2 = parseInt(hexVal.slice(4, 6), 16);

  const baseHsl = rgbToHsl(r2, g2, b2);
  const rgbHsl  = rgbToHsl(r1, g1, b1);
  const out = hslToRgb(baseHsl[0], baseHsl[1], rgbHsl[2]);

  const outputColor = `rgb(${out[0]}, ${out[1]}, ${out[2]})`;
  bgTintCache.set(rgb, outputColor);
  node.style.backgroundColor = outputColor;
}

const OP   = document.querySelector('.username.u-concealed')?.textContent || '!';
const USER = document.querySelector('.p-navgroup-linkText')?.textContent || '!';
// console.log(OP, USER);

const messages = document.querySelectorAll('article.message');
for (const message of messages) {
  const author = message.getAttribute('data-author');

  if (author == USER || message.classList.contains('hasThreadmark')) continue;

  if (author === OP) {
    message.querySelector('.username')?.insertAdjacentHTML('afterbegin', '<strong style="color:crimson">AUTHOR:</strong><br/>');
  } else {
    message.style.backgroundColor = base;

    const quotes = message.querySelectorAll('blockquote');
    for (const quote of quotes) {
      const quoteTitle = quote.querySelector('div.bbCodeBlock-title');
      const quoteBlock = quote.querySelector('div.bbCodeBlock-content');

      applyBgTint(quote);
      if (quoteTitle) applyBgTint(quoteTitle);
      if (quoteBlock) quoteBlock.style.backgroundColor = quoteBg;
    }

    const left = message.querySelector('div.message-cell.message-cell--user');
    const right = message.querySelector('div.message-cell.message-cell--main');
    if (left && right) left.style.backgroundColor = getComputedStyle(right).backgroundColor;

    const reactionbar = message.querySelector('div.reactionsBar');
    if (reactionbar) applyBgTint(reactionbar);

    const rating = message.querySelector('div.sv-rating');
    if (rating) applyBgTint(rating);

    const icons = message.querySelectorAll('div.sv-rating__count');
    for (const icon of icons) { applyBgTint(icon); }

    if (IS_SB) {
      const detail1 = message.querySelector('div.message-userDetails');
      const detail2 = message.querySelector('div.message-userExtras');

      if (detail1) {
        applyBgTint(detail1);
        detail1.classList.add('mobile-only');
      }
      if (detail2) {
        applyBgTint(detail2);
        detail2.classList.add('mobile-only');
      }
    }
  }
}