Code
// ==UserScript==
// @name HF Popularity Tracker
// @namespace https://hackforums.net/
// @version 1.0.5
// @description Track HackForums popularity/reputation with native-style dropdown, stats, filters, search, member cards, history, smart notifications, and profile/return-rep actions.
// @match https://hackforums.net/*
// @grant GM_notification
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/******************************************************************
* CONFIG
******************************************************************/
const DEBUG = true;
const AUTO_SCAN_MINUTES = 60;
const STARTUP_SCAN_MIN_AGE_SECONDS = 180;
const MAX_CONCURRENT_FETCHES = 2;
const REQUEST_TIMEOUT_MS = 20000;
const ERROR_BACKOFF_MINUTES = 10;
const MAX_LOG_ITEMS = 500;
const MAX_MEMBER_HISTORY = 200;
const MAX_DETAIL_TOASTS = 5;
const MAX_ACK_TOKENS = 2000;
const ICON_PRIMARY = 'fa fa-ranking-star';
const ICON_FALLBACK = 'fa fa-star';
const LAST_UID_KEY = 'hf_pop_tracker_last_uid';
const DEFAULT_SETTINGS = {
enableToasts: true,
notifyNew: true,
notifyUpdated: true,
notifyRemoved: false,
notifyPositive: true,
notifyNeutral: true,
notifyNegative: true,
notifyCommentChangeOnly: false
};
/******************************************************************
* RUNTIME STATE
******************************************************************/
let CURRENT_UID = '';
let REPUTATION_BASE = '';
let STORAGE_KEYS = null;
let toolbarButtonLi = null;
let toolbarButtonAnchor = null;
let toolbarButtonIcon = null;
let toolbarBadge = null;
let dropdownContainer = null;
let dropdownBody = null;
let memberModal = null;
let scanInProgress = false;
let autoLoopStarted = false;
let uiState = {
filterType: 'all',
search: '',
selectedMemberUid: '',
};
/******************************************************************
* HELPERS
******************************************************************/
function log(...args) {
if (DEBUG) console.log('[HF Pop Tracker]', ...args);
}
function normalizeText(str) {
return String(str || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeTokenText(str) {
return normalizeText(str).toLowerCase();
}
function makeToken(parts) {
return JSON.stringify(parts.map(part => normalizeTokenText(part)));
}
function escapeHtml(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function safeParse(value, fallback) {
if (value == null || value === '') return fallback;
try {
const parsed = JSON.parse(value);
return parsed == null ? fallback : parsed;
} catch {
return fallback;
}
}
function now() {
return Date.now();
}
function timeAgo(ts) {
if (!ts) return 'never';
const diff = Math.floor((Date.now() - ts) / 1000);
const abs = Math.abs(diff);
const suffix = diff >= 0 ? 'ago' : 'from now';
if (abs < 60) return `${abs}s ${suffix}`;
if (abs < 3600) return `${Math.floor(abs / 60)}m ${suffix}`;
if (abs < 86400) return `${Math.floor(abs / 3600)}h ${suffix}`;
return `${Math.floor(abs / 86400)}d ${suffix}`;
}
function monthAgoTimestamp() {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.getTime();
}
function notify(title, text, timeout = 10000) {
const settings = getSettings();
if (!settings.enableToasts) return;
try {
GM_notification({ title, text, timeout });
} catch (e) {
console.warn('[HF Pop Tracker] GM_notification failed:', e);
}
}
function extractUidFromHref(href) {
if (!href) return '';
const m = String(href).match(/[?&]uid=(\d+)/i);
return m ? m[1] : '';
}
function extractPageFromHref(href) {
if (!href) return null;
const m = String(href).match(/[?&]page=(\d+)/i);
return m ? parseInt(m[1], 10) : null;
}
function extractRidFromHref(href) {
if (!href) return '';
const m = String(href).match(/[?&]rid=(\d+)/i);
return m ? m[1] : '';
}
function parseScoreValue(scoreStr) {
const m = String(scoreStr || '').match(/[+-]?\d+/);
return m ? parseInt(m[0], 10) : 0;
}
function typeColor(type) {
if (type === 'positive') return '#4caf50';
if (type === 'negative') return '#ff5f56';
if (type === 'neutral') return '#d6a63a';
return '#9e9e9e';
}
function typeIcon(type) {
if (type === 'positive') return 'fa fa-plus-circle';
if (type === 'negative') return 'fa fa-minus-circle';
if (type === 'neutral') return 'fa fa-dot-circle';
return 'fa fa-circle';
}
function createStorageKeys(uid) {
return {
snapshot: `hf_pop_tracker_snapshot_${uid}`,
meta: `hf_pop_tracker_meta_${uid}`,
log: `hf_pop_tracker_log_${uid}`,
auto: `hf_pop_tracker_auto_${uid}`,
settings: `hf_pop_tracker_settings_${uid}`,
memberHistory: `hf_pop_tracker_member_history_${uid}`,
acknowledgedAt: `hf_pop_tracker_acknowledged_at_${uid}`,
acknowledgedTokens: `hf_pop_tracker_ack_tokens_${uid}`
};
}
function findKnownUidFromStorage() {
const lastUid = localStorage.getItem(LAST_UID_KEY);
if (lastUid && /^\d+$/.test(lastUid)) return lastUid;
let newestUid = '';
let newestScan = -1;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i) || '';
const m = key.match(/^hf_pop_tracker_meta_(\d+)$/);
if (!m) continue;
const meta = safeParse(localStorage.getItem(key), {});
const lastScan = Number(meta?.lastScan || 0);
if (lastScan > newestScan) {
newestScan = lastScan;
newestUid = m[1];
}
}
if (newestUid) return newestUid;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i) || '';
const m = key.match(/^hf_pop_tracker_(?:snapshot|log|member_history|ack_tokens|acknowledged_at)_(\d+)$/);
if (m) return m[1];
}
return '';
}
function getStore(key, fallback) {
return safeParse(localStorage.getItem(key), fallback);
}
function setStore(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function getMeta() {
const meta = getStore(STORAGE_KEYS.meta, {
initialized: false,
lastScan: 0,
lastPageCount: 0,
lastEntryCount: 0,
uid: CURRENT_UID,
backoffUntil: 0,
lastError: '',
lastSummary: null
});
return {
initialized: !!meta.initialized,
lastScan: Number(meta.lastScan || 0),
lastPageCount: Number(meta.lastPageCount || 0),
lastEntryCount: Number(meta.lastEntryCount || 0),
uid: String(meta.uid || CURRENT_UID),
backoffUntil: Number(meta.backoffUntil || 0),
lastError: String(meta.lastError || ''),
lastSummary: meta.lastSummary || null
};
}
function setMeta(meta) {
setStore(STORAGE_KEYS.meta, {
initialized: !!meta.initialized,
lastScan: Number(meta.lastScan || 0),
lastPageCount: Number(meta.lastPageCount || 0),
lastEntryCount: Number(meta.lastEntryCount || 0),
uid: String(meta.uid || CURRENT_UID),
backoffUntil: Number(meta.backoffUntil || 0),
lastError: String(meta.lastError || ''),
lastSummary: meta.lastSummary || null
});
}
function getSnapshot() {
const data = getStore(STORAGE_KEYS.snapshot, {});
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
}
function setSnapshot(snapshot) {
setStore(
STORAGE_KEYS.snapshot,
snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) ? snapshot : {}
);
}
function getLogItems() {
const items = getStore(STORAGE_KEYS.log, []);
return Array.isArray(items) ? items : [];
}
function setLogItems(items) {
setStore(STORAGE_KEYS.log, Array.isArray(items) ? items : []);
}
function getAcknowledgedAt() {
return Number(localStorage.getItem(STORAGE_KEYS.acknowledgedAt) || 0);
}
function setAcknowledgedAt(ts) {
localStorage.setItem(STORAGE_KEYS.acknowledgedAt, String(Number(ts || 0)));
}
function getAcknowledgedTokens() {
const tokens = getStore(STORAGE_KEYS.acknowledgedTokens, []);
return Array.isArray(tokens) ? tokens.filter(Boolean).map(String) : [];
}
function getAcknowledgedTokenSet() {
return new Set(getAcknowledgedTokens());
}
function setAcknowledgedTokens(tokens) {
const unique = [...new Set((Array.isArray(tokens) ? tokens : []).filter(Boolean).map(String))];
setStore(STORAGE_KEYS.acknowledgedTokens, unique.slice(-MAX_ACK_TOKENS));
}
function rememberAcknowledgedTokens(tokens) {
setAcknowledgedTokens([...getAcknowledgedTokens(), ...tokens]);
}
function buildEntryStateToken(entry) {
const e = sanitizeEntry(entry);
if (!e) return '';
return makeToken(['entry-state', e.rid, e.senderUid, e.type, e.score, e.comment]);
}
function buildEntrySeenToken(entry) {
const e = sanitizeEntry(entry);
if (!e || !e.rid) return '';
return makeToken(['entry-seen', e.rid, e.senderUid, e.type, e.score]);
}
function buildAddedChangeToken(item) {
const e = sanitizeEntry(item);
if (!e) return '';
return makeToken(['change-new', e.rid, e.senderUid, e.type, e.score, e.comment]);
}
function buildRemovedChangeToken(item) {
const e = sanitizeEntry(item);
if (!e) return '';
return makeToken(['change-removed', e.rid, e.senderUid, e.type, e.score, e.comment]);
}
function buildUpdatedChangeToken(update) {
const before = sanitizeEntry(update?.before);
const after = sanitizeEntry(update?.after);
if (!before || !after) return '';
return makeToken([
'change-updated',
after.rid,
after.senderUid,
before.type,
before.score,
before.comment,
after.type,
after.score,
after.comment
]);
}
function buildLogFallbackToken(item) {
if (!item || item.type !== 'change') return '';
return makeToken([
'log-change',
item.changeKind || '',
item.senderUid || '',
item.repType || '',
item.repScore || '',
item.message || ''
]);
}
function tokensForAdded(item) {
return [buildAddedChangeToken(item), buildEntryStateToken(item), buildEntrySeenToken(item)].filter(Boolean);
}
function tokensForUpdated(update) {
return [buildUpdatedChangeToken(update), buildEntryStateToken(update?.after)].filter(Boolean);
}
function tokensForRemoved(item) {
return [buildRemovedChangeToken(item)].filter(Boolean);
}
function tokensForLogItem(item) {
const tokens = [];
if (item?.changeSignature) tokens.push(String(item.changeSignature));
if (Array.isArray(item?.ackTokens)) tokens.push(...item.ackTokens.map(String));
const fallback = buildLogFallbackToken(item);
if (fallback) tokens.push(fallback);
return tokens.filter(Boolean);
}
function isLogItemAcknowledged(item, acknowledgedTokens = getAcknowledgedTokenSet()) {
return tokensForLogItem(item).some(token => acknowledgedTokens.has(token));
}
function rememberCurrentAcknowledgementState() {
const tokens = [];
for (const item of getLogItems()) {
if (item?.type === 'change') tokens.push(...tokensForLogItem(item));
}
for (const entry of Object.values(getSnapshot())) {
tokens.push(buildEntryStateToken(entry), buildEntrySeenToken(entry));
}
rememberAcknowledgedTokens(tokens);
}
function acknowledgeAll() {
rememberCurrentAcknowledgementState();
setAcknowledgedAt(now());
updateToolbarState(0);
renderDropdownContent();
}
function getUnacknowledgedChangeCount() {
if (!STORAGE_KEYS) return 0;
const acknowledgedAt = getAcknowledgedAt();
const acknowledgedTokens = getAcknowledgedTokenSet();
return getLogItems().filter(item =>
item &&
item.ts > acknowledgedAt &&
item.type === 'change' &&
!isLogItemAcknowledged(item, acknowledgedTokens)
).length;
}
function getMemberHistory() {
const items = getStore(STORAGE_KEYS.memberHistory, {});
return items && typeof items === 'object' && !Array.isArray(items) ? items : {};
}
function setMemberHistory(history) {
setStore(STORAGE_KEYS.memberHistory, history && typeof history === 'object' ? history : {});
}
function getSettings() {
const saved = getStore(STORAGE_KEYS.settings, {});
return { ...DEFAULT_SETTINGS, ...(saved && typeof saved === 'object' ? saved : {}) };
}
function setSettings(settings) {
setStore(STORAGE_KEYS.settings, { ...DEFAULT_SETTINGS, ...(settings || {}) });
}
function pushLog(type, message, extra = {}) {
const items = getLogItems();
items.unshift({
ts: Date.now(),
type: String(type || 'info'),
message: String(message || ''),
...extra
});
while (items.length > MAX_LOG_ITEMS) items.pop();
setLogItems(items);
renderDropdownContent();
}
function isAutoEnabled() {
return localStorage.getItem(STORAGE_KEYS.auto) !== '0';
}
function setAutoEnabled(enabled) {
localStorage.setItem(STORAGE_KEYS.auto, enabled ? '1' : '0');
}
/******************************************************************
* UID DETECTION
******************************************************************/
function detectUidFromDom() {
const selectors = [
'a[href*="search.php?action=finduser&uid="]',
'a[href*="search.php?action=finduserthreads&uid="]',
'a[href*="marketcp.php?action=profile&uid="]',
'a[href*="contracts.php?uid="]'
];
for (const selector of selectors) {
const link = document.querySelector(selector);
if (link) {
const uid = extractUidFromHref(link.href);
if (uid) return uid;
}
}
const knownUid = findKnownUidFromStorage();
if (knownUid) return knownUid;
const repLinks = [...document.querySelectorAll('a[href*="reputation.php?uid="]')];
for (const a of repLinks) {
const uid = extractUidFromHref(a.href);
if (uid && /^\d+$/.test(uid)) return uid;
}
return '';
}
function ensureUid() {
const uid = detectUidFromDom();
if (!uid) throw new Error('Could not auto-detect your HackForums UID from the page.');
CURRENT_UID = uid;
REPUTATION_BASE = `${location.origin}/reputation.php?uid=${CURRENT_UID}`;
STORAGE_KEYS = createStorageKeys(CURRENT_UID);
localStorage.setItem(LAST_UID_KEY, CURRENT_UID);
return uid;
}
/******************************************************************
* NETWORK
******************************************************************/
async function fetchUrl(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
signal: controller.signal
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return await res.text();
} finally {
clearTimeout(timer);
}
}
async function fetchPool(urls, concurrency) {
const results = new Array(urls.length);
let index = 0;
async function worker() {
while (true) {
const current = index++;
if (current >= urls.length) return;
results[current] = await fetchUrl(urls[current]);
}
}
const workers = [];
const count = Math.min(concurrency, urls.length);
for (let i = 0; i < count; i++) workers.push(worker());
await Promise.all(workers);
return results;
}
/******************************************************************
* PARSING
******************************************************************/
function parseDoc(html) {
const parser = new DOMParser();
return parser.parseFromString(String(html || ''), 'text/html');
}
function detectTotalPages(html) {
const doc = parseDoc(html);
let maxPage = 1;
const links = [
...doc.querySelectorAll('a[href*="reputation.php?uid="][href*="page="]'),
...doc.querySelectorAll('.pagination a[href*="page="]'),
...doc.querySelectorAll('.multipage a[href*="page="]'),
...doc.querySelectorAll('a[href*="page="]')
];
for (const a of links) {
const page = extractPageFromHref(a.href);
if (page && page > maxPage) maxPage = page;
const txt = normalizeText(a.textContent);
if (/^\d+$/.test(txt)) {
const n = parseInt(txt, 10);
if (n > maxPage) maxPage = n;
}
}
return maxPage;
}
function parseRepTypeFromClass(el) {
const strong = el?.querySelector?.('strong[class*="reputation_"]');
const cls = `${String(el?.className || '')} ${String(strong?.className || '')}`;
if (cls.includes('reputation_positive')) return 'positive';
if (cls.includes('reputation_negative')) return 'negative';
if (cls.includes('reputation_neutral')) return 'neutral';
return 'unknown';
}
function extractRid(el) {
if (!el) return '';
const id = String(el.id || '');
let m = id.match(/^rid[_-]?(\d+)$/i);
if (m) return m[1];
const deleteLink = el.querySelector('a[href*="reputation.php?action=delete"][href*="rid="]');
if (deleteLink) {
const rid = extractRidFromHref(deleteLink.href);
if (rid) return rid;
}
const anyRepLink = el.querySelector('a[href*="rid="]');
if (anyRepLink) {
const rid = extractRidFromHref(anyRepLink.href);
if (rid) return rid;
}
return '';
}
function sanitizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
return {
rid: String(entry.rid || ''),
senderUid: String(entry.senderUid || ''),
senderName: normalizeText(entry.senderName || ''),
senderHref: String(entry.senderHref || ''),
type: String(entry.type || 'unknown'),
score: String(entry.score || ''),
comment: normalizeText(entry.comment || ''),
dateText: normalizeText(entry.dateText || ''),
rawText: normalizeText(entry.rawText || ''),
page: Number(entry.page || 1)
};
}
function buildStableKey(entry) {
const e = sanitizeEntry(entry);
if (!e) return '';
return e.rid ? `rid:${e.rid}` : '';
}
function extractCommentFromRow(row, strongNode) {
if (!row || !strongNode) return '';
const clone = row.cloneNode(true);
clone.querySelectorAll('.smalltext, script, style').forEach(el => el.remove());
const strongClone = clone.querySelector('strong.reputation_positive, strong.reputation_negative, strong.reputation_neutral, strong[class*="reputation_"]');
if (!strongClone) return '';
const fullText = normalizeText(clone.innerText || clone.textContent || '');
const strongText = normalizeText(strongClone.textContent || strongNode.textContent || '');
const idx = fullText.indexOf(strongText);
if (idx === -1) return '';
let tail = fullText.slice(idx + strongText.length);
tail = tail.replace(/^:\s*/, '');
return normalizeText(tail);
}
function parseReputationPage(html, pageNumber) {
const doc = parseDoc(html);
const results = {};
const rows = [...doc.querySelectorAll('td[id^="rid"]')];
for (const row of rows) {
const rid = extractRid(row);
if (!rid) continue;
const senderLink = row.querySelector('a[href*="member.php?action=profile&uid="], a[href*="member.php?uid="]');
const senderUid = senderLink ? extractUidFromHref(senderLink.href) : '';
const senderName = senderLink ? normalizeText(senderLink.textContent) : '';
const senderHref = senderLink ? senderLink.href : '';
const type = parseRepTypeFromClass(row);
const rawText = normalizeText(row.textContent);
let dateText = '';
const smallText = row.querySelector('.smalltext');
if (smallText) {
const m = normalizeText(smallText.textContent).match(/Last updated\s+(.+)$/i);
if (m) dateText = normalizeText(m[1]);
}
const strong = row.querySelector('strong.reputation_positive, strong.reputation_negative, strong.reputation_neutral, strong[class*="reputation_"]');
let score = '';
if (strong) {
const strongText = normalizeText(strong.textContent);
const scoreMatch = strongText.match(/\(([+-]\d+)\)/);
if (scoreMatch) score = scoreMatch[1];
}
const comment = extractCommentFromRow(row, strong);
const entry = sanitizeEntry({
rid,
senderUid,
senderName,
senderHref,
type,
score,
comment,
dateText,
rawText,
page: pageNumber
});
const key = buildStableKey(entry);
if (!key) continue;
results[key] = entry;
}
return results;
}
async function scanAllPages(progressCb) {
const firstHtml = await fetchUrl(REPUTATION_BASE);
if (/you must be registered|please login|member login/i.test(firstHtml)) {
throw new Error('Could not read your reputation page. Make sure you are logged in.');
}
const totalPages = detectTotalPages(firstHtml);
progressCb?.(`Detected ${totalPages} page(s).`);
const merged = parseReputationPage(firstHtml, 1);
progressCb?.(`Parsed page 1/${totalPages}.`);
if (totalPages === 1) return { totalPages, entries: merged };
const urls = [];
const pageNums = [];
for (let page = 2; page <= totalPages; page++) {
urls.push(`${REPUTATION_BASE}&page=${page}`);
pageNums.push(page);
}
const htmlPages = await fetchPool(urls, MAX_CONCURRENT_FETCHES);
for (let i = 0; i < htmlPages.length; i++) {
const pageNum = pageNums[i];
const parsed = parseReputationPage(htmlPages[i], pageNum);
Object.assign(merged, parsed);
progressCb?.(`Parsed page ${pageNum}/${totalPages}.`);
}
return { totalPages, entries: merged };
}
/******************************************************************
* DIFF / HISTORY / STATS
******************************************************************/
function diffSnapshots(oldSnap, newSnap) {
const oldData = oldSnap && typeof oldSnap === 'object' ? oldSnap : {};
const newData = newSnap && typeof newSnap === 'object' ? newSnap : {};
const added = [];
const removed = [];
const updated = [];
const oldKeys = new Set(Object.keys(oldData));
const newKeys = new Set(Object.keys(newData));
for (const key of newKeys) {
if (!oldKeys.has(key)) {
const item = sanitizeEntry(newData[key]);
if (item) added.push(item);
}
}
for (const key of oldKeys) {
if (!newKeys.has(key)) {
const item = sanitizeEntry(oldData[key]);
if (item) removed.push(item);
}
}
for (const key of newKeys) {
if (!oldKeys.has(key)) continue;
const before = sanitizeEntry(oldData[key]);
const after = sanitizeEntry(newData[key]);
if (!before || !after) continue;
const changes = [];
if ((before.type || '') !== (after.type || '')) changes.push(`type: ${before.type || 'unknown'} -> ${after.type || 'unknown'}`);
if ((before.score || '') !== (after.score || '')) changes.push(`score: ${before.score || 'none'} -> ${after.score || 'none'}`);
if ((before.comment || '') !== (after.comment || '')) changes.push('comment changed');
if (changes.length) {
updated.push({ before, after, changes });
}
}
return { added, removed, updated };
}
function isAcknowledgedChange(tokens, acknowledgedTokens) {
return tokens.some(token => acknowledgedTokens.has(token));
}
function filterAcknowledgedDiff(diff) {
const acknowledgedTokens = getAcknowledgedTokenSet();
return {
added: diff.added.filter(item => !isAcknowledgedChange(tokensForAdded(item), acknowledgedTokens)),
removed: diff.removed.filter(item => !isAcknowledgedChange(tokensForRemoved(item), acknowledgedTokens)),
updated: diff.updated.filter(item => !isAcknowledgedChange(tokensForUpdated(item), acknowledgedTokens))
};
}
function computeDiffDelta(diff) {
let delta = 0;
for (const item of diff.added) delta += parseScoreValue(item.score);
for (const item of diff.removed) delta -= parseScoreValue(item.score);
for (const item of diff.updated) {
delta += parseScoreValue(item.after.score) - parseScoreValue(item.before.score);
}
return delta;
}
function appendMemberHistory(diff) {
const history = getMemberHistory();
const stamp = now();
function push(uid, event) {
if (!uid) return;
if (!history[uid]) history[uid] = [];
history[uid].unshift({ ts: stamp, ...event });
while (history[uid].length > MAX_MEMBER_HISTORY) history[uid].pop();
}
for (const item of diff.added) {
push(item.senderUid, {
kind: 'new',
rid: item.rid,
type: item.type,
score: item.score,
comment: item.comment,
senderName: item.senderName,
senderHref: item.senderHref
});
}
for (const item of diff.removed) {
push(item.senderUid, {
kind: 'removed',
rid: item.rid,
type: item.type,
score: item.score,
comment: item.comment,
senderName: item.senderName,
senderHref: item.senderHref
});
}
for (const item of diff.updated) {
push(item.after.senderUid, {
kind: 'updated',
rid: item.after.rid,
typeBefore: item.before.type,
typeAfter: item.after.type,
scoreBefore: item.before.score,
scoreAfter: item.after.score,
commentBefore: item.before.comment,
commentAfter: item.after.comment,
senderName: item.after.senderName,
senderHref: item.after.senderHref
});
}
setMemberHistory(history);
}
function computeStats(snapshot) {
const entries = Object.values(snapshot || {}).map(sanitizeEntry).filter(Boolean);
let positives = 0;
let negatives = 0;
let neutrals = 0;
let totalRepScore = 0;
for (const entry of entries) {
totalRepScore += parseScoreValue(entry.score);
if (entry.type === 'positive') positives++;
else if (entry.type === 'negative') negatives++;
else if (entry.type === 'neutral') neutrals++;
}
const logs = getLogItems();
const cutoff = monthAgoTimestamp();
let monthlyDelta = 0;
for (const item of logs) {
if (!item || !item.ts || item.ts < cutoff) continue;
if (typeof item.delta === 'number') monthlyDelta += item.delta;
}
return {
totalRepScore,
positives,
negatives,
neutrals,
monthlyDelta,
totalEntries: entries.length
};
}
function buildSummaryFromDiff(diff) {
const addCount = diff.added.length;
const removeCount = diff.removed.length;
const updateCount = diff.updated.length;
const total = addCount + removeCount + updateCount;
return {
total,
addCount,
removeCount,
updateCount,
delta: computeDiffDelta(diff),
text: `${total} change(s): ${addCount} new, ${updateCount} updated, ${removeCount} removed.`
};
}
/******************************************************************
* NOTIFICATION FILTERS
******************************************************************/
function shouldNotifyItem(kind, type, isCommentOnlyChange) {
const s = getSettings();
if (kind === 'new' && !s.notifyNew) return false;
if (kind === 'updated' && !s.notifyUpdated) return false;
if (kind === 'removed' && !s.notifyRemoved) return false;
if (s.notifyCommentChangeOnly && kind === 'updated' && !isCommentOnlyChange) return false;
if (type === 'positive' && !s.notifyPositive) return false;
if (type === 'neutral' && !s.notifyNeutral) return false;
if (type === 'negative' && !s.notifyNegative) return false;
return true;
}
/******************************************************************
* ACTIONS
******************************************************************/
function openProfile(uid) {
if (!uid) return;
window.open(`${location.origin}/member.php?action=profile&uid=${uid}`, '_blank');
}
function openRepPage(uid) {
if (!uid) return;
window.open(`${location.origin}/reputation.php?uid=${uid}`, '_blank');
}
/******************************************************************
* UI
******************************************************************/
function injectStyles() {
if (document.getElementById('hf-pop-tracker-native-styles')) return;
const style = document.createElement('style');
style.id = 'hf-pop-tracker-native-styles';
style.textContent = `
#hf-popularity-toolbar-btn > a {
position: relative;
display: inline-flex !important;
align-items: center;
gap: 4px;
}
#hf-popularity-toolbar-btn.active > a {
color: #7bc2ff !important;
}
#hf-popularity-toolbar-btn .hf-popularity-num {
position: relative;
display: none;
min-width: 16px;
height: 16px;
line-height: 16px;
border-radius: 999px;
background: #1f8bff;
color: #fff;
font-size: 10px;
font-weight: 700;
text-align: center;
padding: 0 4px;
margin-left: 2px;
top: -1px;
}
#hf-popularity-container {
display: none;
position: absolute;
width: 520px;
max-width: calc(100vw - 24px);
z-index: 99999;
background: #111;
border: 1px solid rgba(255,255,255,.06);
box-shadow: 0 12px 36px rgba(0,0,0,.4);
overflow: hidden;
}
#hf-popularity-container.show {
display: block;
}
#hf-popularity-container .notify-header {
padding: 10px 12px;
background-color: #454545;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
#hf-popularity-container .notify-header-heading {
font-size: 16px;
color: #fff;
font-weight: 600;
}
#hf-popularity-container .hf-pop-head-right {
display: flex;
align-items: center;
gap: 14px;
}
#hf-popularity-container .hf-pop-head-right a {
color: #fff;
text-decoration: none;
}
#hf-popularity-notifications-container {
background: #111;
max-height: 75vh;
overflow-y: auto;
}
#hf-popularity-notifications-container .notify-section-header {
padding: 8px 12px;
background: #1b1b1b;
border-top: 1px solid rgba(255,255,255,.04);
border-bottom: 1px solid rgba(255,255,255,.04);
}
#hf-popularity-notifications-container .notify-section-header .notify-header-heading {
color: #ddd;
font-size: 13px;
font-weight: 600;
}
#hf-popularity-notifications-container .notify-notice-container {
display: flex;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,.05);
background: #111;
}
#hf-popularity-notifications-container .notify-notice-container:hover {
background: #171717;
}
#hf-popularity-notifications-container .notify-notice-avatar-container {
width: 36px;
min-width: 36px;
display: flex;
align-items: flex-start;
justify-content: center;
}
#hf-popularity-notifications-container .notify-notice-avatar {
width: 36px;
height: 36px;
border-radius: 2px;
overflow: hidden;
background: #181818;
display: flex;
align-items: center;
justify-content: center;
}
#hf-popularity-notifications-container .notify-notice-avatar i {
font-size: 16px;
}
#hf-popularity-notifications-container .notify-notice-details-container {
width: 100%;
min-width: 0;
}
#hf-popularity-notifications-container .hf-pop-tinytext {
color: #888;
font-size: 11px;
margin-top: -1px;
}
#hf-popularity-notifications-container .hf-pop-mainline {
margin-top: 3px;
color: #ababab;
line-height: 1.45;
font-size: 13px;
word-break: break-word;
}
#hf-popularity-notifications-container .hf-pop-mainline strong {
color: #ddd;
}
#hf-popularity-notifications-container .hf-pop-controls,
#hf-popularity-notifications-container .hf-pop-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
#hf-popularity-notifications-container .hf-pop-btn,
#hf-popularity-notifications-container .hf-pop-filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: #1b1b1b;
color: #ddd;
border: 1px solid rgba(255,255,255,.08);
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
text-decoration: none;
cursor: pointer;
}
#hf-popularity-notifications-container .hf-pop-btn:hover,
#hf-popularity-notifications-container .hf-pop-filter-btn:hover,
#hf-popularity-notifications-container .hf-pop-filter-btn.active {
background: #252525;
color: #fff;
}
#hf-popularity-notifications-container .hf-pop-overview-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
width: 100%;
}
#hf-popularity-notifications-container .hf-pop-stat-box {
background: #181818;
border: 1px solid rgba(255,255,255,.05);
padding: 8px;
border-radius: 4px;
}
#hf-popularity-notifications-container .hf-pop-stat-label {
font-size: 10px;
color: #888;
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 4px;
}
#hf-popularity-notifications-container .hf-pop-stat-value {
font-size: 15px;
color: #f0f0f0;
font-weight: 700;
}
#hf-pop-search {
width: 100%;
box-sizing: border-box;
background: #1b1b1b;
color: #eee;
border: 1px solid rgba(255,255,255,.08);
padding: 8px 10px;
font-size: 12px;
outline: none;
}
#hf-pop-member-modal {
display: none;
position: fixed;
z-index: 100000;
inset: 0;
background: rgba(0,0,0,.6);
}
#hf-pop-member-modal.show {
display: block;
}
#hf-pop-member-modal .hf-pop-member-card {
width: 560px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 40px);
overflow-y: auto;
background: #111;
border: 1px solid rgba(255,255,255,.08);
box-shadow: 0 18px 48px rgba(0,0,0,.5);
margin: 20px auto;
}
#hf-pop-member-modal .hf-pop-member-head {
padding: 12px;
background: #454545;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
#hf-pop-member-modal .hf-pop-member-body {
padding: 12px;
}
#hf-pop-member-modal .hf-pop-member-history-item {
border-bottom: 1px solid rgba(255,255,255,.05);
padding: 10px 0;
}
.hf-type-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
color: #fff;
}
.hf-type-positive { background: #2e7d32; }
.hf-type-negative { background: #b71c1c; }
.hf-type-neutral { background: #8d6e00; }
.hf-type-unknown { background: #666; }
`;
document.head.appendChild(style);
}
function getAvatarHtml(iconClass, color) {
return `
<div class="notify-notice-avatar-container">
<div class="notify-notice-avatar">
<i class="${escapeHtml(iconClass)}" aria-hidden="true" style="color:${escapeHtml(color)};"></i>
</div>
</div>
`;
}
function closeDropdown() {
if (!dropdownContainer) return;
dropdownContainer.classList.remove('show');
toolbarButtonLi?.classList.remove('active');
}
function openDropdown() {
createDropdown();
dropdownContainer.classList.add('show');
positionDropdown();
toolbarButtonLi?.classList.add('active');
renderDropdownContent();
}
function toggleDropdown() {
if (!dropdownContainer || !dropdownContainer.classList.contains('show')) openDropdown();
else closeDropdown();
}
function positionDropdown() {
if (!toolbarButtonAnchor || !dropdownContainer) return;
const rect = toolbarButtonAnchor.getBoundingClientRect();
const top = rect.bottom + window.scrollY + 8;
let left = rect.left + window.scrollX - 20;
const maxLeft = window.scrollX + window.innerWidth - dropdownContainer.offsetWidth - 12;
if (left > maxLeft) left = Math.max(window.scrollX + 12, maxLeft);
if (left < window.scrollX + 12) left = window.scrollX + 12;
dropdownContainer.style.top = `${top}px`;
dropdownContainer.style.left = `${left}px`;
}
function createMemberModal() {
if (memberModal) return;
memberModal = document.createElement('div');
memberModal.id = 'hf-pop-member-modal';
memberModal.innerHTML = `
<div class="hf-pop-member-card">
<div class="hf-pop-member-head">
<span id="hf-pop-member-title">Member Summary</span>
<a href="javascript:void(0)" id="hf-pop-member-close" style="color:#fff;"><i class="fa fa-times-circle fa-lg"></i></a>
</div>
<div class="hf-pop-member-body" id="hf-pop-member-body"></div>
</div>
`;
document.body.appendChild(memberModal);
memberModal.addEventListener('click', (e) => {
if (e.target === memberModal) memberModal.classList.remove('show');
});
memberModal.querySelector('#hf-pop-member-close').addEventListener('click', (e) => {
e.preventDefault();
memberModal.classList.remove('show');
});
}
function openMemberSummary(uid) {
if (!uid) return;
createMemberModal();
const snapshot = getSnapshot();
const history = getMemberHistory();
const currentEntries = Object.values(snapshot).map(sanitizeEntry).filter(x => x && x.senderUid === uid);
const memberHist = history[uid] || [];
const name = currentEntries[0]?.senderName || memberHist[0]?.senderName || `UID ${uid}`;
const totalCurrent = currentEntries.reduce((sum, x) => sum + parseScoreValue(x.score), 0);
const currentHtml = currentEntries.length
? currentEntries.map(entry => `
<div class="hf-pop-member-history-item">
<div class="hf-pop-tinytext">Current entry - RID ${escapeHtml(entry.rid)}</div>
<div class="hf-pop-mainline">
<span class="hf-type-pill hf-type-${escapeHtml(entry.type)}">${escapeHtml(entry.type)} ${escapeHtml(entry.score)}</span>
<div style="margin-top:6px;">${escapeHtml(entry.comment || '(no comment)')}</div>
</div>
</div>
`).join('')
: `<div class="hf-pop-member-history-item"><div class="hf-pop-mainline">No current entries for this member.</div></div>`;
const historyHtml = memberHist.length
? memberHist.map(item => `
<div class="hf-pop-member-history-item">
<div class="hf-pop-tinytext">${escapeHtml(new Date(item.ts).toLocaleString())} - ${escapeHtml(item.kind)}</div>
<div class="hf-pop-mainline">
${
item.kind === 'updated'
? `
<div><strong>Score:</strong> ${escapeHtml(item.scoreBefore || '0')} -> ${escapeHtml(item.scoreAfter || '0')}</div>
<div><strong>Comment:</strong> "${escapeHtml(item.commentBefore || '')}" -> "${escapeHtml(item.commentAfter || '')}"</div>
`
: `
<div><strong>Type:</strong> ${escapeHtml(item.type || '')} ${escapeHtml(item.score || '')}</div>
<div><strong>Comment:</strong> ${escapeHtml(item.comment || '(no comment)')}</div>
`
}
</div>
</div>
`).join('')
: `<div class="hf-pop-member-history-item"><div class="hf-pop-mainline">No recorded history yet.</div></div>`;
memberModal.querySelector('#hf-pop-member-title').textContent = `Member Summary - ${name}`;
memberModal.querySelector('#hf-pop-member-body').innerHTML = `
<div class="hf-pop-controls" style="margin-bottom:12px;">
<a href="javascript:void(0)" class="hf-pop-btn" id="hf-pop-member-open-profile"><i class="fa fa-user"></i><span>Open Profile</span></a>
<a href="javascript:void(0)" class="hf-pop-btn" id="hf-pop-member-return-rep"><i class="fa fa-star"></i><span>Return Rep</span></a>
</div>
<div class="hf-pop-overview-grid" style="margin-bottom:12px;">
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Member UID</div>
<div class="hf-pop-stat-value">${escapeHtml(uid)}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Current Entries</div>
<div class="hf-pop-stat-value">${currentEntries.length}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Current Score Sum</div>
<div class="hf-pop-stat-value">${totalCurrent >= 0 ? '+' : ''}${totalCurrent}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">History Events</div>
<div class="hf-pop-stat-value">${memberHist.length}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Name</div>
<div class="hf-pop-stat-value" style="font-size:12px;">${escapeHtml(name)}</div>
</div>
</div>
<div class="notify-section-header"><span class="notify-header-heading">Current Entries</span></div>
${currentHtml}
<div class="notify-section-header" style="margin-top:12px;"><span class="notify-header-heading">History</span></div>
${historyHtml}
`;
memberModal.querySelector('#hf-pop-member-open-profile').addEventListener('click', (e) => {
e.preventDefault();
openProfile(uid);
});
memberModal.querySelector('#hf-pop-member-return-rep').addEventListener('click', (e) => {
e.preventDefault();
openRepPage(uid);
});
memberModal.classList.add('show');
}
function createDropdown() {
if (dropdownContainer) return;
dropdownContainer = document.createElement('div');
dropdownContainer.id = 'hf-popularity-container';
dropdownContainer.innerHTML = `
<div class="notify-header">
<span class="notify-header-heading">Popularity Tracker</span>
<div class="hf-pop-head-right">
<a href="javascript:void(0);" id="hf-pop-header-scan" title="Scan now"><i class="fa fa-sync-alt fa-lg"></i></a>
<a href="javascript:void(0);" id="hf-pop-header-toggle-auto" title="Toggle hourly auto scan"><i class="fa fa-bolt fa-lg"></i></a>
<a href="javascript:void(0);" id="hf-pop-header-reset" title="Reset baseline"><i class="fa fa-undo fa-lg"></i></a>
<a href="javascript:void(0);" id="hf-pop-header-close" title="Close"><i class="fa fa-times-circle fa-lg"></i></a>
</div>
</div>
<div id="hf-popularity-notifications-container"></div>
`;
document.body.appendChild(dropdownContainer);
dropdownBody = dropdownContainer.querySelector('#hf-popularity-notifications-container');
dropdownContainer.querySelector('#hf-pop-header-scan').addEventListener('click', (e) => {
e.preventDefault();
runScan(true, true);
});
dropdownContainer.querySelector('#hf-pop-header-toggle-auto').addEventListener('click', (e) => {
e.preventDefault();
setAutoEnabled(!isAutoEnabled());
pushLog('info', `Auto scan turned ${isAutoEnabled() ? 'on' : 'off'}.`);
updateToolbarState();
renderDropdownContent();
});
dropdownContainer.querySelector('#hf-pop-header-reset').addEventListener('click', (e) => {
e.preventDefault();
resetBaseline();
});
dropdownContainer.querySelector('#hf-pop-header-close').addEventListener('click', (e) => {
e.preventDefault();
closeDropdown();
});
document.addEventListener('click', (e) => {
if (!dropdownContainer || !toolbarButtonLi) return;
if (!dropdownContainer.classList.contains('show')) return;
const insideDropdown = dropdownContainer.contains(e.target);
const insideButton = toolbarButtonLi.contains(e.target);
const insideModal = memberModal?.contains(e.target);
if (!insideDropdown && !insideButton && !insideModal) closeDropdown();
});
window.addEventListener('resize', () => {
if (dropdownContainer?.classList.contains('show')) positionDropdown();
});
window.addEventListener('scroll', () => {
if (dropdownContainer?.classList.contains('show')) positionDropdown();
}, true);
}
function renderOverviewBlock() {
const meta = getMeta();
const stats = computeStats(getSnapshot());
const backoffLine = meta.backoffUntil && now() < meta.backoffUntil
? `<br><strong>Paused:</strong> retrying in ${escapeHtml(timeAgo(meta.backoffUntil))}`
: '';
const monthly = stats.monthlyDelta >= 0 ? `+${stats.monthlyDelta}` : `${stats.monthlyDelta}`;
return `
<div class="notify-section-header">
<span class="notify-header-heading">Scoreboard</span>
</div>
<div class="notify-notice-container">
${getAvatarHtml('fa fa-chart-line', '#4ea1ff')}
<div class="notify-notice-details-container">
<div class="hf-pop-overview-grid">
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Total Rep Score</div>
<div class="hf-pop-stat-value">${stats.totalRepScore >= 0 ? '+' : ''}${stats.totalRepScore}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Positives</div>
<div class="hf-pop-stat-value" style="color:#4caf50;">${stats.positives}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Negatives</div>
<div class="hf-pop-stat-value" style="color:#ff5f56;">${stats.negatives}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Neutrals</div>
<div class="hf-pop-stat-value" style="color:#d6a63a;">${stats.neutrals}</div>
</div>
<div class="hf-pop-stat-box">
<div class="hf-pop-stat-label">Monthly +/-</div>
<div class="hf-pop-stat-value" style="color:${stats.monthlyDelta >= 0 ? '#4caf50' : '#ff5f56'};">${monthly}</div>
</div>
</div>
<div class="hf-pop-mainline" style="margin-top:10px;">
<strong>UID:</strong> ${escapeHtml(CURRENT_UID || '?')}<br>
<strong>Pages:</strong> ${meta.lastPageCount || 0}<br>
<strong>Last Scan:</strong> ${escapeHtml(timeAgo(meta.lastScan || 0))}<br>
<strong>Acknowledged:</strong> ${escapeHtml(timeAgo(getAcknowledgedAt()))}<br>
<strong>Baseline:</strong> ${meta.initialized ? 'Ready' : 'Not initialized'}<br>
<strong>Hourly auto scan:</strong> ${isAutoEnabled() ? 'On' : 'Off'}<br>
<strong>Status:</strong> ${scanInProgress ? 'Scanning...' : 'Idle'}${backoffLine}
</div>
<div class="hf-pop-controls">
<a href="javascript:void(0)" class="hf-pop-btn" id="hf-pop-btn-scan"><i class="fa fa-sync-alt"></i><span>Scan Now</span></a>
<a href="javascript:void(0)" class="hf-pop-btn" id="hf-pop-btn-auto"><i class="fa fa-bolt"></i><span>Hourly: ${isAutoEnabled() ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn" id="hf-pop-btn-ack"><i class="fa fa-check-circle"></i><span>Acknowledge All</span></a>
<a href="javascript:void(0)" class="hf-pop-btn" id="hf-pop-btn-reset"><i class="fa fa-undo"></i><span>Reset Baseline</span></a>
</div>
</div>
</div>
`;
}
function renderFiltersBlock() {
const settings = getSettings();
const filters = [
['all', 'All'],
['new', 'New'],
['updated', 'Updated'],
['removed', 'Removed'],
['positive', 'Positive'],
['neutral', 'Neutral'],
['negative', 'Negative']
];
return `
<div class="notify-section-header">
<span class="notify-header-heading">Filters & Notifications</span>
</div>
<div class="notify-notice-container">
${getAvatarHtml('fa fa-filter', '#9c7cff')}
<div class="notify-notice-details-container">
<input id="hf-pop-search" type="text" placeholder="Search by name or comment..." value="${escapeHtml(uiState.search)}">
<div class="hf-pop-filter-row">
${filters.map(([key, label]) => `
<a href="javascript:void(0)" class="hf-pop-filter-btn ${uiState.filterType === key ? 'active' : ''}" data-filter="${escapeHtml(key)}">${escapeHtml(label)}</a>
`).join('')}
</div>
<div class="hf-pop-controls" style="margin-top:10px;">
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="enableToasts"><i class="fa fa-bell"></i><span>Toasts: ${settings.enableToasts ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyNew"><span>New: ${settings.notifyNew ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyUpdated"><span>Updated: ${settings.notifyUpdated ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyRemoved"><span>Removed: ${settings.notifyRemoved ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyPositive"><span>+ : ${settings.notifyPositive ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyNeutral"><span>0 : ${settings.notifyNeutral ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyNegative"><span>- : ${settings.notifyNegative ? 'On' : 'Off'}</span></a>
<a href="javascript:void(0)" class="hf-pop-btn hf-pop-setting-toggle" data-key="notifyCommentChangeOnly"><span>Comment-only updates: ${settings.notifyCommentChangeOnly ? 'On' : 'Off'}</span></a>
</div>
</div>
</div>
`;
}
function iconForLogType(type) {
switch (type) {
case 'change': return { icon: 'fa fa-star', color: '#ffcc4d' };
case 'changes': return { icon: 'fa fa-bell', color: '#4ea1ff' };
case 'baseline': return { icon: 'fa fa-database', color: '#8bc34a' };
case 'error': return { icon: 'fa fa-exclamation-triangle', color: '#ff5f56' };
case 'scan': return { icon: 'fa fa-sync-alt', color: '#9c7cff' };
default: return { icon: 'fa fa-info-circle', color: '#9e9e9e' };
}
}
function filterLogs(logs) {
const term = normalizeText(uiState.search).toLowerCase();
const acknowledgedAt = getAcknowledgedAt();
const acknowledgedTokens = getAcknowledgedTokenSet();
return logs.filter(item => {
const msg = normalizeText(item.message).toLowerCase();
const name = normalizeText(item.senderName || '').toLowerCase();
const type = normalizeText(item.repType || '').toLowerCase();
if (!term && item.type === 'change' && (item.ts <= acknowledgedAt || isLogItemAcknowledged(item, acknowledgedTokens))) return false;
if (!term && item.type !== 'change' && item.ts <= acknowledgedAt) return false;
if (term && !msg.includes(term) && !name.includes(term)) return false;
const f = uiState.filterType;
if (f === 'all') return true;
if (f === 'new') return item.changeKind === 'new';
if (f === 'updated') return item.changeKind === 'updated';
if (f === 'removed') return item.changeKind === 'removed';
if (f === 'positive') return type === 'positive';
if (f === 'neutral') return type === 'neutral';
if (f === 'negative') return type === 'negative';
return true;
});
}
function renderLogControls(item) {
const uid = item.senderUid || '';
return `
<div class="hf-pop-controls">
${uid ? `<a href="javascript:void(0)" class="hf-pop-btn hf-pop-open-profile" data-uid="${escapeHtml(uid)}"><i class="fa fa-user"></i><span>Profile</span></a>` : ''}
${uid ? `<a href="javascript:void(0)" class="hf-pop-btn hf-pop-return-rep" data-uid="${escapeHtml(uid)}"><i class="fa fa-star"></i><span>Return Rep</span></a>` : ''}
${uid ? `<a href="javascript:void(0)" class="hf-pop-btn hf-pop-member-summary" data-uid="${escapeHtml(uid)}"><i class="fa fa-id-card"></i><span>Summary</span></a>` : ''}
</div>
`;
}
function renderTypePill(type, score) {
const cls = `hf-type-${escapeHtml(type || 'unknown')}`;
return `<span class="hf-type-pill ${cls}"><i class="${escapeHtml(typeIcon(type))}"></i>${escapeHtml(type || 'unknown')} ${escapeHtml(score || '')}</span>`;
}
function renderActivityBlock() {
const logs = filterLogs(getLogItems());
if (!logs.length) {
return `
<div class="notify-section-header">
<span class="notify-header-heading">Recent Activity</span>
</div>
<div class="notify-notice-container">
${getAvatarHtml('fa fa-inbox', '#9e9e9e')}
<div class="notify-notice-details-container">
<div class="hf-pop-mainline">No matching entries.</div>
</div>
</div>
`;
}
return `
<div class="notify-section-header">
<span class="notify-header-heading">Recent Activity</span>
</div>
${logs.map(item => {
const visual = item.repType ? { icon: typeIcon(item.repType), color: typeColor(item.repType) } : iconForLogType(item.type);
return `
<div class="notify-notice-container">
${getAvatarHtml(visual.icon, visual.color)}
<div class="notify-notice-details-container">
<div class="hf-pop-tinytext">${escapeHtml(new Date(item.ts).toLocaleString())}</div>
<div class="hf-pop-mainline">
${item.repType ? renderTypePill(item.repType, item.repScore) : ''}
<div style="margin-top:${item.repType ? '6px' : '0'};">${escapeHtml(item.message)}</div>
</div>
${renderLogControls(item)}
</div>
</div>
`;
}).join('')}
`;
}
function bindControls() {
dropdownBody.querySelector('#hf-pop-btn-scan')?.addEventListener('click', (e) => {
e.preventDefault();
runScan(true, true);
});
dropdownBody.querySelector('#hf-pop-btn-auto')?.addEventListener('click', (e) => {
e.preventDefault();
setAutoEnabled(!isAutoEnabled());
pushLog('info', `Auto scan turned ${isAutoEnabled() ? 'on' : 'off'}.`);
renderDropdownContent();
});
dropdownBody.querySelector('#hf-pop-btn-ack')?.addEventListener('click', (e) => {
e.preventDefault();
acknowledgeAll();
});
dropdownBody.querySelector('#hf-pop-btn-reset')?.addEventListener('click', (e) => {
e.preventDefault();
resetBaseline();
});
dropdownBody.querySelector('#hf-pop-search')?.addEventListener('input', (e) => {
uiState.search = e.target.value || '';
renderDropdownContent();
});
dropdownBody.querySelectorAll('.hf-pop-filter-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
uiState.filterType = btn.getAttribute('data-filter') || 'all';
renderDropdownContent();
});
});
dropdownBody.querySelectorAll('.hf-pop-setting-toggle').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const key = btn.getAttribute('data-key');
const settings = getSettings();
settings[key] = !settings[key];
setSettings(settings);
renderDropdownContent();
});
});
dropdownBody.querySelectorAll('.hf-pop-open-profile').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
openProfile(btn.getAttribute('data-uid') || '');
});
});
dropdownBody.querySelectorAll('.hf-pop-return-rep').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
openRepPage(btn.getAttribute('data-uid') || '');
});
});
dropdownBody.querySelectorAll('.hf-pop-member-summary').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
openMemberSummary(btn.getAttribute('data-uid') || '');
});
});
}
function renderDropdownContent() {
if (!dropdownBody || !STORAGE_KEYS) return;
dropdownBody.innerHTML = `
${renderOverviewBlock()}
${renderFiltersBlock()}
${renderActivityBlock()}
`;
bindControls();
}
function buildToolbarUI() {
injectStyles();
createDropdown();
createMemberModal();
const host =
document.querySelector('.lower.panel-nav-lower .menu.panel_links') ||
document.querySelector('ul.menu.panel_links') ||
document.querySelector('ul.menu.user_links');
if (!host) throw new Error('Could not find the HF toolbar host.');
let li = document.getElementById('hf-popularity-toolbar-btn');
if (!li) {
li = document.createElement('li');
li.id = 'hf-popularity-toolbar-btn';
li.className = 'button-dropdown';
li.innerHTML = `
<a href="javascript:void(0)" class="dropdown-toggle" data-tooltip="Popularity Tracker">
<i class="${ICON_PRIMARY}" id="hf-pop-toolbar-icon" aria-hidden="true"></i>
<span>▾</span>
<span class="hf-popularity-num">0</span>
</a>
`;
host.appendChild(li);
}
toolbarButtonLi = li;
toolbarButtonAnchor = li.querySelector('a');
toolbarButtonIcon = li.querySelector('#hf-pop-toolbar-icon');
toolbarBadge = li.querySelector('.hf-popularity-num');
toolbarButtonAnchor.addEventListener('click', (e) => {
e.preventDefault();
toggleDropdown();
});
setTimeout(() => {
if (toolbarButtonIcon && toolbarButtonIcon.offsetWidth === 0) {
toolbarButtonIcon.className = ICON_FALLBACK;
}
}, 1000);
updateToolbarState();
}
function updateToolbarState(changeCount = getUnacknowledgedChangeCount()) {
if (toolbarBadge) {
toolbarBadge.textContent = String(changeCount);
toolbarBadge.style.display = changeCount > 0 ? 'inline-flex' : 'none';
}
if (toolbarButtonLi) {
const active = scanInProgress || dropdownContainer?.classList.contains('show');
toolbarButtonLi.classList.toggle('active', !!active);
}
}
/******************************************************************
* CHANGE REPORTING
******************************************************************/
function formatAddedItem(item) {
return `${item.senderName || 'Unknown'} left ${item.type}${item.score ? ` (${item.score})` : ''}${item.comment ? ` - "${item.comment}"` : ''}`;
}
function formatRemovedItem(item) {
return `${item.senderName || 'Unknown'}'s popularity entry disappeared`;
}
function formatUpdatedItem(update) {
const after = update.after;
const before = update.before;
let detail = `${after.senderName || 'Unknown'} updated popularity: ${update.changes.join(', ')}`;
if ((before.comment || '') !== (after.comment || '')) {
detail += ` | old: "${before.comment || ''}" | new: "${after.comment || ''}"`;
}
return detail;
}
function writeDiffToLog(diff, summary) {
pushLog('changes', summary.text, { delta: summary.delta });
for (const item of diff.added) {
const ackTokens = tokensForAdded(item);
pushLog('change', `New popularity: ${formatAddedItem(item)}`, {
senderUid: item.senderUid,
senderName: item.senderName,
repType: item.type,
repScore: item.score,
changeKind: 'new',
changeSignature: ackTokens[0] || '',
ackTokens,
delta: parseScoreValue(item.score)
});
}
for (const item of diff.updated) {
const before = parseScoreValue(item.before.score);
const after = parseScoreValue(item.after.score);
const ackTokens = tokensForUpdated(item);
pushLog('change', `Popularity updated: ${formatUpdatedItem(item)}`, {
senderUid: item.after.senderUid,
senderName: item.after.senderName,
repType: item.after.type,
repScore: item.after.score,
changeKind: 'updated',
changeSignature: ackTokens[0] || '',
ackTokens,
delta: after - before
});
}
for (const item of diff.removed) {
const ackTokens = tokensForRemoved(item);
pushLog('change', `Popularity removed: ${formatRemovedItem(item)}`, {
senderUid: item.senderUid,
senderName: item.senderName,
repType: item.type,
repScore: item.score,
changeKind: 'removed',
changeSignature: ackTokens[0] || '',
ackTokens,
delta: -parseScoreValue(item.score)
});
}
}
function sendGroupedToasts(diff, summary) {
const settings = getSettings();
if (!settings.enableToasts || summary.total === 0) return;
const anyNotify =
diff.added.some(x => shouldNotifyItem('new', x.type, false)) ||
diff.updated.some(x => shouldNotifyItem('updated', x.after.type, x.before.comment !== x.after.comment && x.before.score === x.after.score && x.before.type === x.after.type)) ||
diff.removed.some(x => shouldNotifyItem('removed', x.type, false));
if (!anyNotify) return;
notify('HF Popularity Tracker', summary.text, 12000);
const details = [];
for (const item of diff.added) {
if (shouldNotifyItem('new', item.type, false)) {
details.push({ title: 'New popularity', text: formatAddedItem(item) });
}
}
for (const item of diff.updated) {
const commentOnly = item.before.comment !== item.after.comment &&
item.before.score === item.after.score &&
item.before.type === item.after.type;
if (shouldNotifyItem('updated', item.after.type, commentOnly)) {
details.push({ title: 'Popularity updated', text: formatUpdatedItem(item) });
}
}
for (const item of diff.removed) {
if (shouldNotifyItem('removed', item.type, false)) {
details.push({ title: 'Popularity removed', text: formatRemovedItem(item) });
}
}
for (const detail of details.slice(0, MAX_DETAIL_TOASTS)) {
notify(detail.title, detail.text, 10000);
}
}
/******************************************************************
* MAIN
******************************************************************/
async function runScan(manual = false, ignoreCooldown = false) {
if (scanInProgress) {
if (manual) pushLog('info', 'A scan is already in progress.');
return;
}
ensureUid();
const metaBefore = getMeta();
if (!ignoreCooldown) {
if (metaBefore.backoffUntil && now() < metaBefore.backoffUntil) {
if (manual) {
pushLog('info', `Scan paused due to server backoff. Try again in ${timeAgo(metaBefore.backoffUntil)}.`);
openDropdown();
}
return;
}
if (metaBefore.lastScan && (now() - metaBefore.lastScan) < (AUTO_SCAN_MINUTES * 60 * 1000)) {
if (manual) {
pushLog('info', `Recent scan already completed ${timeAgo(metaBefore.lastScan)}. Skipping duplicate scan.`);
openDropdown();
}
return;
}
}
scanInProgress = true;
updateToolbarState();
renderDropdownContent();
try {
pushLog('scan', 'Starting popularity scan...');
const result = await scanAllPages((msg) => log(msg));
const newSnapshot = result.entries || {};
const oldSnapshot = getSnapshot();
const meta = getMeta();
const oldCount = Object.keys(oldSnapshot).length;
const newCount = Object.keys(newSnapshot).length;
meta.lastScan = Date.now();
meta.lastPageCount = result.totalPages;
meta.lastEntryCount = newCount;
meta.uid = CURRENT_UID;
meta.backoffUntil = 0;
meta.lastError = '';
if (!meta.initialized || oldCount === 0) {
setSnapshot(newSnapshot);
meta.initialized = true;
setMeta(meta);
pushLog('baseline', `Baseline saved silently with ${newCount} entries across ${result.totalPages} page(s).`);
if (manual) {
pushLog('info', 'Initial scan completed. No toasts were sent because this was the baseline.');
openDropdown();
}
updateToolbarState();
return;
}
const diff = diffSnapshots(oldSnapshot, newSnapshot);
const summary = buildSummaryFromDiff(diff);
const reportableDiff = filterAcknowledgedDiff(diff);
const reportableSummary = buildSummaryFromDiff(reportableDiff);
meta.lastSummary = summary;
setSnapshot(newSnapshot);
setMeta(meta);
if (summary.total === 0) {
pushLog('scan', `No changes found. ${newCount} entries scanned across ${result.totalPages} page(s).`);
if (manual) openDropdown();
updateToolbarState();
return;
}
if (reportableSummary.total === 0) {
pushLog('scan', `No new unacknowledged changes. ${summary.total} acknowledged repeat(s) ignored.`);
if (manual) openDropdown();
updateToolbarState();
return;
}
appendMemberHistory(reportableDiff);
writeDiffToLog(reportableDiff, reportableSummary);
sendGroupedToasts(reportableDiff, reportableSummary);
updateToolbarState();
openDropdown();
} catch (err) {
console.error('[HF Pop Tracker] Scan error:', err);
const meta = getMeta();
meta.lastError = String(err);
if (/HTTP 503|HTTP 429|HTTP 502|HTTP 500/i.test(String(err))) {
meta.backoffUntil = now() + (ERROR_BACKOFF_MINUTES * 60 * 1000);
setMeta(meta);
pushLog('error', `${String(err)} - pausing scans for ${ERROR_BACKOFF_MINUTES} minutes.`);
} else {
setMeta(meta);
pushLog('error', String(err));
}
if (manual) openDropdown();
} finally {
scanInProgress = false;
updateToolbarState();
renderDropdownContent();
}
}
function resetBaseline() {
ensureUid();
localStorage.removeItem(STORAGE_KEYS.snapshot);
localStorage.removeItem(STORAGE_KEYS.meta);
localStorage.removeItem(STORAGE_KEYS.memberHistory);
localStorage.removeItem(STORAGE_KEYS.acknowledgedAt);
localStorage.removeItem(STORAGE_KEYS.acknowledgedTokens);
setMeta({
initialized: false,
lastScan: 0,
lastPageCount: 0,
lastEntryCount: 0,
uid: CURRENT_UID,
backoffUntil: 0,
lastError: '',
lastSummary: null
});
pushLog('baseline', 'Baseline reset. Next scan will silently rebuild it.');
updateToolbarState();
renderDropdownContent();
openDropdown();
}
function maybeStartupScan() {
const meta = getMeta();
if (meta.backoffUntil && now() < meta.backoffUntil) {
pushLog('info', 'Startup auto-scan skipped due to backoff. Retrying later.');
return;
}
if (meta.lastScan && (now() - meta.lastScan) < (STARTUP_SCAN_MIN_AGE_SECONDS * 1000)) {
log('Skipping startup scan because a recent scan already happened.');
return;
}
runScan(false, false);
}
function startAutoLoop() {
if (autoLoopStarted) return;
autoLoopStarted = true;
setInterval(() => {
try {
ensureUid();
} catch {
return;
}
if (!isAutoEnabled()) return;
runScan(false, false);
}, AUTO_SCAN_MINUTES * 60 * 1000);
}
function installMenuCommands() {
GM_registerMenuCommand('HF Pop Tracker: Scan now', () => runScan(true, true));
GM_registerMenuCommand('HF Pop Tracker: Open dropdown', () => openDropdown());
GM_registerMenuCommand('HF Pop Tracker: Acknowledge all', () => {
ensureUid();
acknowledgeAll();
});
GM_registerMenuCommand('HF Pop Tracker: Reset baseline', () => resetBaseline());
GM_registerMenuCommand('HF Pop Tracker: Toggle auto', () => {
ensureUid();
setAutoEnabled(!isAutoEnabled());
renderDropdownContent();
pushLog('info', `Auto scan turned ${isAutoEnabled() ? 'on' : 'off'}.`);
});
}
function init() {
ensureUid();
buildToolbarUI();
installMenuCommands();
startAutoLoop();
setTimeout(() => {
maybeStartupScan();
}, 2500);
}
init();
})();