Greasy Fork is available in English.
Save Bitcointalk posts locally and automatically show your remaining sMerit.
// ==UserScript==
// @name Bitcointalk Saved Posts
// @namespace https://bitcointalk.org/
// @version 1.1.0
// @description Save Bitcointalk posts locally and automatically show your remaining sMerit.
// @author Misfoxie
// @match https://bitcointalk.org/*
// @match http://bitcointalk.org/*
// @match https://www.bitcointalk.org/*
// @match http://www.bitcointalk.org/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect bitcointalk.org
// @connect www.bitcointalk.org
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'btt_saved_posts_v1';
const POST_SELECTOR = '.subject[id^="subject_"]';
const SMERIT_CACHE_KEY = 'btt_remaining_smerit_cache';
const SMERIT_CACHE_TIME_KEY = 'btt_remaining_smerit_cache_time';
const SMERIT_CACHE_DURATION = 5 * 60 * 1000;
const css = `
#bttsp-launcher { position:fixed; right:18px; bottom:18px; z-index:99998; border:0; border-radius:999px; background:#263b55; color:#fff; padding:11px 15px; box-shadow:0 4px 18px #0005; cursor:pointer; font:600 13px Arial,sans-serif; }
#bttsp-launcher:hover { background:#345273; }
#bttsp-panel { position:fixed; right:18px; bottom:68px; z-index:99999; width:min(390px,calc(100vw - 28px)); max-height:min(650px,calc(100vh - 90px)); display:none; flex-direction:column; overflow:hidden; border:1px solid #8da0b4; border-radius:10px; background:#f5f7fa; color:#1d2936; box-shadow:0 10px 36px #0007; font:13px Arial,sans-serif; text-align:left; }
#bttsp-panel.bttsp-open { display:flex; }
.bttsp-head { display:flex; align-items:center; justify-content:space-between; padding:13px 14px; background:#263b55; color:#fff; }
.bttsp-head strong { font-size:15px; }
.bttsp-close { border:0; background:transparent; color:#fff; font-size:22px; line-height:18px; cursor:pointer; }
#bttsp-list { overflow:auto; padding:8px; }
.bttsp-empty { padding:28px 15px; color:#627181; text-align:center; line-height:1.5; }
.bttsp-thread { margin-bottom:8px; overflow:hidden; border:1px solid #c8d1db; border-radius:7px; background:#fff; }
.bttsp-thread summary { position:relative; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px 11px; cursor:pointer; list-style:none; }
.bttsp-thread summary::-webkit-details-marker { display:none; }
.bttsp-summary-text { min-width:0; }
.bttsp-title { display:block; overflow:hidden; color:#203f64; font-weight:700; text-overflow:ellipsis; white-space:nowrap; }
.bttsp-preview { display:block; margin-top:5px; overflow:hidden; color:#596978; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.bttsp-meta { display:block; margin-top:5px; color:#8995a1; font-size:11px; }
.bttsp-open-latest { display:inline-block; align-self:center; border:1px solid #315a83; border-radius:5px; background:#345f89; color:#fff !important; padding:7px 10px; font-weight:700; text-decoration:none !important; white-space:nowrap; }
.bttsp-open-latest:hover { background:#264b70; }
.bttsp-posts { border-top:1px solid #dce2e8; }
.bttsp-item { display:grid; grid-template-columns:1fr auto; gap:8px; padding:9px 11px; border-bottom:1px solid #edf0f3; }
.bttsp-item:last-child { border-bottom:0; }
.bttsp-link { min-width:0; color:#294f79; text-decoration:none; }
.bttsp-link:hover { text-decoration:underline; }
.bttsp-link span { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.bttsp-link small { display:block; margin-top:3px; color:#7a8793; }
.bttsp-delete { align-self:center; border:0; border-radius:4px; background:#eceff2; color:#8b3030; padding:4px 7px; cursor:pointer; }
.bttsp-save { position:relative !important; top:2px !important; margin-left:7px !important; border:1px solid #526b85 !important; border-radius:4px !important; background:#edf2f6 !important; color:#27435f !important; padding:3px 7px !important; cursor:pointer !important; font:bold 11px Arial,sans-serif !important; vertical-align:middle !important; }
.bttsp-save:hover { background:#dce7ef !important; }
.bttsp-save.bttsp-is-saved { border-color:#a56d08 !important; background:#ffe7a8 !important; color:#634200 !important; }
.bttsp-smerit { display:inline-block; margin-left:7px; border:1px solid #9abe92; border-radius:11px; background:#dcebd8; color:#17601d; padding:3px 9px; font:bold 12px Arial,sans-serif; line-height:1.25; vertical-align:middle; white-space:nowrap; }
.bttsp-smerit.bttsp-smerit-error { background:#eee; color:#777; }
.bttsp-saved-row { box-shadow:inset 5px 0 #e0a11d !important; }
.bttsp-saved-cell { background-image:linear-gradient(90deg,rgba(255,224,132,.30),rgba(255,255,255,0) 55%) !important; }
.bttsp-saved-cell, .bttsp-saved-cell * { color:#71869a !important; }
.bttsp-saved-cell a, .bttsp-saved-cell a * { color:#4f789f !important; }
.bttsp-saved-row .bttsp-poster-text { color:#71869a !important; }
.bttsp-saved-row .poster_info a .bttsp-poster-text { color:#4f789f !important; }
.bttsp-saved-cell .bttsp-save { border-color:#a56d08 !important; background:#ffe7a8 !important; color:#634200 !important; opacity:1 !important; }
.bttsp-saved-cell .bttsp-smerit { border-color:#9abe92 !important; background:#dcebd8 !important; color:#17601d !important; opacity:1 !important; }
.bttsp-saved-cell .bttsp-saved-label { background:#e0a11d !important; color:#302000 !important; }
.bttsp-saved-label { display:inline-block; margin-left:7px; border-radius:10px; background:#e0a11d; color:#302000; padding:2px 7px; font:bold 10px Arial,sans-serif; vertical-align:middle; }
#bttsp-toast { position:fixed; left:50%; bottom:24px; z-index:100000; transform:translateX(-50%); border-radius:6px; background:#182536; color:#fff; padding:9px 14px; box-shadow:0 3px 12px #0006; font:13px Arial,sans-serif; opacity:0; pointer-events:none; transition:opacity .2s; }
#bttsp-toast.bttsp-show { opacity:1; }
@media (max-width:600px) { #bttsp-launcher { right:10px; bottom:10px; } #bttsp-panel { right:7px; bottom:58px; } }
`;
let saved = loadSaved();
let toastTimer;
function loadSaved() {
const value = GM_getValue(STORAGE_KEY, []);
if (!Array.isArray(value)) return [];
return value.filter(item => item && item.postId && item.topicId && item.url);
}
function persist() {
GM_setValue(STORAGE_KEY, saved);
}
function getCachedSmerit() {
const value = localStorage.getItem(SMERIT_CACHE_KEY);
const time = Number(localStorage.getItem(SMERIT_CACHE_TIME_KEY));
if (value === null || !time || Date.now() - time > SMERIT_CACHE_DURATION) return null;
return value;
}
function cacheSmerit(value) {
localStorage.setItem(SMERIT_CACHE_KEY, String(value));
localStorage.setItem(SMERIT_CACHE_TIME_KEY, String(Date.now()));
}
function findMeritLinks() {
return [...document.querySelectorAll('a[href*="action=merit"]')];
}
function extractSmerit(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const text = (doc.body ? doc.body.textContent : html).replace(/\s+/g, ' ').trim();
const patterns = [
/You have\s+(\d+)\s+sendable merits?/i,
/You have\s+(\d+)\s+sMerits?/i,
/You have\s+(\d+)\s+available merits?/i,
/You can send\s+(\d+)\s+merits?/i,
/sendable merits?\s*[:\-]?\s*(\d+)/i,
/sMerits?\s*[:\-]?\s*(\d+)/i
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match) return match[1];
}
return null;
}
function showSmerit(value, failed = false) {
findMeritLinks().forEach(link => {
let badge = link.parentElement && link.parentElement.querySelector(`.bttsp-smerit[data-for="${link.href}"]`);
if (!badge) {
badge = el('span', 'bttsp-smerit');
badge.dataset.for = link.href;
link.insertAdjacentElement('afterend', badge);
}
badge.textContent = `sMerit: ${value}`;
badge.classList.toggle('bttsp-smerit-error', failed);
badge.title = failed ? 'Could not read the current sMerit balance' : 'Remaining sendable merit (cached for five minutes)';
});
}
function initSmerit() {
const meritLinks = findMeritLinks();
if (!meritLinks.length) return;
const cached = getCachedSmerit();
if (cached !== null) {
showSmerit(cached);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: meritLinks[0].href,
headers: { Accept: 'text/html' },
onload(response) {
const value = extractSmerit(response.responseText || '');
if (value !== null) {
cacheSmerit(value);
showSmerit(value);
} else {
showSmerit('?', true);
console.warn('[Bitcointalk Saved Posts] Could not detect remaining sMerit.');
}
},
onerror() {
showSmerit('?', true);
console.warn('[Bitcointalk Saved Posts] Failed to load the Merit page.');
}
});
}
function normalizeText(value, maxLength) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}\u2026` : text;
}
function parseTopicId(url) {
const match = String(url).match(/[?;&]topic=(\d+)/i);
return match ? match[1] : '';
}
function getPostData(subject) {
const postId = subject.id.replace('subject_', '');
const contentCell = subject.closest('.td_headerandpost') || subject.closest('td');
const postTable = contentCell && contentCell.closest('table');
const permalink = (contentCell && contentCell.querySelector(`a[href*="#msg${postId}"]`)) || subject.querySelector('a');
const postBody = contentCell && contentCell.querySelector('.post');
const authorLink = postTable && postTable.querySelector('.poster_info a[href*="action=profile"]');
const numberLink = contentCell && contentCell.querySelector('.message_number');
const topicId = parseTopicId(permalink ? permalink.href : location.href);
if (!postId || !topicId || !permalink) return null;
return {
postId,
topicId,
threadTitle: normalizeText((subject.querySelector('a') || subject).textContent.replace(/^Re:\s*/i, ''), 180) || `Topic ${topicId}`,
postTitle: normalizeText((subject.querySelector('a') || subject).textContent, 180),
author: normalizeText(authorLink && authorLink.textContent, 80) || 'Unknown member',
postNumber: normalizeText(numberLink && numberLink.textContent, 20) || `#${postId}`,
snippet: normalizeText(postBody && postBody.textContent, 240) || 'Saved Bitcointalk post',
url: new URL(permalink.href, location.href).href,
savedAt: Date.now()
};
}
function findSaved(postId) {
return saved.find(item => item.postId === String(postId));
}
function savePost(data) {
if (findSaved(data.postId)) {
saved = saved.filter(item => item.postId !== data.postId);
showToast('Post removed from saved posts');
} else {
saved.push(data);
showToast('Post saved on this device');
}
persist();
decoratePosts();
renderPanel();
}
function preparePosterText(postTable) {
const poster = postTable && postTable.querySelector('.poster_info');
if (!poster || poster.dataset.bttspTextPrepared) return;
poster.dataset.bttspTextPrepared = '1';
const walker = document.createTreeWalker(poster, NodeFilter.SHOW_TEXT);
const textNodes = [];
while (walker.nextNode()) {
if (walker.currentNode.nodeValue.trim()) textNodes.push(walker.currentNode);
}
textNodes.forEach(textNode => {
const wrapper = el('span', 'bttsp-poster-text');
textNode.parentNode.insertBefore(wrapper, textNode);
wrapper.appendChild(textNode);
});
}
function removePost(postId) {
saved = saved.filter(item => item.postId !== String(postId));
persist();
decoratePosts();
renderPanel();
showToast('Saved post removed');
}
function decoratePosts() {
document.querySelectorAll(POST_SELECTOR).forEach(subject => {
const data = getPostData(subject);
if (!data) return;
const contentCell = subject.closest('.td_headerandpost') || subject.closest('td');
const postTable = contentCell && contentCell.closest('table');
const buttonArea = contentCell && contentCell.querySelector('.td_buttons > div, .td_buttons');
if (!buttonArea) return;
preparePosterText(postTable);
let button = buttonArea.querySelector(`.bttsp-save[data-post-id="${data.postId}"]`);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = 'bttsp-save';
button.dataset.postId = data.postId;
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
savePost(getPostData(subject));
});
buttonArea.appendChild(button);
}
const isSaved = Boolean(findSaved(data.postId));
button.textContent = isSaved ? '\u2605 Saved' : '\u2606 Save';
button.title = isSaved ? 'Remove this saved post' : 'Save this post locally';
button.classList.toggle('bttsp-is-saved', isSaved);
if (postTable) postTable.classList.toggle('bttsp-saved-row', isSaved);
if (contentCell) contentCell.classList.toggle('bttsp-saved-cell', isSaved);
let label = subject.querySelector('.bttsp-saved-label');
if (isSaved && !label) {
label = document.createElement('span');
label.className = 'bttsp-saved-label';
label.textContent = 'SAVED';
subject.appendChild(label);
} else if (!isSaved && label) {
label.remove();
}
});
}
function groupByThread() {
const groups = new Map();
saved.forEach(post => {
if (!groups.has(post.topicId)) groups.set(post.topicId, []);
groups.get(post.topicId).push(post);
});
return [...groups.values()]
.map(posts => posts.sort((a, b) => b.savedAt - a.savedAt))
.sort((a, b) => b[0].savedAt - a[0].savedAt);
}
function formatDate(timestamp) {
try { return new Date(timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); }
catch (_) { return new Date(timestamp).toLocaleString(); }
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function renderPanel() {
const list = document.getElementById('bttsp-list');
if (!list) return;
list.replaceChildren();
const groups = groupByThread();
if (!groups.length) {
list.appendChild(el('div', 'bttsp-empty', 'No saved posts yet. Open a thread and use the \u2606 Save button beside a post.'));
return;
}
groups.forEach(posts => {
const latest = posts[0];
const details = el('details', 'bttsp-thread');
const summary = document.createElement('summary');
const summaryText = el('span', 'bttsp-summary-text');
summaryText.appendChild(el('span', 'bttsp-title', latest.threadTitle));
summaryText.appendChild(el('span', 'bttsp-preview', latest.snippet));
summaryText.appendChild(el('span', 'bttsp-meta', `${posts.length} saved post${posts.length === 1 ? '' : 's'} \u00b7 latest ${formatDate(latest.savedAt)}`));
const openLatest = el('a', 'bttsp-open-latest', 'Open');
openLatest.href = latest.url;
openLatest.title = `Open the most recently saved post (${latest.postNumber})`;
openLatest.addEventListener('click', event => event.stopPropagation());
summary.append(summaryText, openLatest);
details.appendChild(summary);
const postList = el('div', 'bttsp-posts');
posts.forEach(post => {
const item = el('div', 'bttsp-item');
const link = el('a', 'bttsp-link');
link.href = post.url;
link.title = post.snippet;
link.appendChild(el('span', '', `${post.postNumber} \u2014 ${post.snippet}`));
link.appendChild(el('small', '', `${post.author} \u00b7 ${formatDate(post.savedAt)}`));
const remove = el('button', 'bttsp-delete', '\u2715');
remove.type = 'button';
remove.title = 'Remove saved post';
remove.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
removePost(post.postId);
});
item.append(link, remove);
postList.appendChild(item);
});
details.appendChild(postList);
list.appendChild(details);
});
}
function showToast(message) {
const toast = document.getElementById('bttsp-toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('bttsp-show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('bttsp-show'), 1800);
}
function buildUi() {
const style = document.createElement('style');
style.textContent = css;
const launcher = el('button', '', 'Saved posts');
launcher.id = 'bttsp-launcher';
launcher.type = 'button';
const panel = el('aside');
panel.id = 'bttsp-panel';
panel.setAttribute('aria-label', 'Saved Bitcointalk posts');
const head = el('div', 'bttsp-head');
head.appendChild(el('strong', '', 'Saved threads'));
const close = el('button', 'bttsp-close', '\u00d7');
close.type = 'button';
close.title = 'Close';
head.appendChild(close);
const list = el('div');
list.id = 'bttsp-list';
panel.append(head, list);
const toast = el('div');
toast.id = 'bttsp-toast';
document.head.appendChild(style);
document.body.append(launcher, panel, toast);
launcher.addEventListener('click', () => panel.classList.toggle('bttsp-open'));
close.addEventListener('click', () => panel.classList.remove('bttsp-open'));
document.addEventListener('keydown', event => {
if (event.key === 'Escape') panel.classList.remove('bttsp-open');
});
}
buildUi();
decoratePosts();
renderPanel();
initSmerit();
})();