PlatesMania Avatars + Relative Time

Avatars + relative timestamps on notifications. Changes: swap flag → photo, add flag emoji, user avatar, relative time, proper hover previews, and show latest comments in comment notifications.

// ==UserScript==
// @name         PlatesMania Avatars + Relative Time
// @namespace    pm-like-avatars
// @version      1.4
// @description  Avatars + relative timestamps on notifications. Changes: swap flag → photo, add flag emoji, user avatar, relative time, proper hover previews, and show latest comments in comment notifications.
// @match        https://*.platesmania.com/user*
// @match        http://*.platesmania.com/user*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // ---------- Only run on your own profile ----------
  function getOwnUserPath() {
    const loginBarUser = document.querySelector('.loginbar.pull-right > li > a[href^="/user"]');
    if (loginBarUser) return new URL(loginBarUser.getAttribute("href"), location.origin).pathname;
    const langLink = document.querySelector('.languages a[href*="/user"]');
    if (langLink) return new URL(langLink.getAttribute("href")).pathname;
    return null;
  }
  const ownPath = getOwnUserPath();
  const herePath = location.pathname.replace(/\/+$/, "");
  if (!ownPath || herePath !== ownPath.replace(/\/+$/, "")) return;

  // ---------- Config ----------
  const CACHE_KEY = "pm_profile_pic_cache_v1";
  const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days

  const NOMER_CACHE_KEY = "pm_nomer_photo_cache_v1";
  const NOMER_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; // 60 days

  const LIKE_ITEM_SELECTOR = ".col-xs-12.margin-bottom-5.bg-info";
  const CONTAINER_SELECTOR = "#mCSB_2_container, #content";
  const PROCESSED_FLAG = "pmAvatarAdded";

  GM_addStyle(`
    .pm-like-avatar {
      width: 20px !important;
      height: 20px !important;
      border-radius: 4px !important;
      object-fit: cover !important;
      vertical-align: text-bottom !important;
      margin-right: 6px !important;
      overflow: hidden !important;
      display: inline-block !important;
    }
    .pm-avatar-preview {
      position: fixed !important;
      width: 200px !important;
      height: 200px !important;
      max-width: 200px !important;
      max-height: 200px !important;
      border-radius: 10px !important;
      box-shadow: 0 8px 28px rgba(0,0,0,0.28) !important;
      background: #fff !important;
      z-index: 2147483647 !important;
      pointer-events: none !important;
      display: none;
      object-fit: cover; /* default for avatars */
    }
    .pm-nomer-thumb {
      width: 40px !important;
      height: 40px !important;
      object-fit: cover !important;
      border-radius: 6px !important;
    }
    /* COMMENT PREVIEWS (only in notifications) */
    .pm-comment-preview {
      /* almost-black, tuned for bright green backgrounds */
      color: #333 !important;
      display: block;
      width: 100%;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      line-height: 1.3;
    }
    .pm-comment-preview b {
      font-weight: 700;
    }
    .pm-comment-preview img {
      height: 20px !important;
      width: auto !important;
      vertical-align: middle;
      display: inline-block !important;
      float: none !important;
      margin-left: 4px;
      margin-right: 4px;
    }
    .pm-comment-preview br {
      display: none; /* keep it to one line */
    }
  `);

  // ---------- Cache helpers ----------
  function readCache(key = CACHE_KEY) {
    try {
      const raw = typeof GM_getValue === "function" ? GM_getValue(key, "{}") : localStorage.getItem(key) || "{}";
      return JSON.parse(raw);
    } catch {
      return {};
    }
  }
  function writeCache(obj, key = CACHE_KEY) {
    const raw = JSON.stringify(obj);
    if (typeof GM_setValue === "function") GM_setValue(key, raw);
    else localStorage.setItem(key, raw);
  }
  function getFromCache(id, key = CACHE_KEY, maxAge = MAX_AGE_MS) {
    const c = readCache(key);
    const e = c[id];
    if (!e) return null;
    if (Date.now() - e.ts > maxAge) {
      delete c[id];
      writeCache(c, key);
      return null;
    }
    return e.url;
  }
  function putInCache(id, url, key = CACHE_KEY) {
    const c = readCache(key);
    c[id] = { url, ts: Date.now() };
    writeCache(c, key);
  }
  (function prune() {
    const c1 = readCache(CACHE_KEY), c2 = readCache(NOMER_CACHE_KEY);
    let changed1 = false, changed2 = false;
    for (const [k, v] of Object.entries(c1)) if (!v || !v.ts || (Date.now() - v.ts > MAX_AGE_MS)) { delete c1[k]; changed1 = true; }
    for (const [k, v] of Object.entries(c2)) if (!v || !v.ts || (Date.now() - v.ts > NOMER_MAX_AGE_MS)) { delete c2[k]; changed2 = true; }
    if (changed1) writeCache(c1, CACHE_KEY);
    if (changed2) writeCache(c2, NOMER_CACHE_KEY);
  })();

  // ---------- PREVIEW helpers (one shared floating <img>) ----------
  const PM_PREVIEW_SIZE = 200;
  const PM_PREVIEW_PAD = 12;
  const pmPreviewEl = document.createElement("img");
  pmPreviewEl.className = "pm-avatar-preview";
  document.addEventListener("DOMContentLoaded", () => {
    if (!pmPreviewEl.isConnected) document.body.appendChild(pmPreviewEl);
  });
  if (!pmPreviewEl.isConnected) document.body.appendChild(pmPreviewEl);

  function pmMovePreview(e) {
    const vw = window.innerWidth;
    let x = e.clientX - PM_PREVIEW_SIZE / 2;
    let y = e.clientY - PM_PREVIEW_SIZE - PM_PREVIEW_PAD;
    if (x < 4) x = 4;
    if (x + PM_PREVIEW_SIZE > vw - 4) x = vw - PM_PREVIEW_SIZE - 4;
    if (y < 4) y = e.clientY + PM_PREVIEW_PAD;
    pmPreviewEl.style.left = `${x}px`;
    pmPreviewEl.style.top = `${y}px`;
  }
  function pmShowPreview(src, e, mode = "cover") {
    pmPreviewEl.style.objectFit = (mode === "contain") ? "contain" : "cover";
    pmPreviewEl.src = src || "";
    pmPreviewEl.style.display = "block";
    pmMovePreview(e);
  }
  function pmHidePreview() {
    pmPreviewEl.style.display = "none";
    pmPreviewEl.removeAttribute("src");
  }
  function attachPreview(el, src, mode = "cover") {
    el.addEventListener("mouseenter", (e) => pmShowPreview(src, e, mode));
    el.addEventListener("mousemove", pmMovePreview);
    el.addEventListener("mouseleave", pmHidePreview);
  }
  function attachSelfPreview(el, mode = "cover") {
    el.addEventListener("mouseenter", (e) => pmShowPreview(el.src, e, mode));
    el.addEventListener("mousemove", pmMovePreview);
    el.addEventListener("mouseleave", pmHidePreview);
  }
  function attachPreviewLazy(el, getSrc, mode = "cover") {
    el.addEventListener("mouseenter", (e) => {
      const src = getSrc();
      if (src) pmShowPreview(src, e, mode);
    });
    el.addEventListener("mousemove", pmMovePreview);
    el.addEventListener("mouseleave", pmHidePreview);
  }

  // ---------- Small utils ----------
  function absUrl(href) {
    try { return new URL(href, location.origin).toString(); }
    catch { return href; }
  }
  function escapeHtml(s = "") {
    return s.replace(/[&<>"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch]));
  }

  // ---------- Avatars ----------
  async function fetchProfileAvatar(userPath) {
    const res = await fetch(absUrl(userPath), { credentials: "same-origin" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const html = await res.text();
    const doc = new DOMParser().parseFromString(html, "text/html");
    const img = doc.querySelector(".profile-img[src]");
    return img ? absUrl(img.getAttribute("src")) : null;
  }
  function addAvatarToRow(rowEl, avatarUrl) {
    if (!rowEl || rowEl.dataset[PROCESSED_FLAG] === "1") return;
    const strong = rowEl.querySelector("strong");
    const userLink = strong && strong.querySelector('a[href^="/user"]');
    if (!userLink) return;
    if (strong.querySelector("img.pm-like-avatar")) {
      rowEl.dataset[PROCESSED_FLAG] = "1";
      return;
    }
    const img = document.createElement("img");
    img.className = "pm-like-avatar";
    img.width = 20;
    img.height = 20;
    img.alt = "";
    img.src = avatarUrl;
    attachPreview(img, avatarUrl, "cover");
    strong.insertBefore(img, userLink); // like-list layout
    rowEl.dataset[PROCESSED_FLAG] = "1";
  }

  const queue = [];
  let active = 0, CONCURRENCY = 3;
  function enqueue(task) { queue.push(task); runQueue(); }
  function runQueue() {
    while (active < CONCURRENCY && queue.length) {
      active++;
      const fn = queue.shift();
      Promise.resolve().then(fn).finally(() => { active--; runQueue(); });
    }
  }

  function processRow(rowEl) {
    if (!rowEl || rowEl.dataset[PROCESSED_FLAG] === "1" || rowEl.dataset.pmAvatarPending === "1") return;
    const userLink = rowEl.querySelector('strong > a[href^="/user"]');
    if (!userLink) return;
    const userHref = userLink.getAttribute("href");
    const userId = (userHref.match(/\/user(\d+)\b/) || [])[1];
    if (!userId) return;
    const cached = getFromCache(userId);
    if (cached) {
      addAvatarToRow(rowEl, cached);
      return;
    }
    rowEl.dataset.pmAvatarPending = "1";
    enqueue(async () => {
      try {
        const url = await fetchProfileAvatar(userHref);
        if (url) {
          putInCache(userId, url);
          addAvatarToRow(rowEl, url);
        } else {
          rowEl.dataset[PROCESSED_FLAG] = "1";
        }
      } catch { /* allow retry */ }
      finally { delete rowEl.dataset.pmAvatarPending; }
    });
  }
  function processAllAvatars(root = document) {
    root.querySelectorAll(LIKE_ITEM_SELECTOR).forEach(processRow);
  }

  // ---------- Relative "x ago" (Moscow → local) ----------
  function parseMoscowTime(str) {
    const m = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
    if (!m) return null;
    const [, Y, Mo, D, H, Mi, S] = m.map(Number);
    const ms = Date.UTC(Y, Mo - 1, D, H - 3, Mi, S);
    return new Date(ms);
  }
  function rel(date) {
    if (!date) return "";
    const s = Math.max(0, (Date.now() - date.getTime()) / 1000);
    if (s < 60) return `${Math.floor(s)}s ago`;
    const m = s / 60;
    if (m < 60) return `${Math.floor(m)}m ago`;
    const h = m / 60;
    if (h < 24) return `${Math.floor(h)}h ago`;
    const d = h / 24;
    if (d < 30) return `${Math.floor(d)}d ago`;
    return date.toLocaleDateString();
  }
  function processAllTimes(root = document) {
    root.querySelectorAll(`${LIKE_ITEM_SELECTOR} small`).forEach(el => {
      if (el.dataset.pmTimeDone === "1") return;
      const t = el.textContent.trim();
      const dt = parseMoscowTime(t);
      if (dt) {
        el.textContent = rel(dt);
        el.dataset.pmTimeDone = "1";
      }
    });
  }

  // ---------- Private messages panel ----------
  function ccToFlag(cc) {
    if (!cc || cc.length !== 2) return "";
    const A = 0x1F1E6, a = cc.toUpperCase();
    return String.fromCodePoint(A + (a.charCodeAt(0) - 65), A + (a.charCodeAt(1) - 65));
  }

  // Fetch small photo url (/s/) by scraping the nomer page (regex is fine)
  async function fetchNomerSmallPhoto(nomerHref) {
    const res = await fetch(absUrl(nomerHref), { credentials: "same-origin" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const html = await res.text();
    const m = html.match(/https?:\/\/img\d+\.platesmania\.com\/[^"'<>]+\/m\/\d+\.jpg/);
    if (!m) return null;
    return m[0].replace(/\/m\//, "/s/");
  }

  // NEW: Fetch latest N comments from the nomer page
  async function fetchNomerComments(nomerHref, count = 1) {
    const res = await fetch(absUrl(nomerHref), { credentials: "same-origin" });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const html = await res.text();
    const doc = new DOMParser().parseFromString(html, "text/html");
    // Each comment block
    const bodies = Array.from(doc.querySelectorAll('#ok .media.media-v2 .media-body'));
    const items = bodies.map(b => {
      const userEl = b.querySelector('.media-heading strong a, .media-heading strong a span'); // prefer span text if present
      let user = "";
      if (userEl) user = (userEl.textContent || "").trim();
      // content div like <div id="z3672309"> ... </div>
      const contentEl = b.querySelector('div[id^="z"]');
      const html = contentEl ? contentEl.innerHTML.trim() : "";
      return { user, html };
    }).filter(x => x.user && x.html);

    // take the last N (latest)
    return items.slice(-count);
  }

  // ensure "by [pic] username": insert right after the "by " text node, else before link
  function insertAvatarBy(img, iEl, userLink) {
    const nodes = Array.from(iEl.childNodes);
    const byNode = nodes.find(n => n.nodeType === 3 && /\bby\s*$/i.test(n.textContent));
    if (byNode && byNode.nextSibling === userLink) {
      iEl.insertBefore(img, userLink);
    } else if (byNode) {
      if (byNode.nextSibling) iEl.insertBefore(img, byNode.nextSibling);
      else iEl.appendChild(img);
    } else {
      iEl.insertBefore(img, userLink);
    }
  }

  function processPmAlert(alertEl) {
    if (!alertEl || alertEl.dataset.pmPanelDone === "1" || alertEl.dataset.pmPanelPending === "1") return;

    const strong = alertEl.querySelector(".overflow-h > strong");
    const titleLink = strong && strong.querySelector('a[href*="/nomer"]');
    if (!strong || !titleLink) {
      alertEl.dataset.pmPanelDone = "1";
      return;
    }

    const path = new URL(titleLink.getAttribute("href"), location.origin).pathname;
    const pathParts = path.split("/").filter(Boolean);
    const cc = pathParts[0] || "";
    const nomerId = (path.match(/nomer(\d+)/) || [])[1];

    // 2) Prefix title with emoji flag
    const flag = ccToFlag(cc);
    if (flag && titleLink && !titleLink.dataset.pmFlagged) {
      titleLink.textContent = `${flag} ${titleLink.textContent.trim()}`;
      titleLink.dataset.pmFlagged = "1";
    }

    // 1) Swap flag icon → small photo + hover (contain)
    const flagImg = alertEl.querySelector("img.rounded-x, .alert img");
    if (flagImg && nomerId) {
      const cached = getFromCache(nomerId, NOMER_CACHE_KEY, NOMER_MAX_AGE_MS);
      if (cached) {
        flagImg.src = cached;
        flagImg.classList.add("pm-nomer-thumb");
        attachPreviewLazy(flagImg, () => (flagImg.src ? flagImg.src.replace(/\/s\//, "/m/") : ""), "contain");
      } else {
        alertEl.dataset.pmPanelPending = "1";
        enqueue(async () => {
          try {
            const photoUrl = await fetchNomerSmallPhoto(titleLink.getAttribute("href"));
            if (photoUrl) {
              putInCache(nomerId, photoUrl, NOMER_CACHE_KEY);
              flagImg.src = photoUrl;
              flagImg.classList.add("pm-nomer-thumb");
              attachPreviewLazy(flagImg, () => photoUrl.replace(/\/s\//, "/m/"), "contain");
            }
          } catch { /* ignore */ }
          finally { delete alertEl.dataset.pmPanelPending; }
        });
      }
    }

    // 3) Avatar next to username (after "by ") + hover (cover) ; 4) relative time
    const iEl = alertEl.querySelector("i");
    if (iEl) {
      // avatar for user placed as "by [pic] username"
      const userLink = iEl.querySelector('a[href^="/user"]');
      if (userLink && !iEl.querySelector("img.pm-like-avatar")) {
        const userHref = userLink.getAttribute("href");
        const userId = (userHref.match(/\/user(\d+)\b/) || [])[1];
        const addAvatar = (url) => {
          const img = document.createElement("img");
          img.className = "pm-like-avatar";
          img.width = 20;
          img.height = 20;
          img.alt = "";
          img.src = url;
          attachPreview(img, url, "cover");
          insertAvatarBy(img, iEl, userLink);
        };
        const cached = userId && getFromCache(userId);
        if (cached) addAvatar(cached);
        else if (userId) {
          enqueue(async () => {
            try {
              const url = await fetchProfileAvatar(userHref);
              if (url) {
                putInCache(userId, url);
                addAvatar(url);
              }
            } catch { /* ignore */ }
          });
        }
      }

      // relative time: tooltip HH:mm:ss + date in parentheses if present in text
      if (!iEl.dataset.pmRelTimeDone) {
        const time = iEl.getAttribute("data-original-title") || "";
        const mDate = iEl.textContent && iEl.textContent.match(/\((\d{4}-\d{2}-\d{2})\)/);
        const dateStr = mDate ? mDate[1] : "";
        const dt = (time && dateStr) ? parseMoscowTime(`${dateStr} ${time}`) : null;
        if (dt) {
          iEl.innerHTML = iEl.innerHTML.replace(/\(\d{4}-\d{2}-\d{2}\)/, (`(${rel(dt)})`));
          iEl.dataset.pmRelTimeDone = "1";
        }
      }
    }

    // 5) Replace "New comments to your photos" with latest N comment previews
    const pDesc = alertEl.querySelector(".overflow-h > p");
    const plusEm = strong && strong.querySelector("small.pull-right em");
    let count = 1;
    if (plusEm) {
      const m = plusEm.textContent && plusEm.textContent.match(/\+(\d+)/);
      if (m) count = Math.max(1, parseInt(m[1], 10));
    }
    if (pDesc && titleLink && !pDesc.dataset.pmCommentsLoaded && !pDesc.dataset.pmCommentsPending) {
      pDesc.dataset.pmCommentsPending = "1";
      enqueue(async () => {
        try {
          const comments = await fetchNomerComments(titleLink.getAttribute("href"), count);
          if (comments && comments.length) {
            // Build HTML lines: <div class="pm-comment-preview"><b>User:</b> commentHTML</div>
            const html = comments
              .map(c => `<div class="pm-comment-preview"><b>${escapeHtml(c.user)}:</b> ${c.html}</div>`)
              .join("");
            pDesc.innerHTML = html;
          }
        } catch { /* ignore; leave default text */ }
        finally {
          delete pDesc.dataset.pmCommentsPending;
          pDesc.dataset.pmCommentsLoaded = "1";
        }
      });
    }

    alertEl.dataset.pmPanelDone = "1";
  }

  function processPmPanel(root = document) {
    const alerts = root.querySelectorAll('.panel .alert.alert-blocks, #scrollbar3 .alert.alert-blocks');
    alerts.forEach(processPmAlert);
  }

  // ---------- Observe ----------
  function observe() {
    const container = document.querySelector(CONTAINER_SELECTOR) || document.body;
    const mo = new MutationObserver(muts => {
      muts.forEach(m => m.addedNodes.forEach(n => {
        if (!(n instanceof HTMLElement)) return;
        processAllAvatars(n);
        processAllTimes(n);
        processPmPanel(n);
      }));
    });
    mo.observe(container, { childList: true, subtree: true });

    const pmPanel = document.querySelector('#scrollbar3') || document.querySelector('.panel .panel-title i.fa-send')?.closest('.panel');
    if (pmPanel) {
      const mo2 = new MutationObserver(muts => {
        muts.forEach(m => m.addedNodes.forEach(n => {
          if (n instanceof HTMLElement) processPmPanel(n);
        }));
      });
      mo2.observe(pmPanel, { childList: true, subtree: true });
    }
  }

  // ---------- Kickoff ----------
  processAllAvatars(document);
  processAllTimes(document);
  processPmPanel(document);
  observe();

  setInterval(() => {
    processAllAvatars(document);
    processAllTimes(document);
    processPmPanel(document);
  }, 1500);
})();