Manifest version: 1.0.12. These are the complete files packaged in the Firefox add-on, copied without changes. This page follows the latest website deployment; your installed version may differ.
Download file · SHA-256: e9f4f844467a819466f1ef421aa2af5b35142a8b35cfa959904ebe15da51063a
// Generated by node scripts/build.cjs. Edit app/ instead.
(function () {
"use strict";
var DEFAULT_CONFIG = {
karma: {
enabled: true,
ceiling: 400000,
poor: { below: 2, weight: 0 },
adequate: { below: 1000, weight: 1 },
good: { above: 1000, weight: 2 },
},
comments: {
enabled: true,
ceiling: 1000,
poor: { below: 3, weight: 0 },
adequate: { below: 100, weight: 1 },
good: { above: 99, weight: 2 },
},
submissions: {
enabled: true,
ceiling: 1 / 14,
poor: { above: 1, weight: 0 },
adequate: { above: 1 / 7, weight: 1 },
good: { below: 1 / 7, weight: 2 },
},
comment_rate: {
enabled: false,
ceiling: 10,
poor: { below: 1 / 30, weight: 0 },
adequate: { below: 1, weight: 1 },
good: { above: 1, weight: 2 },
},
created: {
enabled: true,
ceiling: '2009-01-01',
poor: { below: 365, weight: 0 },
adequate: { below: 1825, weight: 1 },
good: { above: 1825, weight: 2 },
},
minTrustScore: 2,
sortSparklines: false,
invertSparklines: false,
sortByTrust: false,
};
function deepMerge(defaults, overrides) {
var result = JSON.parse(JSON.stringify(defaults));
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return result;
Object.keys(defaults).forEach(function (key) {
var value = overrides[key];
if (defaults[key] && typeof defaults[key] === 'object') {
result[key] = deepMerge(defaults[key], value);
} else if (typeof value === typeof defaults[key]) {
if (typeof value === 'number' && (!Number.isFinite(value) ||
(key === 'ceiling' && value <= 0) ||
(key !== 'weight' && value < 0))) return;
if (typeof value === 'string' && (!Number.isFinite(Date.parse(value)) || Date.parse(value) >= Date.now())) return;
result[key] = value;
}
});
return result;
}
// ── Signal evaluation ──────────────────────────────────────────────
function evalKarma(profile, cfg) {
var k = profile.karma || 0;
var bar = Math.min(1, Math.log(Math.max(0, k) + 1) / Math.log(cfg.ceiling + 1));
var condition;
if (k < cfg.poor.below) condition = 'poor';
else if (k < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalComments(profile, cfg) {
var c = profile.total_comments || 0;
var bar = Math.min(1, c / cfg.ceiling);
var condition;
if (c < cfg.poor.below) condition = 'poor';
else if (c < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalSubmissions(profile, cfg) {
var rate = profile.submissions_rate || 0;
var bar = Math.min(1, rate > 0 ? Math.min(1, cfg.ceiling / rate) : 1);
var condition;
if (rate > cfg.poor.above) condition = 'poor';
else if (rate > cfg.adequate.above) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalCommentRate(profile, cfg) {
var rate = profile.comments_rate || 0;
var bar = Math.min(1, rate / cfg.ceiling);
var condition;
if (rate < cfg.poor.below) condition = 'poor';
else if (rate < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalCreated(profile, cfg) {
var created = new Date(profile.created_at).getTime();
var ceiling = new Date(cfg.ceiling).getTime();
var now = Date.now();
var age = now - created;
var maxAge = now - ceiling;
var bar = Math.min(1, age / maxAge);
var days = age / (24 * 3600 * 1000);
var condition;
if (days < cfg.poor.below) condition = 'poor';
else if (days < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
var COLORS = { poor: '#e74c3c', adequate: '#f39c12', good: '#27ae60' };
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function friendlyRate(dailyRate) {
if (dailyRate >= 1) return dailyRate.toFixed(1) + '/day';
if (dailyRate * 7 >= 1) return (dailyRate * 7).toFixed(1) + '/week';
if (dailyRate * 30 >= 1) return (dailyRate * 30).toFixed(1) + '/month';
return (dailyRate * 365).toFixed(1) + '/year';
}
function friendlyAge(isoDate) {
var ms = Date.now() - new Date(isoDate).getTime();
var days = ms / (24 * 3600 * 1000);
if (days >= 365.25) return Math.floor(days / 365.25) + 'y ' + Math.floor((days % 365.25) / 30) + 'mo';
if (days >= 30) return Math.floor(days / 30) + 'mo';
return Math.floor(days) + 'd';
}
function signalsEnabled(config) {
return ['karma', 'comments', 'submissions', 'comment_rate', 'created'].some(function (key) { return config[key].enabled; });
}
function evaluateProfile(profile, config) {
var signals = [];
if (config.karma.enabled) {
var r = evalKarma(profile, config.karma);
signals.push({ name: 'Karma', detail: formatNumber(profile.karma), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.karma[r.condition].weight });
}
if (config.comments.enabled) {
var r = evalComments(profile, config.comments);
signals.push({ name: 'Comments', detail: formatNumber(profile.total_comments), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comments[r.condition].weight });
}
if (config.submissions.enabled) {
var r = evalSubmissions(profile, config.submissions);
signals.push({ name: 'Submissions', detail: formatNumber(profile.total_submissions) + ' (' + friendlyRate(profile.submissions_rate) + ')', bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.submissions[r.condition].weight });
}
if (config.comment_rate.enabled) {
var r = evalCommentRate(profile, config.comment_rate);
signals.push({ name: 'Comment Rate', detail: friendlyRate(profile.comments_rate || 0), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comment_rate[r.condition].weight });
}
if (config.created.enabled) {
var r = evalCreated(profile, config.created);
signals.push({ name: 'Age', detail: friendlyAge(profile.created_at), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.created[r.condition].weight });
}
return signals;
}
var PRESETS = {
default: DEFAULT_CONFIG,
'high-submission-penalty': deepMerge(DEFAULT_CONFIG, { submissions: { poor: { weight: -5 } } }),
};
function getConfig(saved, preset) {
return deepMerge(PRESETS[preset || 'default'], saved);
}
function validateConfig(candidate) {
var badOrder = ['karma', 'comments', 'comment_rate', 'created'].some(function (key) {
return candidate[key].enabled && candidate[key].poor.below > candidate[key].adequate.below;
}) || (candidate.submissions.enabled && candidate.submissions.adequate.above > candidate.submissions.poor.above);
return badOrder ? 'Check thresholds: the adequate range must lie between poor and good.' : '';
}
var PROFILE_TTL_MS = 6 * 60 * 60 * 1000;
function classifyProfile(profile) {
if (!profile) return { status: 'missing', profile: null };
var valid = Number.isFinite(Date.parse(profile.created_at)) && Number.isFinite(profile.karma) &&
['total_comments', 'total_submissions', 'submissions_rate'].every(function (key) {
return Number.isFinite(profile[key]) && profile[key] >= 0;
}) && (profile.comments_rate == null || (Number.isFinite(profile.comments_rate) && profile.comments_rate >= 0));
if (!valid) return { status: 'unavailable', profile: null };
var checked = Date.parse(profile.checked_at);
var fresh = Number.isFinite(checked) && checked <= Date.now() + 300000 && Date.now() - checked < PROFILE_TTL_MS;
return { status: fresh ? 'ready' : 'stale', profile: profile };
}
// A single, bounded queue is shared by all HN tabs.
var CACHE_KEY = 'ts-profile-cache';
var CACHE_TTL_MS = PROFILE_TTL_MS;
var MAX_STALE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
var MISSING_TTL_MS = 5 * 60 * 1000;
var MAX_CACHE_ENTRIES = 500;
var MAX_ACTIVE_PROFILES = 3; // At most nine HTTP requests across all tabs.
var REQUEST_TIMEOUT_MS = 15000;
var HN_USER_URL = 'https://hacker-news.firebaseio.com/v0/user/';
var ALGOLIA_URL = 'https://hn.algolia.com/api/v1/search';
var cache = Object.create(null);
var cacheReady;
var pending = new Map();
var queue = [];
var active = 0;
var saveTimer;
var writes = Promise.resolve();
function pruneCache() {
var now = Date.now();
var keys = Object.keys(cache).filter(function (key) {
var entry = cache[key];
var valid = entry && Number.isFinite(entry.cachedAt) &&
now - entry.cachedAt < (entry.profile ? MAX_STALE_AGE_MS : MISSING_TTL_MS);
if (!valid) delete cache[key];
return valid;
});
keys.sort(function (a, b) { return cache[b].cachedAt - cache[a].cachedAt; });
keys.slice(MAX_CACHE_ENTRIES).forEach(function (key) { delete cache[key]; });
}
function loadCache() {
if (!cacheReady) {
cacheReady = browser.storage.local.get(CACHE_KEY).then(function (result) {
Object.assign(cache, result[CACHE_KEY] || {});
pruneCache();
scheduleSave(); // Also persist pruning of a legacy, unbounded cache.
}).catch(function () { /* Storage failure must not prevent loading profiles. */ });
}
return cacheReady;
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(function () {
saveTimer = null;
pruneCache();
// Capture a bounded snapshot and serialize writes so older writes cannot win.
var snapshot = Object.assign(Object.create(null), cache);
writes = writes.then(function () {
return browser.storage.local.set({ [CACHE_KEY]: snapshot });
}).catch(function (err) { console.warn('[TrustSpark] Cache write failed:', err); });
}, 250);
}
function fetchJSON(url, signal) {
return fetch(url, { credentials: 'omit', signal: signal }).then(function (r) {
if (!r.ok) throw new Error('Profile API ' + r.status);
return r.json();
});
}
async function fetchUserProfile(username) {
var controller = new AbortController();
var timeout = setTimeout(function () { controller.abort(); }, REQUEST_TIMEOUT_MS);
try {
var results = await Promise.all([
fetchJSON(HN_USER_URL + encodeURIComponent(username) + '.json', controller.signal),
fetchJSON(ALGOLIA_URL + '?tags=story,author_' + encodeURIComponent(username) + '&hitsPerPage=0', controller.signal),
fetchJSON(ALGOLIA_URL + '?tags=comment,author_' + encodeURIComponent(username) + '&hitsPerPage=0', controller.signal),
]);
var user = results[0];
if (user === null) return null;
if (!user || user.id !== username || !Number.isFinite(user.created) ||
!Number.isFinite(user.karma) || !Number.isFinite(results[1].nbHits) ||
!Number.isFinite(results[2].nbHits) || results[1].nbHits < 0 || results[2].nbHits < 0) throw new Error('Invalid profile response');
var createdMs = user.created * 1000;
var daysAlive = (Date.now() - createdMs) / 86400000;
return {
checked_at: new Date().toISOString(),
created_at: new Date(createdMs).toISOString(),
karma: user.karma,
bio_length: (user.about || '').length,
total_submissions: results[1].nbHits,
total_comments: results[2].nbHits,
submissions_rate: daysAlive > 0 ? results[1].nbHits / daysAlive : 0,
comments_rate: daysAlive > 0 ? results[2].nbHits / daysAlive : 0,
};
} finally {
clearTimeout(timeout);
controller.abort(); // Cancel sibling requests if one failed.
}
}
function drainQueue() {
while (active < MAX_ACTIVE_PROFILES && queue.length) {
var job = queue.shift();
active++;
runJob(job);
}
}
async function runJob(job) {
try {
var profile = await fetchUserProfile(job.username);
cache[job.username] = { profile: profile, cachedAt: Date.now() };
pruneCache();
scheduleSave();
job.resolve(profile ? classifyProfile(profile) : { status: 'missing', profile: null });
} catch (err) {
// Do not cache failed requests as zero-valued (low-trust) accounts.
var previous = cache[job.username];
job.resolve(previous && previous.profile ? { status: 'stale', profile: previous.profile } : { status: 'unavailable', profile: null });
} finally {
pending.delete(job.username);
active--;
drainQueue();
}
}
function getProfile(username, force) {
var entry = cache[username];
if (!force && entry && Date.now() - entry.cachedAt < (entry.profile ? CACHE_TTL_MS : MISSING_TTL_MS)) {
return Promise.resolve(entry.profile ? classifyProfile(entry.profile) : { status: 'missing', profile: null });
}
if (pending.has(username)) return pending.get(username);
var promise = new Promise(function (resolve) { queue.push({ username: username, resolve: resolve }); });
pending.set(username, promise);
drainQueue();
return promise;
}
browser.runtime.onMessage.addListener(function (msg) {
if (!msg || !['getProfiles', 'getProfileStates'].includes(msg.type) || !Array.isArray(msg.usernames)) return;
var usernames = Array.from(new Set(msg.usernames.filter(function (u) {
return typeof u === 'string' && /^[a-zA-Z0-9_-]{1,50}$/.test(u);
}))).slice(0, 2000);
return loadCache().then(function () {
return Promise.all(usernames.map(function (u) {
return getProfile(u, msg.force === true).then(function (p) { return [u, p]; });
}));
}).then(function (pairs) {
var out = Object.create(null);
pairs.forEach(function (pair) {
if (msg.type === 'getProfileStates') out[pair[0]] = pair[1];
else if (pair[1].status === 'ready') out[pair[0]] = pair[1].profile;
});
return out;
});
});
})();
Download file · SHA-256: 99686c5d646c6c717ebaed6f8f82a9c80e1945afe1bb119a4b848647cbcb6cf3
// Generated by node scripts/build.cjs. Edit app/ instead.
(function () {
"use strict";
var DEFAULT_CONFIG = {
karma: {
enabled: true,
ceiling: 400000,
poor: { below: 2, weight: 0 },
adequate: { below: 1000, weight: 1 },
good: { above: 1000, weight: 2 },
},
comments: {
enabled: true,
ceiling: 1000,
poor: { below: 3, weight: 0 },
adequate: { below: 100, weight: 1 },
good: { above: 99, weight: 2 },
},
submissions: {
enabled: true,
ceiling: 1 / 14,
poor: { above: 1, weight: 0 },
adequate: { above: 1 / 7, weight: 1 },
good: { below: 1 / 7, weight: 2 },
},
comment_rate: {
enabled: false,
ceiling: 10,
poor: { below: 1 / 30, weight: 0 },
adequate: { below: 1, weight: 1 },
good: { above: 1, weight: 2 },
},
created: {
enabled: true,
ceiling: '2009-01-01',
poor: { below: 365, weight: 0 },
adequate: { below: 1825, weight: 1 },
good: { above: 1825, weight: 2 },
},
minTrustScore: 2,
sortSparklines: false,
invertSparklines: false,
sortByTrust: false,
};
function deepMerge(defaults, overrides) {
var result = JSON.parse(JSON.stringify(defaults));
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return result;
Object.keys(defaults).forEach(function (key) {
var value = overrides[key];
if (defaults[key] && typeof defaults[key] === 'object') {
result[key] = deepMerge(defaults[key], value);
} else if (typeof value === typeof defaults[key]) {
if (typeof value === 'number' && (!Number.isFinite(value) ||
(key === 'ceiling' && value <= 0) ||
(key !== 'weight' && value < 0))) return;
if (typeof value === 'string' && (!Number.isFinite(Date.parse(value)) || Date.parse(value) >= Date.now())) return;
result[key] = value;
}
});
return result;
}
// ── Signal evaluation ──────────────────────────────────────────────
function evalKarma(profile, cfg) {
var k = profile.karma || 0;
var bar = Math.min(1, Math.log(Math.max(0, k) + 1) / Math.log(cfg.ceiling + 1));
var condition;
if (k < cfg.poor.below) condition = 'poor';
else if (k < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalComments(profile, cfg) {
var c = profile.total_comments || 0;
var bar = Math.min(1, c / cfg.ceiling);
var condition;
if (c < cfg.poor.below) condition = 'poor';
else if (c < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalSubmissions(profile, cfg) {
var rate = profile.submissions_rate || 0;
var bar = Math.min(1, rate > 0 ? Math.min(1, cfg.ceiling / rate) : 1);
var condition;
if (rate > cfg.poor.above) condition = 'poor';
else if (rate > cfg.adequate.above) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalCommentRate(profile, cfg) {
var rate = profile.comments_rate || 0;
var bar = Math.min(1, rate / cfg.ceiling);
var condition;
if (rate < cfg.poor.below) condition = 'poor';
else if (rate < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalCreated(profile, cfg) {
var created = new Date(profile.created_at).getTime();
var ceiling = new Date(cfg.ceiling).getTime();
var now = Date.now();
var age = now - created;
var maxAge = now - ceiling;
var bar = Math.min(1, age / maxAge);
var days = age / (24 * 3600 * 1000);
var condition;
if (days < cfg.poor.below) condition = 'poor';
else if (days < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
var COLORS = { poor: '#e74c3c', adequate: '#f39c12', good: '#27ae60' };
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function friendlyRate(dailyRate) {
if (dailyRate >= 1) return dailyRate.toFixed(1) + '/day';
if (dailyRate * 7 >= 1) return (dailyRate * 7).toFixed(1) + '/week';
if (dailyRate * 30 >= 1) return (dailyRate * 30).toFixed(1) + '/month';
return (dailyRate * 365).toFixed(1) + '/year';
}
function friendlyAge(isoDate) {
var ms = Date.now() - new Date(isoDate).getTime();
var days = ms / (24 * 3600 * 1000);
if (days >= 365.25) return Math.floor(days / 365.25) + 'y ' + Math.floor((days % 365.25) / 30) + 'mo';
if (days >= 30) return Math.floor(days / 30) + 'mo';
return Math.floor(days) + 'd';
}
function signalsEnabled(config) {
return ['karma', 'comments', 'submissions', 'comment_rate', 'created'].some(function (key) { return config[key].enabled; });
}
function evaluateProfile(profile, config) {
var signals = [];
if (config.karma.enabled) {
var r = evalKarma(profile, config.karma);
signals.push({ name: 'Karma', detail: formatNumber(profile.karma), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.karma[r.condition].weight });
}
if (config.comments.enabled) {
var r = evalComments(profile, config.comments);
signals.push({ name: 'Comments', detail: formatNumber(profile.total_comments), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comments[r.condition].weight });
}
if (config.submissions.enabled) {
var r = evalSubmissions(profile, config.submissions);
signals.push({ name: 'Submissions', detail: formatNumber(profile.total_submissions) + ' (' + friendlyRate(profile.submissions_rate) + ')', bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.submissions[r.condition].weight });
}
if (config.comment_rate.enabled) {
var r = evalCommentRate(profile, config.comment_rate);
signals.push({ name: 'Comment Rate', detail: friendlyRate(profile.comments_rate || 0), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comment_rate[r.condition].weight });
}
if (config.created.enabled) {
var r = evalCreated(profile, config.created);
signals.push({ name: 'Age', detail: friendlyAge(profile.created_at), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.created[r.condition].weight });
}
return signals;
}
var PRESETS = {
default: DEFAULT_CONFIG,
'high-submission-penalty': deepMerge(DEFAULT_CONFIG, { submissions: { poor: { weight: -5 } } }),
};
function getConfig(saved, preset) {
return deepMerge(PRESETS[preset || 'default'], saved);
}
function validateConfig(candidate) {
var badOrder = ['karma', 'comments', 'comment_rate', 'created'].some(function (key) {
return candidate[key].enabled && candidate[key].poor.below > candidate[key].adequate.below;
}) || (candidate.submissions.enabled && candidate.submissions.adequate.above > candidate.submissions.poor.above);
return badOrder ? 'Check thresholds: the adequate range must lie between poor and good.' : '';
}
var PROFILE_TTL_MS = 6 * 60 * 60 * 1000;
function classifyProfile(profile) {
if (!profile) return { status: 'missing', profile: null };
var valid = Number.isFinite(Date.parse(profile.created_at)) && Number.isFinite(profile.karma) &&
['total_comments', 'total_submissions', 'submissions_rate'].every(function (key) {
return Number.isFinite(profile[key]) && profile[key] >= 0;
}) && (profile.comments_rate == null || (Number.isFinite(profile.comments_rate) && profile.comments_rate >= 0));
if (!valid) return { status: 'unavailable', profile: null };
var checked = Date.parse(profile.checked_at);
var fresh = Number.isFinite(checked) && checked <= Date.now() + 300000 && Date.now() - checked < PROFILE_TTL_MS;
return { status: fresh ? 'ready' : 'stale', profile: profile };
}
// ── Filter / reveal helpers ──────────────────────────────────────────
function findFilterTargets(link) {
var comtr = link.closest('tr.comtr');
if (comtr) return [comtr];
var row = link.closest('tr');
if (!row) return [];
var prev = row.previousElementSibling;
if (prev && prev.classList.contains('athing')) return [prev, row];
return [row];
}
// Blur content rather than table rows. Offscreen content keeps its layout but
// does not retain an active filter; the margin prepares it before scrolling in.
var blurObserver = typeof IntersectionObserver === 'function' ? new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.target.classList.contains('ts-filter-content')) {
entry.target.classList.toggle('ts-blur-active', entry.isIntersecting);
}
});
}, { rootMargin: '200px' }) : null;
var uiStyle = document.createElement('style');
uiStyle.textContent = '.ts-filter-content{visibility:hidden;cursor:pointer}' +
'.ts-filter-content.ts-blur-active{visibility:visible;filter:blur(5px)}' +
'.coll .ts-filter-content{filter:none}' +
'.ts-filter-content:focus-visible{outline:2px solid #ff6600;outline-offset:2px}' +
'.ts-sparkline:focus-visible,.ts-profile-details:focus-visible{outline:2px solid #ff6600;outline-offset:2px}' +
'@media(pointer:coarse){.ts-sparkline::before,.ts-profile-details::before{content:"";position:absolute;inset:-7px -2px;}}';
document.head.appendChild(uiStyle);
function removeFilter(el) {
el.classList.remove('ts-filtered');
if (el._tsFilterContent) {
var host = el._tsFilterContent;
if (blurObserver) blurObserver.unobserve(host);
host.classList.remove('ts-filter-content', 'ts-blur-active');
host.removeEventListener('click', el._tsFilterListener, true);
host.removeEventListener('keydown', el._tsFilterKeyListener);
el._tsFilterAttributes.forEach(function (attribute) {
if (attribute[1] === null) host.removeAttribute(attribute[0]);
else host.setAttribute(attribute[0], attribute[1]);
});
delete el._tsFilterContent;
delete el._tsFilterListener;
delete el._tsFilterKeyListener;
delete el._tsFilterAttributes;
}
}
function applyFilterGroup(targets) {
if (!targets.length || targets[0].classList.contains('ts-filtered')) return;
var host = targets[0].querySelector('.comment, .titleline');
if (!host) return; // Leave unfamiliar HN layouts readable.
targets.forEach(function (el) { el.classList.add('ts-filtered'); });
host.classList.add('ts-filter-content');
if (blurObserver) blurObserver.observe(host);
else host.classList.add('ts-blur-active');
var showContent = function () { targets.forEach(removeFilter); };
var revealClick = function (event) {
event.preventDefault();
event.stopPropagation();
showContent();
};
host.addEventListener('click', revealClick, true);
var revealKey = function (event) {
if (event.target === host && (event.key === 'Enter' || event.key === ' ')) revealClick(event);
};
host.addEventListener('keydown', revealKey);
targets[0]._tsFilterContent = host;
targets[0]._tsFilterListener = revealClick;
targets[0]._tsFilterKeyListener = revealKey;
targets[0]._tsFilterAttributes = ['tabindex', 'role', 'aria-label'].map(function (name) {
return [name, host.getAttribute(name)];
});
host.setAttribute('tabindex', '0');
host.setAttribute('role', 'group');
host.setAttribute('aria-label', 'Blurred content — press Enter or Space to reveal');
}
// One tooltip per document, built only when requested, including on touch.
var tooltip = null;
var activeSparkline = null;
var pinnedTooltip = false;
var tooltipHideTimer;
var sparklineDetails = new WeakMap();
function hideTooltip() {
clearTimeout(tooltipHideTimer);
if (tooltip) tooltip.hidden = true;
if (activeSparkline) {
activeSparkline.setAttribute('aria-expanded', 'false');
activeSparkline.removeAttribute('aria-describedby');
}
activeSparkline = null;
pinnedTooltip = false;
}
function showTooltip(container, pinned) {
hideTooltip();
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'ts-profile-tooltip';
tooltip.className = 'ts-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.style.cssText = 'position:fixed;background:#f6f6ef;border:1px solid #828282;border-radius:3px;padding:6px 10px;min-width:160px;box-sizing:border-box;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,0.18);font:8pt/1.5 Verdana,Geneva,sans-serif;color:#828282;overflow-wrap:anywhere;';
document.body.appendChild(tooltip);
}
tooltip.replaceChildren();
var details = sparklineDetails.get(container);
var freshness = document.createElement('div');
freshness.textContent = (details.status && details.status !== 'ready' ? stateLabel(details.status) + '. ' : '') + 'Last checked: ' + (details.checkedAt || 'unknown');
freshness.className = 'ts-last-checked';
freshness.style.cssText = 'font-size:8pt;margin-top:4px;color:#666;';
details.signals.forEach(function (s) {
var row = document.createElement('div');
row.className = 'ts-signal-detail';
var marker = document.createElement('span');
marker.className = 'ts-signal-marker';
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText = 'display:inline-block;width:8px;height:8px;background:' + s.color + ';border-radius:1px;margin-right:4px;';
var value = document.createElement('b');
value.style.color = '#000';
value.textContent = s.detail;
var condition = document.createElement('span');
condition.textContent = ' (' + s.condition + ', ' + s.weight + ' points)';
condition.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;';
row.title = s.condition + ', ' + s.weight + ' trust points';
row.append(marker, document.createTextNode(s.name + ': '), value, condition);
tooltip.appendChild(row);
});
if (!details.status || details.status === 'ready') {
var separator = document.createElement('hr');
separator.style.cssText = 'border:none;border-top:1px solid #ccc;margin:4px 0;';
tooltip.appendChild(separator);
var points = details.signals.reduce(function (total, signal) { return total + signal.weight; }, 0);
var maxPoints = ['karma', 'comments', 'submissions', 'comment_rate', 'created'].reduce(function (total, key) {
return total + (config[key].enabled ? Math.max(0, config[key].poor.weight, config[key].adequate.weight, config[key].good.weight) : 0);
}, 0);
var ratio = maxPoints > 0 ? points / maxPoints : 0;
var summary = document.createElement('div');
summary.className = 'ts-trust-summary';
var score = document.createElement('b');
score.textContent = maxPoints > 0 ? points + '/' + maxPoints : String(points);
score.style.color = maxPoints > 0 ? (ratio >= 0.66 ? COLORS.good : ratio >= 0.33 ? COLORS.adequate : COLORS.poor) : '#828282';
summary.append(document.createTextNode('Trust points: '), score);
tooltip.appendChild(summary);
}
tooltip.appendChild(freshness);
tooltip.setAttribute('role', details.record ? 'dialog' : 'tooltip');
if (details.record) {
tooltip.setAttribute('aria-label', stateLabel(details.record.status) + ' for ' + details.record.username);
var retry = document.createElement('button');
retry.type = 'button';
retry.className = 'ts-retry';
retry.textContent = 'Retry';
retry.style.cssText = 'font:inherit;min-height:24px;margin-top:4px;cursor:pointer;';
retry.addEventListener('click', function () {
hideTooltip();
details.link.focus({ preventScroll: true });
retryProfile(details.record);
});
tooltip.appendChild(retry);
} else tooltip.removeAttribute('aria-label');
tooltip.hidden = false;
tooltip.style.left = '8px';
tooltip.style.top = '8px';
var anchor = container.getBoundingClientRect();
var rect = tooltip.getBoundingClientRect();
var width = document.documentElement.clientWidth;
var height = window.innerHeight;
tooltip.style.left = Math.max(8, Math.min(anchor.left, width - rect.width - 8)) + 'px';
var top = anchor.top - rect.height - 4;
if (top < 8) top = anchor.bottom + 4;
tooltip.style.top = Math.max(8, Math.min(top, height - rect.height - 8)) + 'px';
activeSparkline = container;
pinnedTooltip = pinned;
container.setAttribute('aria-expanded', 'true');
container.setAttribute('aria-describedby', 'ts-profile-tooltip');
}
// HN handles collapse/hide clicks with stopImmediatePropagation; dismiss first.
document.addEventListener('click', function (e) {
if (activeSparkline && !activeSparkline.contains(e.target) && (!tooltip || !tooltip.contains(e.target))) hideTooltip();
}, true);
document.addEventListener('click', function (e) {
var container = e.target.closest('.ts-sparkline, .ts-profile-details');
if (container) {
e.preventDefault();
if (activeSparkline === container && pinnedTooltip) hideTooltip();
else {
showTooltip(container, true);
var retry = tooltip.querySelector('.ts-retry');
if (e.detail === 0 && retry) retry.focus({ preventScroll: true });
}
} else if (!tooltip || !tooltip.contains(e.target)) hideTooltip();
});
function scheduleTooltipHide() {
clearTimeout(tooltipHideTimer);
tooltipHideTimer = setTimeout(function () {
if (!pinnedTooltip && tooltip && !tooltip.matches(':hover') &&
!tooltip.contains(document.activeElement) && activeSparkline && !activeSparkline.matches(':hover')) hideTooltip();
}, 150);
}
document.addEventListener('pointerover', function (e) {
if (e.pointerType !== 'mouse') return;
if (tooltip && tooltip.contains(e.target)) { clearTimeout(tooltipHideTimer); return; }
var container = e.target.closest('.ts-sparkline, .ts-profile-details');
if (container) {
clearTimeout(tooltipHideTimer);
if (!pinnedTooltip && activeSparkline !== container) showTooltip(container, false);
}
});
document.addEventListener('pointerout', function (e) {
if (pinnedTooltip || !activeSparkline) return;
if (activeSparkline.contains(e.target) || (tooltip && tooltip.contains(e.target))) {
if (!activeSparkline.contains(e.relatedTarget) && (!tooltip || !tooltip.contains(e.relatedTarget))) scheduleTooltipHide();
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && activeSparkline) {
var trigger = activeSparkline;
var focusInPanel = tooltip && tooltip.contains(document.activeElement);
hideTooltip();
if (focusInPanel) trigger.focus({ preventScroll: true });
}
});
document.addEventListener('focusout', function (e) {
if (activeSparkline && (e.target === activeSparkline || (tooltip && tooltip.contains(e.target))) &&
e.relatedTarget !== activeSparkline && (!tooltip || !tooltip.contains(e.relatedTarget))) hideTooltip();
});
window.addEventListener('scroll', hideTooltip, { passive: true });
window.addEventListener('resize', hideTooltip, { passive: true });
function createSparkline(signals, profile) {
if (config.sortSparklines) {
signals = signals.slice().sort(function (a, b) {
return config.invertSparklines ? a.bar - b.bar : b.bar - a.bar;
});
}
var container = document.createElement('button');
container.type = 'button';
container.className = 'ts-sparkline';
container.setAttribute('aria-label', 'Show account trust signals');
container.setAttribute('aria-expanded', 'false');
container.setAttribute('aria-controls', 'ts-profile-tooltip');
container.style.cssText = 'display:inline-flex;align-items:flex-end;gap:1px;height:10px;min-width:0;min-height:0;box-sizing:content-box;padding:0;margin:0 4px 0 0;line-height:10px;vertical-align:middle;position:relative;cursor:pointer;border:0;background:transparent;';
signals.forEach(function (s) {
var bar = document.createElement('span');
var value = Number.isFinite(s.bar) ? Math.max(0, Math.min(1, s.bar)) : 0;
bar.style.cssText = 'display:inline-block;width:3px;height:' + Math.max(2, Math.round(value * 10)) + 'px;background:' + s.color + ';border-radius:1px;';
bar.title = s.name + ': ' + s.detail;
container.appendChild(bar);
});
sparklineDetails.set(container, { signals: signals, status: 'ready', checkedAt: profile.checked_at });
return container;
}
// ── Sort by trust ──────────────────────────────────────────────────
var allProfiles = null;
var sortLayouts = null;
var sortIsActive = false;
function userSortKey(username) {
var profile = username && allProfiles && allProfiles[username];
if (!profile) return { known: false, score: 0, barSum: 0 };
var signals = evaluateProfile(profile, config);
return {
known: true,
score: signals.reduce(function (sum, signal) { return sum + signal.weight; }, 0),
barSum: signals.reduce(function (sum, signal) { return sum + signal.bar; }, 0),
};
}
function isTrailer(row) {
return row.classList.contains('morespace') || (!row.classList.contains('comtr') && !row.classList.contains('athing') && !!row.querySelector('a.morelink, form'));
}
function commentIndent(row) {
var cell = row.querySelector('td.ind');
if (!cell) return null;
var indent = cell.getAttribute('indent');
if (indent !== null) return Number(indent);
var image = cell.querySelector('img[width]');
return image ? Number(image.getAttribute('width')) : null;
}
function captureLayout(tbody, comments) {
var units = [];
var current = null;
Array.from(tbody.children).forEach(function (row) {
if (isTrailer(row)) { current = null; return; }
var startsUnit = comments ? row.classList.contains('comtr') && commentIndent(row) === 0 :
row.classList.contains('athing') && !row.classList.contains('comtr');
if (startsUnit) {
current = { rows: [], index: units.length, username: null };
units.push(current);
}
if (!current) return;
// Comment table footers are not part of the last root's subtree.
if (comments && !row.classList.contains('comtr') && !row.classList.contains('spacer')) { current = null; return; }
current.rows.push(row);
if (!comments || startsUnit) {
var link = row.querySelector('a.hnuser');
if (link && !current.username) current.username = link.textContent.trim();
}
});
if (units.length < 2) return null;
var originalNodes = Array.from(tbody.childNodes);
var movable = new Set(units.flatMap(function (unit) { return unit.rows; }));
var ranks = units.map(function (unit) {
var element = comments ? null : unit.rows[0].querySelector('.rank');
return element ? { element: element, text: element.textContent } : null;
}).filter(Boolean);
var navigation = [];
if (comments) units.forEach(function (unit) {
unit.rows[0].querySelectorAll('a.clicky[href]').forEach(function (link) {
if (!['next', 'prev'].includes(link.textContent.trim())) return;
navigation.push({ link: link, href: link.getAttribute('href'), hidden: link.hidden });
});
});
return { tbody: tbody, comments: comments, units: units, originalNodes: originalNodes, movable: movable, ranks: ranks, navigation: navigation };
}
function captureSortLayouts() {
var tables = new Map();
document.querySelectorAll('tr.comtr, tr.athing').forEach(function (row) {
var comments = row.classList.contains('comtr');
// Each direct row group is handled independently; nested HN tables stay intact.
tables.set(row.parentNode, comments || tables.get(row.parentNode) || false);
});
return Array.from(tables, function (entry) { return captureLayout(entry[0], entry[1]); }).filter(Boolean);
}
function refreshSortLayouts() {
if (!sortLayouts) return;
sortLayouts = sortLayouts.map(function (layout) {
if (!layout.tbody.isConnected) return null;
var liveNodes = Array.from(layout.tbody.childNodes);
var oldNodes = new Set(layout.originalNodes);
if (liveNodes.length === oldNodes.size && liveNodes.every(function (node) { return oldNodes.has(node); })) return layout;
// HN's Hide action removes a story and can append its replacement in place.
// Rebuild from live nodes so sorting cannot resurrect a hidden story.
var fresh = captureLayout(layout.tbody, layout.comments);
if (!fresh) return null;
var liveUnits = new Map(fresh.units.map(function (unit) { return [unit.rows[0], unit]; }));
var baseline = [];
layout.units.forEach(function (unit) {
var live = liveUnits.get(unit.rows[0]);
if (live) { baseline.push(live); liveUnits.delete(unit.rows[0]); }
});
baseline.push.apply(baseline, Array.from(liveUnits.values()));
fresh.units = baseline;
baseline.forEach(function (unit, index) { unit.index = index; });
var rows = baseline.flatMap(function (unit) { return unit.rows; });
var index = 0;
fresh.originalNodes = fresh.originalNodes.map(function (node) { return fresh.movable.has(node) ? rows[index++] : node; });
if (!layout.comments) {
var firstRank = layout.ranks.length ? parseInt(layout.ranks[0].text, 10) || 1 : 1;
fresh.ranks = baseline.map(function (unit, i) {
var element = unit.rows[0].querySelector('.rank');
return element ? { element: element, text: (firstRank + i) + '.' } : null;
}).filter(Boolean);
}
fresh.navigation = fresh.navigation.map(function (saved) {
return layout.navigation.find(function (old) { return old.link === saved.link; }) || saved;
});
return fresh;
}).filter(Boolean);
}
function keepFocus(action) {
var focused = document.activeElement;
action();
if (focused && focused !== document.body && focused.isConnected && document.activeElement !== focused) {
focused.focus({ preventScroll: true });
}
}
function placeRows(layout, rows) {
var index = 0;
var orderedNodes = layout.originalNodes.map(function (node) {
return layout.movable.has(node) ? rows[index++] : node;
});
var currentNodes = Array.from(layout.tbody.childNodes);
if (currentNodes.length === orderedNodes.length && currentNodes.every(function (node, i) { return node === orderedNodes[i]; })) return;
var fragment = document.createDocumentFragment();
// Fixed rows/text nodes retain their slots, including all pagination/footer rows.
orderedNodes.forEach(function (node) { fragment.appendChild(node); });
layout.tbody.appendChild(fragment);
}
function restoreSort() {
if (!sortIsActive || !sortLayouts) return;
refreshSortLayouts();
keepFocus(function () {
sortLayouts.forEach(function (layout) {
placeRows(layout, layout.units.flatMap(function (unit) { return unit.rows; }));
layout.ranks.forEach(function (rank) { rank.element.textContent = rank.text; });
layout.navigation.forEach(function (saved) { saved.link.setAttribute('href', saved.href); saved.link.hidden = saved.hidden; });
});
});
sortIsActive = false;
}
function applySortByTrust() {
if (!config.sortByTrust || !signalsEnabled(config)) { restoreSort(); return; }
if (!sortLayouts || !sortLayouts.length) sortLayouts = captureSortLayouts();
else refreshSortLayouts();
keepFocus(function () {
sortLayouts.forEach(function (layout) {
var ordered = layout.units.map(function (unit) { return Object.assign({}, unit, userSortKey(unit.username)); });
ordered.sort(function (a, b) {
// Unavailable data has no score and follows all known profiles, even negative ones.
return Number(b.known) - Number(a.known) || b.score - a.score || b.barSum - a.barSum || a.index - b.index;
});
placeRows(layout, ordered.flatMap(function (unit) { return unit.rows; }));
if (!layout.comments) {
var firstRank = layout.ranks.length ? parseInt(layout.ranks[0].text, 10) || 1 : 1;
ordered.forEach(function (unit, index) {
var rank = unit.rows[0].querySelector('.rank');
var text = (firstRank + index) + '.';
if (rank && rank.textContent !== text) rank.textContent = text;
});
} else ordered.forEach(function (unit, index) {
unit.rows[0].querySelectorAll('a.clicky[href]').forEach(function (link) {
var label = link.textContent.trim();
if (label !== 'prev' && label !== 'next') return;
var target = ordered[index + (label === 'prev' ? -1 : 1)];
var original = layout.navigation.find(function (saved) { return saved.link === link; });
link.hidden = !!(original && original.hidden) || !target || !target.rows[0].id;
if (!link.hidden) link.setAttribute('href', '#' + target.rows[0].id);
});
});
});
});
sortIsActive = true;
}
// Resolve small batches in viewport order; never wait for a whole thread.
var pageRecords = new Map();
var renderQueue = new Map();
var renderFrame = null;
var profileLoader;
var profileOptions = {};
var activeBatches = 0;
var MAX_BATCHES = 2;
var PROFILES_PER_BATCH = 3;
var RENDERS_PER_FRAME = 40;
var observer;
var freshnessTimer;
function classifyPageProfile(profile) {
var state = classifyProfile(profile);
// A published demo is a snapshot, not the extension's refreshable cache.
if (profileOptions.snapshot && state.status === 'stale') state.status = 'ready';
if (profileOptions.snapshot && state.status === 'missing') state.status = 'unavailable';
return state;
}
function checkFreshness() {
clearTimeout(freshnessTimer);
if (profileOptions.snapshot) { maybeSort(); return; }
var nextExpiry = Infinity;
pageRecords.forEach(function (record) {
if (record.status !== 'ready') return;
var state = classifyProfile(record.profile);
if (state.status !== 'ready') {
record.status = state.status;
delete allProfiles[record.username];
queueRecord(record);
} else nextExpiry = Math.min(nextExpiry, Date.parse(record.profile.checked_at) + PROFILE_TTL_MS);
});
maybeSort();
if (Number.isFinite(nextExpiry)) freshnessTimer = setTimeout(checkFreshness, Math.max(100, nextExpiry - Date.now()));
}
document.addEventListener('visibilitychange', function () { if (!document.hidden) checkFreshness(); });
function queueRecord(record) {
record.links.forEach(function (link) { renderQueue.set(link, record); });
if (!renderFrame) renderFrame = requestAnimationFrame(flushRenders);
}
function stateLabel(status) {
return { queued: 'Loading…', loading: 'Loading…', missing: 'Account not found', unavailable: 'Profile unavailable', failed: 'Profile unavailable', stale: 'Stale profile' }[status] || 'Profile unavailable';
}
function createProfileState(record, link) {
var wrapper = document.createElement('span');
wrapper.className = 'ts-profile-state';
wrapper.dataset.tsStatus = record.status;
wrapper.style.cssText = 'display:inline-block;width:15px;height:10px;line-height:10px;vertical-align:middle;margin-right:4px;text-align:center;font:10px/10px Verdana,Geneva,sans-serif;color:#828282;';
var label = record.username + ': ' + stateLabel(record.status);
wrapper.title = label;
if (record.status === 'queued' || record.status === 'loading') {
wrapper.textContent = '…';
wrapper.setAttribute('aria-label', label);
return wrapper;
}
var details = document.createElement('button');
details.type = 'button';
details.className = 'ts-profile-details';
details.textContent = { missing: '–', unavailable: '!', stale: '↻' }[record.status] || '!';
details.setAttribute('aria-label', label + ', show details and retry');
details.setAttribute('aria-expanded', 'false');
details.setAttribute('aria-controls', 'ts-profile-tooltip');
details.style.cssText = 'font:inherit;position:relative;width:15px;height:10px;min-width:0;min-height:0;line-height:10px;box-sizing:content-box;padding:0;margin:0;cursor:pointer;border:0;background:transparent;color:' + (record.status === 'stale' ? COLORS.adequate : '#828282') + ';';
sparklineDetails.set(details, { signals: [], status: record.status, checkedAt: record.profile && record.profile.checked_at, record: record, link: link });
wrapper.appendChild(details);
return wrapper;
}
function retryProfile(record) {
if (record.status === 'queued' || record.status === 'loading') return;
record.status = 'queued';
record.force = true;
record.signals = null;
record.links.forEach(function (link) { record.visible.add(link); });
queueRecord(record);
pumpProfiles();
}
function renderLink(link, record) {
if (!link.isConnected) return;
if (link._tsIndicator) {
if (link._tsIndicator === activeSparkline || link._tsIndicator.contains(activeSparkline)) hideTooltip();
link._tsIndicator.remove();
delete link._tsIndicator;
}
var targets = findFilterTargets(link);
targets.forEach(removeFilter);
if (!signalsEnabled(config)) return;
var indicator;
if (record.status === 'ready') {
if (!record.signals) record.signals = evaluateProfile(record.profile, config);
var signals = record.signals;
indicator = createSparkline(signals, record.profile);
indicator.dataset.tsStatus = 'ready';
indicator.setAttribute('aria-label', record.username + ': show account trust signals');
var points = signals.reduce(function (sum, signal) { return sum + signal.weight; }, 0);
if (config.minTrustScore > 0 && points < config.minTrustScore) applyFilterGroup(targets);
} else indicator = createProfileState(record, link);
link.parentNode.insertBefore(indicator, link);
link._tsIndicator = indicator;
}
function flushRenders() {
renderFrame = null;
var started = performance.now();
var count = 0;
for (var entry of renderQueue) {
renderQueue.delete(entry[0]);
renderLink(entry[0], entry[1]);
if (++count >= RENDERS_PER_FRAME || performance.now() - started > 6) break;
}
if (renderQueue.size) renderFrame = requestAnimationFrame(flushRenders);
else maybeSort();
}
function maybeSort() {
if (!config.sortByTrust || !signalsEnabled(config)) { restoreSort(); return; }
if (activeBatches || renderQueue.size || Array.from(pageRecords.values()).some(function (record) {
return record.status === 'queued' || record.status === 'loading';
})) return;
applySortByTrust();
}
function pumpProfiles() {
if (!profileLoader || !signalsEnabled(config)) return;
while (activeBatches < MAX_BATCHES) {
var waiting = Array.from(pageRecords.values()).filter(function (record) { return record.status === 'queued'; });
waiting.sort(function (a, b) { return Number(b.visible.size > 0) - Number(a.visible.size > 0); });
var forced = waiting.filter(function (record) { return record.force; });
var batch = (forced.length ? forced : waiting).slice(0, PROFILES_PER_BATCH);
if (!batch.length) break;
activeBatches++;
batch.forEach(function (record) { record.status = 'loading'; queueRecord(record); });
loadBatch(batch);
}
maybeSort();
}
async function loadBatch(batch) {
try {
var profiles = await profileLoader(batch.map(function (record) { return record.username; }), batch.some(function (record) { return record.force; }));
batch.forEach(function (record) {
var value = profiles && profiles[record.username];
var state = value && value.status ? value : classifyPageProfile(value);
if (state.status === 'ready' || (profileOptions.snapshot && state.status === 'stale')) state = classifyPageProfile(state.profile);
if (profileOptions.snapshot && state.status === 'missing') state = { status: 'unavailable', profile: null };
record.profile = state.profile || null;
record.status = state.status;
record.force = false;
if (record.status === 'ready') allProfiles[record.username] = record.profile;
else delete allProfiles[record.username];
queueRecord(record);
});
} catch (err) {
batch.forEach(function (record) { record.status = record.profile ? 'stale' : 'unavailable'; record.force = false; delete allProfiles[record.username]; queueRecord(record); });
} finally {
activeBatches--;
checkFreshness();
pumpProfiles();
}
}
function resetPage() {
if (!config.sortByTrust || !signalsEnabled(config)) restoreSort();
hideTooltip();
document.querySelectorAll('.ts-filtered').forEach(removeFilter);
document.querySelectorAll('.ts-sparkline, .ts-profile-state').forEach(function (el) { el.remove(); });
document.querySelectorAll('a.hnuser').forEach(function (el) { delete el.dataset.tsProcessed; delete el._tsIndicator; });
pageRecords.forEach(function (record) { record.signals = null; queueRecord(record); });
pumpProfiles();
maybeSort();
}
function startProfiles(loader, options) {
profileLoader = loader;
profileOptions = options || {};
allProfiles = Object.create(null);
document.querySelectorAll('a.hnuser').forEach(function (link) {
var username = link.textContent.trim();
if (!pageRecords.has(username)) pageRecords.set(username, { username: username, links: [], visible: new Set(), status: 'queued', profile: null });
var record = pageRecords.get(username);
record.links.push(link);
var rect = link.getBoundingClientRect();
if (link.getClientRects().length && rect.bottom >= -600 && rect.top <= window.innerHeight + 600) record.visible.add(link);
});
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var record = pageRecords.get(entry.target.textContent.trim());
if (!record) return;
if (entry.isIntersecting) record.visible.add(entry.target);
else record.visible.delete(entry.target);
});
}, { rootMargin: '600px' });
pageRecords.forEach(function (record) { record.links.forEach(function (link) { observer.observe(link); }); });
}
pageRecords.forEach(queueRecord);
pumpProfiles();
}
var config = getConfig();
function loadConfig() {
return browser.storage.local.get('trustspark-config').then(function (result) {
config = getConfig(result['trustspark-config']);
}).catch(function () { /* Defaults remain available if storage fails. */ });
}
browser.storage.onChanged.addListener(function (changes, area) {
if (area !== 'local' || !changes['trustspark-config']) return;
config = getConfig(changes['trustspark-config'].newValue);
resetPage();
});
loadConfig().then(function () {
startProfiles(function (usernames, force) {
return browser.runtime.sendMessage({ type: 'getProfileStates', usernames: usernames, force: force });
});
});
})();
Download file · SHA-256: 80e255f7e1c2e57a74edee51ee54ebbf72fe200c8f2c14ad4666c874b90147ec
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
<rect width="18" height="18" fill="#27ae60"/>
<text x="9" y="14" text-anchor="middle" fill="white" font-size="14" font-family="Verdana,Geneva,sans-serif" font-weight="bold">Y</text>
</svg>
Download file · SHA-256: 4239df2c3d0ac09db79eb89e36c60b638a0cbe74babff213f88f8e81a55f52c2
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<rect width="48" height="48" fill="#27ae60"/>
<text x="24" y="36" text-anchor="middle" fill="white" font-size="36" font-family="Verdana,Geneva,sans-serif" font-weight="bold">Y</text>
</svg>
Download file · SHA-256: 2ea7043b68702e45b2327d1250c9e65aed9eeb5a436b558e1990951f7cae56db
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<rect width="96" height="96" fill="#27ae60"/>
<text x="48" y="72" text-anchor="middle" fill="white" font-size="72" font-family="Verdana,Geneva,sans-serif" font-weight="bold">Y</text>
</svg>
Download file · SHA-256: 060e2bbd5ee7701a61b272a079763d573b3764afbc3d62ff904221ab03b1fa45
{
"manifest_version": 2,
"name": "Hacker News TrustSpark",
"version": "1.0.12",
"description": "Trust signals for Hacker News users",
"permissions": [
"storage",
"*://news.ycombinator.com/*",
"*://hacker-news.firebaseio.com/*",
"*://hn.algolia.com/*"
],
"icons": {
"48": "icons/y48.svg",
"96": "icons/y96.svg"
},
"browser_action": {
"default_popup": "popup/popup.html",
"default_icon": "icons/y18.svg"
},
"background": {
"scripts": ["background.js"],
"persistent": false
},
"content_scripts": [
{
"matches": ["*://news.ycombinator.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"browser_specific_settings": {
"gecko": {
"id": "trustspark@trustspark.dev",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": ["none"]
}
},
"gecko_android": {
"id": "trustspark@trustspark.dev",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": ["none"]
}
}
}
}
Download file · SHA-256: 29b54999bce01ee94b94ad6fdcf5bb6c52a8ef15a0d1311e81fddaa5d3331ccb
.ts-settings, .ts-settings * {
box-sizing: border-box;
}
.ts-settings {
width: 340px;
max-width: 100vw;
max-height: 580px;
overflow-y: auto;
margin: 0;
padding: 12px 16px;
background: #f6f6ef;
font-family: Verdana, Geneva, sans-serif;
font-size: 9pt;
color: #828282;
}
.ts-settings .header{
color: #000;
font-size: 11pt;
font-weight: bold;
}
.ts-settings hr{
border: none;
border-top: 1px solid #ccc;
margin: 8px 0;
}
.ts-settings .section{
margin-bottom: 12px;
}
.ts-settings .section-header{
color: #000;
font-weight: bold;
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.ts-settings .label-row{
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0;
}
.ts-settings input[type="number"],
.ts-settings input[type="date"] {
width: 80px;
padding: 1px 4px;
border: 1px solid #ccc;
border-radius: 2px;
font-size: 9pt;
}
.ts-settings input.inline-input{
width: 55px;
padding: 1px 4px;
border: 1px solid #ccc;
border-radius: 2px;
font-size: 9pt;
}
.ts-settings fieldset{
border: 1px solid #ddd;
border-radius: 3px;
padding: 2px 8px 6px;
margin: 4px 0;
}
.ts-settings fieldset legend{
font-size: 8pt;
font-weight: bold;
padding: 0 4px;
}
.ts-settings .inline-row{
display: flex;
align-items: center;
gap: 4px;
margin: 4px 0;
font-size: 8.5pt;
}
.ts-settings .hint{
font-size: 8pt;
color: #666;
margin-top: 2px;
}
.ts-settings .bar-scale-hint{
font-size: 8pt;
color: #666;
font-style: italic;
}
.ts-settings .btn-row{
display: flex;
gap: 8px;
}
.ts-settings .btn-save{
padding: 4px 12px;
background: #ff6600;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 9pt;
}
.ts-settings .btn-reset{
padding: 4px 12px;
background: #828282;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 9pt;
}
@media (pointer: coarse) {
.ts-settings input[type="number"], .ts-settings input[type="date"], .ts-settings button { min-height: 32px; }
}
.ts-settings .label-row, .ts-settings .inline-row{ gap: 6px; }
.ts-settings .btn-row{ position: sticky; bottom: -12px; background: #f6f6ef; padding: 8px 0; }
.ts-settings #ts-status{ color: #444; }
@media (max-width: 360px) {
.ts-settings { padding: 10px; }
.ts-settings .section-header{ flex-wrap: wrap; gap: 4px; }
}
Download file · SHA-256: 49b003aebddb8d438e328e340346d578db9aff820e5c480ba42971c3c56f9d69
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TrustSpark Settings</title>
<link rel="stylesheet" href="popup.css">
</head>
<body class="ts-settings">
<div id="popup-root"></div>
<script src="popup.js"></script>
</body>
</html>
Download file · SHA-256: 2a469ae1829b3856ac84aa2dc30c2d4c694c4e6168203521198786ab799fe5c8
// Generated by node scripts/build.cjs. Edit app/ instead.
(function () {
"use strict";
var DEFAULT_CONFIG = {
karma: {
enabled: true,
ceiling: 400000,
poor: { below: 2, weight: 0 },
adequate: { below: 1000, weight: 1 },
good: { above: 1000, weight: 2 },
},
comments: {
enabled: true,
ceiling: 1000,
poor: { below: 3, weight: 0 },
adequate: { below: 100, weight: 1 },
good: { above: 99, weight: 2 },
},
submissions: {
enabled: true,
ceiling: 1 / 14,
poor: { above: 1, weight: 0 },
adequate: { above: 1 / 7, weight: 1 },
good: { below: 1 / 7, weight: 2 },
},
comment_rate: {
enabled: false,
ceiling: 10,
poor: { below: 1 / 30, weight: 0 },
adequate: { below: 1, weight: 1 },
good: { above: 1, weight: 2 },
},
created: {
enabled: true,
ceiling: '2009-01-01',
poor: { below: 365, weight: 0 },
adequate: { below: 1825, weight: 1 },
good: { above: 1825, weight: 2 },
},
minTrustScore: 2,
sortSparklines: false,
invertSparklines: false,
sortByTrust: false,
};
function deepMerge(defaults, overrides) {
var result = JSON.parse(JSON.stringify(defaults));
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return result;
Object.keys(defaults).forEach(function (key) {
var value = overrides[key];
if (defaults[key] && typeof defaults[key] === 'object') {
result[key] = deepMerge(defaults[key], value);
} else if (typeof value === typeof defaults[key]) {
if (typeof value === 'number' && (!Number.isFinite(value) ||
(key === 'ceiling' && value <= 0) ||
(key !== 'weight' && value < 0))) return;
if (typeof value === 'string' && (!Number.isFinite(Date.parse(value)) || Date.parse(value) >= Date.now())) return;
result[key] = value;
}
});
return result;
}
// ── Signal evaluation ──────────────────────────────────────────────
function evalKarma(profile, cfg) {
var k = profile.karma || 0;
var bar = Math.min(1, Math.log(Math.max(0, k) + 1) / Math.log(cfg.ceiling + 1));
var condition;
if (k < cfg.poor.below) condition = 'poor';
else if (k < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalComments(profile, cfg) {
var c = profile.total_comments || 0;
var bar = Math.min(1, c / cfg.ceiling);
var condition;
if (c < cfg.poor.below) condition = 'poor';
else if (c < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalSubmissions(profile, cfg) {
var rate = profile.submissions_rate || 0;
var bar = Math.min(1, rate > 0 ? Math.min(1, cfg.ceiling / rate) : 1);
var condition;
if (rate > cfg.poor.above) condition = 'poor';
else if (rate > cfg.adequate.above) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalCommentRate(profile, cfg) {
var rate = profile.comments_rate || 0;
var bar = Math.min(1, rate / cfg.ceiling);
var condition;
if (rate < cfg.poor.below) condition = 'poor';
else if (rate < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
function evalCreated(profile, cfg) {
var created = new Date(profile.created_at).getTime();
var ceiling = new Date(cfg.ceiling).getTime();
var now = Date.now();
var age = now - created;
var maxAge = now - ceiling;
var bar = Math.min(1, age / maxAge);
var days = age / (24 * 3600 * 1000);
var condition;
if (days < cfg.poor.below) condition = 'poor';
else if (days < cfg.adequate.below) condition = 'adequate';
else condition = 'good';
return { bar: bar, condition: condition };
}
var COLORS = { poor: '#e74c3c', adequate: '#f39c12', good: '#27ae60' };
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function friendlyRate(dailyRate) {
if (dailyRate >= 1) return dailyRate.toFixed(1) + '/day';
if (dailyRate * 7 >= 1) return (dailyRate * 7).toFixed(1) + '/week';
if (dailyRate * 30 >= 1) return (dailyRate * 30).toFixed(1) + '/month';
return (dailyRate * 365).toFixed(1) + '/year';
}
function friendlyAge(isoDate) {
var ms = Date.now() - new Date(isoDate).getTime();
var days = ms / (24 * 3600 * 1000);
if (days >= 365.25) return Math.floor(days / 365.25) + 'y ' + Math.floor((days % 365.25) / 30) + 'mo';
if (days >= 30) return Math.floor(days / 30) + 'mo';
return Math.floor(days) + 'd';
}
function signalsEnabled(config) {
return ['karma', 'comments', 'submissions', 'comment_rate', 'created'].some(function (key) { return config[key].enabled; });
}
function evaluateProfile(profile, config) {
var signals = [];
if (config.karma.enabled) {
var r = evalKarma(profile, config.karma);
signals.push({ name: 'Karma', detail: formatNumber(profile.karma), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.karma[r.condition].weight });
}
if (config.comments.enabled) {
var r = evalComments(profile, config.comments);
signals.push({ name: 'Comments', detail: formatNumber(profile.total_comments), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comments[r.condition].weight });
}
if (config.submissions.enabled) {
var r = evalSubmissions(profile, config.submissions);
signals.push({ name: 'Submissions', detail: formatNumber(profile.total_submissions) + ' (' + friendlyRate(profile.submissions_rate) + ')', bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.submissions[r.condition].weight });
}
if (config.comment_rate.enabled) {
var r = evalCommentRate(profile, config.comment_rate);
signals.push({ name: 'Comment Rate', detail: friendlyRate(profile.comments_rate || 0), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.comment_rate[r.condition].weight });
}
if (config.created.enabled) {
var r = evalCreated(profile, config.created);
signals.push({ name: 'Age', detail: friendlyAge(profile.created_at), bar: r.bar, color: COLORS[r.condition], condition: r.condition, weight: config.created[r.condition].weight });
}
return signals;
}
var PRESETS = {
default: DEFAULT_CONFIG,
'high-submission-penalty': deepMerge(DEFAULT_CONFIG, { submissions: { poor: { weight: -5 } } }),
};
function getConfig(saved, preset) {
return deepMerge(PRESETS[preset || 'default'], saved);
}
function validateConfig(candidate) {
var badOrder = ['karma', 'comments', 'comment_rate', 'created'].some(function (key) {
return candidate[key].enabled && candidate[key].poor.below > candidate[key].adequate.below;
}) || (candidate.submissions.enabled && candidate.submissions.adequate.above > candidate.submissions.poor.above);
return badOrder ? 'Check thresholds: the adequate range must lie between poor and good.' : '';
}
var PROFILE_TTL_MS = 6 * 60 * 60 * 1000;
function classifyProfile(profile) {
if (!profile) return { status: 'missing', profile: null };
var valid = Number.isFinite(Date.parse(profile.created_at)) && Number.isFinite(profile.karma) &&
['total_comments', 'total_submissions', 'submissions_rate'].every(function (key) {
return Number.isFinite(profile[key]) && profile[key] >= 0;
}) && (profile.comments_rate == null || (Number.isFinite(profile.comments_rate) && profile.comments_rate >= 0));
if (!valid) return { status: 'unavailable', profile: null };
var checked = Date.parse(profile.checked_at);
var fresh = Number.isFinite(checked) && checked <= Date.now() + 300000 && Date.now() - checked < PROFILE_TTL_MS;
return { status: fresh ? 'ready' : 'stale', profile: profile };
}
function mountSettings(root, options) {
var config = getConfig(options.config, options.preset);
function saveConfig(value) { return Promise.resolve().then(function () { return options.save(getConfig(value, options.preset)); }); }
function setNestedField(obj, path, value) {
var parts = path.split('.');
var cur = obj;
for (var i = 0; i < parts.length - 1; i++) {
if (!cur[parts[i]]) cur[parts[i]] = {};
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
var tierColors = { Poor: '#e74c3c', Adequate: '#f39c12', Good: '#27ae60' };
function signalSection(title, key, ceilingField, tiers) {
var enabled = config[key].enabled;
var checked = enabled ? ' checked' : '';
var bodyDisplay = enabled ? 'block' : 'none';
var bodyId = 'ts-section-' + key;
var h = '<div class="section">';
h += '<div class="section-header"><span>' + title + '</span>';
h += '<label><input type="checkbox" data-signal="' + key + '" data-field="enabled"' + checked + ' data-toggle="' + bodyId + '"> Enabled</label></div>';
h += '<div id="' + bodyId + '" style="display:' + bodyDisplay + '">';
// Ceiling
h += '<div class="label-row" style="margin-bottom:6px;">';
h += '<span>' + ceilingField.label + ' <span class="bar-scale-hint">(bar scale)</span></span>';
h += '<input type="' + (ceilingField.type || 'number') + '" data-signal="' + key + '" data-field="' + ceilingField.field + '" value="' + ceilingField.value + '" step="any">';
h += '</div>';
// Tier fieldsets
tiers.forEach(function (tier) {
var color = tierColors[tier.name] || '#828282';
h += '<fieldset style="border-color:' + color + ';">';
h += '<legend style="color:' + color + ';">' + tier.name + '</legend>';
tier.fields.forEach(function (f) {
if (f.prefix !== undefined) {
h += '<div class="inline-row">';
h += '<span>' + f.prefix + '</span>';
h += '<input type="number" class="inline-input" data-signal="' + key + '" data-field="' + f.field + '" value="' + f.value + '" step="any">';
h += '<span>' + f.suffix + '</span>';
h += '</div>';
} else {
h += '<div class="label-row">';
h += '<span style="font-size:8.5pt;">' + f.label + '</span>';
h += '<input type="' + (f.type || 'number') + '" data-signal="' + key + '" data-field="' + f.field + '" value="' + f.value + '" step="any">';
h += '</div>';
}
});
h += '</fieldset>';
});
h += '</div></div>';
return h;
}
function buildPopupHTML() {
var html = '<span class="header">TrustSpark Settings</span>';
if (options.preset === 'high-submission-penalty') html += '<p class="hint">Demo preset: High submission penalty</p>';
html += '<hr>';
html += signalSection('Karma', 'karma',
{ label: 'Ceiling', field: 'ceiling', value: config.karma.ceiling },
[
{ name: 'Good', fields: [
{ prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.karma.good.weight },
]},
{ name: 'Adequate', fields: [
{ prefix: 'Below', suffix: 'karma points', field: 'adequate.below', value: config.karma.adequate.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.karma.adequate.weight },
]},
{ name: 'Poor', fields: [
{ prefix: 'Below', suffix: 'karma points', field: 'poor.below', value: config.karma.poor.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.karma.poor.weight },
]},
]
);
html += signalSection('Comments', 'comments',
{ label: 'Ceiling', field: 'ceiling', value: config.comments.ceiling },
[
{ name: 'Good', fields: [
{ prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.comments.good.weight },
]},
{ name: 'Adequate', fields: [
{ prefix: 'Below', suffix: 'total comments', field: 'adequate.below', value: config.comments.adequate.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.comments.adequate.weight },
]},
{ name: 'Poor', fields: [
{ prefix: 'Below', suffix: 'total comments', field: 'poor.below', value: config.comments.poor.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.comments.poor.weight },
]},
]
);
html += signalSection('Submission Rate', 'submissions',
{ label: 'Ceiling (daily rate)', field: 'ceiling', value: Math.round(config.submissions.ceiling * 10000) / 10000 },
[
{ name: 'Good', fields: [
{ prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.submissions.good.weight },
]},
{ name: 'Adequate', fields: [
{ prefix: 'More than', suffix: 'submissions per day', field: 'adequate.above', value: Math.round(config.submissions.adequate.above * 10000) / 10000 },
{ prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.submissions.adequate.weight },
]},
{ name: 'Poor', fields: [
{ prefix: 'More than', suffix: 'submissions per day', field: 'poor.above', value: config.submissions.poor.above },
{ prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.submissions.poor.weight },
]},
]
);
html += signalSection('Comment Rate', 'comment_rate',
{ label: 'Ceiling (daily rate)', field: 'ceiling', value: config.comment_rate.ceiling },
[
{ name: 'Good', fields: [
{ prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.comment_rate.good.weight },
]},
{ name: 'Adequate', fields: [
{ prefix: 'Fewer than', suffix: 'comments per day', field: 'adequate.below', value: config.comment_rate.adequate.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.comment_rate.adequate.weight },
]},
{ name: 'Poor', fields: [
{ prefix: 'Fewer than', suffix: 'comments per day', field: 'poor.below', value: Math.round(config.comment_rate.poor.below * 10000) / 10000 },
{ prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.comment_rate.poor.weight },
]},
]
);
html += signalSection('Account Age', 'created',
{ label: 'Ceiling date', field: 'ceiling', value: config.created.ceiling, type: 'date' },
[
{ name: 'Good', fields: [
{ prefix: 'Adds', suffix: 'trust points', field: 'good.weight', value: config.created.good.weight },
]},
{ name: 'Adequate', fields: [
{ prefix: 'Below', suffix: 'days old', field: 'adequate.below', value: config.created.adequate.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'adequate.weight', value: config.created.adequate.weight },
]},
{ name: 'Poor', fields: [
{ prefix: 'Below', suffix: 'days old', field: 'poor.below', value: config.created.poor.below },
{ prefix: 'Adds', suffix: 'trust points', field: 'poor.weight', value: config.created.poor.weight },
]},
]
);
html += '<hr>';
html += '<div class="section">';
html += '<div class="section-header"><span>Trust Filter</span></div>';
html += '<div class="label-row"><span>Min points to show (0 = off)</span>';
html += '<input type="number" min="0" step="1" data-top-field="minTrustScore" value="' + config.minTrustScore + '"></div>';
html += '<div class="hint">Raw total points (e.g. 2 = hide content from accounts with 0 or 1 points). 0 = off.</div>';
html += '</div>';
html += '<hr>';
html += '<div class="section">';
html += '<div class="section-header"><span>Sorting</span></div>';
html += '<div class="label-row"><span>Sort by trust (frontpage & comments)</span>';
html += '<input type="checkbox" data-top-field="sortByTrust"' + (config.sortByTrust ? ' checked' : '') + '></div>';
html += '</div>';
html += '<hr>';
html += '<div class="section">';
html += '<div class="section-header"><span>Sparkline Display</span></div>';
html += '<div class="label-row"><span>Sort sparklines (highest to lowest)</span>';
html += '<input type="checkbox" data-top-field="sortSparklines"' + (config.sortSparklines ? ' checked' : '') + '></div>';
html += '<div class="label-row"><span>Invert sort (lowest to highest)</span>';
html += '<input type="checkbox" data-top-field="invertSparklines"' + (config.invertSparklines ? ' checked' : '') + '></div>';
html += '</div>';
html += '<hr>';
html += '<p id="ts-status" role="status"></p>';
html += '<div class="btn-row">';
html += '<button id="ts-save" class="btn-save">Save</button>';
html += '<button id="ts-reset" class="btn-reset">Reset Defaults</button>';
html += '</div>';
return html;
}
function attachListeners() {
root.querySelectorAll('input').forEach(function (input) {
var field = input.dataset.field || input.dataset.topField || '';
input.setAttribute('aria-label', (input.dataset.signal || 'Settings') + ': ' + input.parentNode.textContent.trim());
if (input.type === 'number' || input.type === 'date') {
input.required = true;
if (input.type === 'number' && !field.endsWith('weight')) input.min = '0';
if (input.type === 'date') input.max = new Date().toISOString().slice(0, 10);
}
});
function syncSignal(input) {
root.querySelector('#' + input.dataset.toggle).style.display = input.checked ? 'block' : 'none';
root.querySelectorAll('input[data-signal="' + input.dataset.signal + '"]:not([data-field="enabled"])').forEach(function (field) { field.disabled = !input.checked; });
}
// Disabled signals must not block saving with hidden invalid fields.
root.querySelectorAll('input[data-toggle]').forEach(function (input) {
syncSignal(input);
input.addEventListener('change', function () { syncSignal(input); });
});
root.querySelector('#ts-save').addEventListener('click', function () {
var inputs = Array.from(root.querySelectorAll('input'));
inputs.forEach(function (input) {
if (input.type === 'number' && input.dataset.field === 'ceiling') input.setCustomValidity(Number(input.value) > 0 ? '' : 'Ceiling must be greater than zero.');
});
if (inputs.some(function (input) { return !input.reportValidity(); })) return;
var candidate = getConfig(config, options.preset);
// Collect values from inputs
root.querySelectorAll('input[data-signal]').forEach(function (input) {
if (input.disabled) return;
var signal = input.dataset.signal;
var field = input.dataset.field;
if (input.type === 'checkbox') {
candidate[signal][field] = input.checked;
} else if (input.type === 'date') {
setNestedField(candidate[signal], field, input.value);
} else {
setNestedField(candidate[signal], field, parseFloat(input.value) || 0);
}
});
root.querySelectorAll('input[data-top-field]').forEach(function (input) {
candidate[input.dataset.topField] = input.type === 'checkbox' ? input.checked : (parseFloat(input.value) || 0);
});
var error = validateConfig(candidate);
if (error) {
root.querySelector('#ts-status').textContent = error;
return;
}
saveConfig(candidate).then(function () {
config = candidate;
root.querySelector('#ts-status').textContent = 'Settings saved.';
var btn = root.querySelector('#ts-save');
btn.textContent = 'Saved!';
setTimeout(function () { btn.textContent = 'Save'; }, 1200);
if (options.onSaved) options.onSaved();
}).catch(function () { root.querySelector('#ts-status').textContent = 'Could not save settings. Please try again.'; });
});
root.querySelector('#ts-reset').addEventListener('click', function () {
var defaults = getConfig(null, options.preset);
saveConfig(defaults).then(function () {
config = defaults;
render();
root.querySelector('#ts-status').textContent = 'Defaults restored.';
}).catch(function () { root.querySelector('#ts-status').textContent = 'Could not reset settings. Please try again.'; });
});
}
function render() {
while (root.firstChild) root.removeChild(root.firstChild);
var parser = new DOMParser();
var parsed = parser.parseFromString(buildPopupHTML(), 'text/html');
while (parsed.body.firstChild) root.appendChild(parsed.body.firstChild);
attachListeners();
}
render();
}
browser.storage.local.get('trustspark-config').catch(function () { return {}; }).then(function (result) {
mountSettings(document.getElementById('popup-root'), {
config: result['trustspark-config'],
save: function (value) { return browser.storage.local.set({ 'trustspark-config': value }); },
});
});
})();