// ==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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
})();