Bitcointalk Saved Posts

Save Bitcointalk posts locally and automatically show your remaining sMerit.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Advertisement:

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

Advertisement:

// ==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();
})();