Bitcointalk Saved Posts

Save Bitcointalk posts locally and automatically show your remaining sMerit.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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