V2Chat

基于 $V2EX 销毁机制的聊天室

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         V2Chat
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  基于 $V2EX 销毁机制的聊天室
// @match        https://*.v2ex.com/*
// @match        https://v2ex.com/*
// @require      https://unpkg.com/@solana/web3.js@latest/lib/index.iife.min.js
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(async function () {
    'use strict';
    const { Connection, PublicKey, Transaction, TransactionInstruction } = solanaWeb3;

    const V2EX_TOKEN_MINT = "9raUVuzeWUk53co63M4WXLWPWE4Xc6Lpn7RS9dnkpump";
    const BURN_ATA_ADDRESS = "5Q9cjTsKBhm29KwUKFwsAiKFBT263ASBb1LiHdXFWYKW";  // 1nc1nerator11111111111111111111111111111111
    const RPC_URL = "https://solana-rpc.publicnode.com";
    const TOKEN_PROGRAM_ID = new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
    const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
    const MAX_LOAD_COUNT = 15;

    const styleMap = {
        1: 'color:#ffffff;',
        10: 'color:#1eff00;',
        100: 'color:#0070dd;',
        1000: 'color:#a335ee;',
        10000: 'color:#ff8000;'
    };

    const chatBox = document.createElement('div');
    chatBox.id = 'geek-chat';
    chatBox.style.cssText = `
        position: fixed;
        bottom: 20px;
        left: 20px;
        width: 400px;
        background: rgba(0,0,0,0.85);
        border-radius: 8px;
        color: white;
        box-shadow: 0 0 12px rgba(0,0,0,0.6);
        z-index: 9999;
        overflow: hidden;
      `;

    chatBox.innerHTML = `
        <div id="chat-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:#111;border-bottom:1px solid #333;font-weight:bold;">
          <div>V2EX Chat</div>
          <button id="minimize-btn" style="cursor:pointer;font-size:16px;color:#aaa;background:none;border:none;">−</button>
        </div>
        <div id="chat-body">
          <div id="chat-messages" style="max-height:300px;overflow-y:auto;padding:10px;background:#111;"></div>
          <div id="chat-input" style="display:flex;gap:6px;border-top:1px solid #333;padding:8px;background:#111;align-items:center;">
            <div id="value-select-wrapper" style="position:relative;" title="Select message value">
              <button id="current-value-btn" data-value="1" style="border:none;border-radius:4px;padding:6px 10px;font-size:14px;font-weight:bold;cursor:pointer;color:black;min-width:50px;text-align:center;background:white;">1</button>
              <div id="value-buttons" style="position:absolute;bottom:36px;left:0;border-radius:4px;display:flex;gap:4px;opacity:0;pointer-events:none;transition:opacity 0.15s;white-space:nowrap;z-index:10;">
                ${[1, 10, 100, 1000, 10000].map(v => `
                  <button class="value-btn" data-value="${v}" style="border:1px solid transparent;border-radius:4px;padding:6px 10px;font-size:14px;cursor:pointer;color:black;font-weight:bold;background-${styleMap[v]}${v === 1 ? 'border-color:white;' : ''}">${v}</button>
                `).join('')}
              </div>
            </div>
            <input type="text" id="message-text" placeholder=">_ Type your message..." maxlength="200" style="flex:1;padding:6px 8px;background:transparent;border:1px solid #444;color:white;outline:none;font-size:14px;" autocomplete="off" />
            <button class="send-btn" style="background:#333;border:1px solid #555;color:white;padding:6px 12px;cursor:pointer;font-size:14px;">Send</button>
          </div>
        </div>
      `;

    document.body.appendChild(chatBox);

    const chatHeader = chatBox.querySelector('#chat-header');
    const currentValueBtn = chatBox.querySelector('#current-value-btn');
    const valueBtns = chatBox.querySelectorAll('.value-btn');
    const valuePanel = chatBox.querySelector('#value-buttons');
    const wrapper = chatBox.querySelector('#value-select-wrapper');
    const input = chatBox.querySelector('#message-text');
    const msgArea = chatBox.querySelector('#chat-messages');
    const sendBtn = chatBox.querySelector('.send-btn');
    const minimizeBtn = chatBox.querySelector('#minimize-btn');
    const chatBody = chatBox.querySelector('#chat-body');

    let selectedValue = 1;

    function showValuePanel() {
        valuePanel.style.opacity = '1';
        valuePanel.style.pointerEvents = 'auto';
    }
    function hideValuePanel() {
        valuePanel.style.opacity = '0';
        valuePanel.style.pointerEvents = 'none';
    }

    wrapper.addEventListener('mouseenter', showValuePanel);
    wrapper.addEventListener('mouseleave', () => {
        // 延迟判断,避免快速切换时闪烁
        setTimeout(() => {
            if (!wrapper.matches(':hover') && !valuePanel.matches(':hover')) {
                hideValuePanel();
            }
        }, 100);
    });

    valuePanel.addEventListener('mouseenter', showValuePanel);
    valuePanel.addEventListener('mouseleave', () => {
        setTimeout(() => {
            if (!wrapper.matches(':hover') && !valuePanel.matches(':hover')) {
                hideValuePanel();
            }
        }, 100);
    });

    valueBtns.forEach(btn => {
        btn.addEventListener('click', () => {
            valueBtns.forEach(b => b.style.borderColor = 'transparent');
            btn.style.borderColor = 'white';
            selectedValue = parseInt(btn.dataset.value);
            currentValueBtn.dataset.value = selectedValue;
            currentValueBtn.textContent = selectedValue;
            currentValueBtn.style = `border:none;border-radius:4px;padding:6px 10px;font-size:14px;font-weight:bold;cursor:pointer;min-width:50px;text-align:center;background-${styleMap[selectedValue]}`;
        });
    });

    let sendLoadingIntervalId = null;
    function startSendLoading() {
        if (sendLoadingIntervalId) return;
        const states = ['Send', 'Send.', 'Send..', 'Send...'];
        let index = 0;
        sendLoadingIntervalId = setInterval(() => {
            index = (index + 1) % states.length;
            sendBtn.textContent = states[index];
        }, 500);
    };

    function stopSendLoading(buttonId) {
        if (sendLoadingIntervalId) {
            clearInterval(sendLoadingIntervalId);
            sendLoadingIntervalId = null;
        }
        sendBtn.textContent = 'Send'
    };

    let myUserName = null;
    async function sendMessage() {
        const text = input.value.trim();
        if (!text) return;
        if (sendLoadingIntervalId) return;

        startSendLoading();

        if (!myUserName) {
            const names = await findMemberName();
            if (!names || names.length == 0) {
                alert('发送失败');
                stopSendLoading();
                return;
            }
            myUserName = names[0];
        }

        if (!confirm(`确定消耗 ${currentValueBtn.dataset.value} $V2EX 发送消息吗\n${myUserName}\n${text}`)) {
            stopSendLoading();
            return;
        }

        const memo = {
            u: myUserName,
            m: text
        };
        const success = await burnWithMessage(currentValueBtn.dataset.value, JSON.stringify(memo));
        if (success) {
            alert('发送成功');
            input.value = '';
            input.focus();
            updateAndShowMessage();
            msgArea.scrollTop = msgArea.scrollHeight;
        }
        stopSendLoading();
    }

    sendBtn.addEventListener('click', sendMessage);
    input.addEventListener('keydown', e => {
        if (e.key === 'Enter') sendMessage();
    });

    minimizeBtn.addEventListener('click', () => {
        if (chatBody.style.display === 'none') {
            chatBody.style.display = 'block';
            minimizeBtn.textContent = '−';
        } else {
            chatBody.style.display = 'none';
            minimizeBtn.textContent = '+';
        }
    });

    async function connectWallet() {
        const provider = window.phantom?.solana;
        if (!provider?.isPhantom) {
            return null;
        }
        try {
            const resp = await provider.connect();
            return { provider, publicKey: new PublicKey(resp.publicKey.toString()) };
        } catch (err) {
            return null;
        }
    }

    async function getAssociatedTokenAddress(walletPublicKey, mintPublicKey) {
        const connection = new Connection(RPC_URL, "confirmed");
        try {
            const response = await connection.getProgramAccounts(TOKEN_PROGRAM_ID, {
                filters: [
                    { dataSize: 165 },
                    { memcmp: { offset: 0, bytes: mintPublicKey.toBase58() } },
                    { memcmp: { offset: 32, bytes: walletPublicKey.toBase58() } },
                ],
            });
            return response.length > 0 ? response[0].pubkey.toBase58() : null;
        } catch (err) {
            console.error(err);
            console.log("query ata address failed: " + err.message);
        }
    }

    function createTransferInstructionData(amount) {
        const data = new Uint8Array(9);
        data[0] = 3;

        const amountBigInt = BigInt(amount);
        for (let i = 0; i < 8; i++) {
            data[i + 1] = Number((amountBigInt >> BigInt(i * 8)) & BigInt(0xff));
        }

        return data;
    }

    async function burnWithMessage(tokenAmount, message) {
        try {
            await connectWallet();
        } catch (e) {
            alert('Phantom 钱包初始化失败');
            return false;
        }

        const connection = new Connection(RPC_URL, "confirmed");
        const mintAddress = V2EX_TOKEN_MINT;
        let success = false;
        try {
            const { provider, publicKey } = await connectWallet();

            const amount = Math.floor(tokenAmount * Math.pow(10, 6));

            let fromATA = await getAssociatedTokenAddress(publicKey, new PublicKey(mintAddress));
            if (!fromATA) {
                throw new Error('你暂未创建 $V2EX 代币地址');
            }

            const instructions = [];

            instructions.push(
                new TransactionInstruction({
                    keys: [
                        { pubkey: new PublicKey(fromATA), isSigner: false, isWritable: true },
                        { pubkey: new PublicKey(BURN_ATA_ADDRESS), isSigner: false, isWritable: true },
                        { pubkey: publicKey, isSigner: true, isWritable: false },
                    ],
                    programId: TOKEN_PROGRAM_ID,
                    data: createTransferInstructionData(amount),
                })
            );

            instructions.push(
                new TransactionInstruction({
                    keys: [],
                    programId: MEMO_PROGRAM_ID,
                    data: new TextEncoder().encode(message)
                })
            )

            const transaction = new Transaction();
            instructions.forEach((instruction) => transaction.add(instruction));
            const { blockhash } = await connection.getLatestBlockhash();
            transaction.recentBlockhash = blockhash;
            transaction.feePayer = publicKey;

            const { signature } = await provider.signAndSendTransaction(transaction);
            const timestamp = Math.floor(Date.now() / 1000);
            allMessage[signature] = {
                signature: signature,
                amount: tokenAmount,
                blockTime: timestamp,
                memo: JSON.parse(message),
                _isTemp: true
            };
            success = true;
        } catch (err) {
            console.log('send message failed: ' + err.message);
            alert('发送失败: ' + err.message);
        }
        return success;
    }

    async function fetchMemoTokenTransfersRaw(address, limit = 10, before = null) {
        const endpoint = RPC_URL;

        // JSON-RPC 请求函数
        async function rpc(method, params) {
            const res = await fetch(endpoint, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                    jsonrpc: "2.0",
                    id: 1,
                    method,
                    params
                })
            });
            const json = await res.json();
            return json.result;
        }

        // 尝试从 memo 中提取 JSON 段
        function extractValidMemo(memoStr) {
            if (!memoStr) return null;

            // 尝试提取所有可能的 JSON 段(第一个 { 到最后一个 })
            const start = memoStr.indexOf("{");
            const end = memoStr.lastIndexOf("}");
            if (start === -1 || end === -1 || end <= start) return null;

            const possibleJson = memoStr.slice(start, end + 1);
            try {
                const parsed = JSON.parse(possibleJson);
                delete parsed.size;
                return parsed;
            } catch {
                return null;
            }
        }

        // 计算 token 转账数量
        function getTransferredTokenAmount(meta) {
            const pre = meta?.preTokenBalances || [];
            const post = meta?.postTokenBalances || [];

            for (let i = 0; i < pre.length; i++) {
                const preBal = pre[i];
                const postBal = post.find(pb =>
                    pb.accountIndex === preBal.accountIndex &&
                    pb.mint === preBal.mint &&
                    pb.owner === preBal.owner
                );

                if (postBal) {
                    const delta =
                        parseFloat(postBal.uiTokenAmount.uiAmount) -
                        parseFloat(preBal.uiTokenAmount.uiAmount);
                    if (delta !== 0) {
                        return Math.abs(delta);
                    }
                }
            }
            return 0;
        }

        // Step 1: 获取签名列表
        const sigParams = [address, { limit }];
        if (before) sigParams[1].before = before;

        const signatures = await rpc("getSignaturesForAddress", sigParams);
        const filtered = signatures.filter(s => s.memo && extractValidMemo(s.memo));

        const results = [];

        // Step 2: 遍历有效交易
        for (const sig of filtered) {
            if (allMessage[sig.signature] && !allMessage[sig.signature]._isTemp) continue;

            const tx = await rpc("getTransaction", [sig.signature, { encoding: "jsonParsed" }]);
            if (!tx || !tx.meta || tx.meta.err) continue;

            const memoJson = extractValidMemo(sig.memo);
            if (!memoJson) continue;

            const amount = getTransferredTokenAmount(tx.meta);



            results.push({
                signature: sig.signature,
                memo: memoJson,
                amount,
                blockTime: sig.blockTime
            });
        }

        return results;
    }

    async function findMemberName() {
        const url = `${window.location.protocol}//${window.location.host}/`;
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);

            const html = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');

            const toolsElement = doc.querySelector('.tools');
            if (toolsElement) {
                const memberLinks = Array.from(toolsElement.querySelectorAll('a'))
                    .filter(link => link.getAttribute('href').startsWith('/member/'))
                    .map(link => link.textContent);
                console.log('find member name', memberLinks);
                return memberLinks;
            } else {
                return [];
            }
        } catch (err) {
            console.log('find member name error: ' + err.message);
            return [];
        }
    }

    function getColorByAmount(amount) {
        if (amount < 10) return styleMap[1];
        if (amount < 100) return styleMap[10];
        if (amount < 1000) return styleMap[100];
        if (amount < 10000) return styleMap[1000];
        return styleMap[10000];
    }

    function getTimeFromTimestamp(timestamp) {
        const date = new Date(timestamp);
        const month = (date.getMonth() + 1).toString().padStart(2, '0');
        const day = date.getDate().toString().padStart(2, '0');
        const hours = date.getHours().toString().padStart(2, '0');
        const minutes = date.getMinutes().toString().padStart(2, '0');
        return `${hours}:${minutes}`;
    }

    let allMessage = {};

    function updateAndShowMessage(data) {
        data?.forEach(newMsg => {
            allMessage[newMsg.signature] = newMsg;
        });
        msgArea.innerHTML = '';
        const entries = Object.entries(allMessage);
        entries.sort((a, b) => a[1].blockTime - b[1].blockTime);
        entries.forEach(entry => {
            const msg = entry[1];
            const chat = document.createElement('div');
            chat.style = `margin:2px 0;${getColorByAmount(msg.amount)}word-break: break-word;line-height: 1.4;`;
            chat.innerHTML = `[ ${getTimeFromTimestamp(msg.blockTime * 1000)} ][ <a href="/member/${msg.memo.u}" target="_blank" style="color: inherit;">${msg.memo.u}</a> ] ${msg.memo.m}`;
            msgArea.appendChild(chat);
        });
    }

    async function loadNewMessage() {
        const chatMessages = await fetchMemoTokenTransfersRaw(BURN_ATA_ADDRESS, MAX_LOAD_COUNT);
        updateAndShowMessage(chatMessages);
    }

    let isLoadingMore = false;
    msgArea.addEventListener('scroll', async () => {
        if (msgArea.scrollTop === 0 && !isLoadingMore) {
            isLoadingMore = true;
            const entries = Object.entries(allMessage);
            entries.sort((a, b) => a[1].blockTime - b[1].blockTime);
            const chatMessages = await fetchMemoTokenTransfersRaw(BURN_ATA_ADDRESS, MAX_LOAD_COUNT, entries[0][0]);
            updateAndShowMessage(chatMessages);
            isLoadingMore = false;
        }
    });

    chatBody.style.display = 'none';
    minimizeBtn.textContent = '+';

    const initMsgDiv = document.createElement('div');
    initMsgDiv.style = `margin:2px 0;color:grey;word-break: break-word;line-height: 1.4;`;
    initMsgDiv.textContent = `[System] Loading message...`;
    msgArea.appendChild(initMsgDiv);

    const firstMessage = await fetchMemoTokenTransfersRaw(BURN_ATA_ADDRESS, MAX_LOAD_COUNT);
    updateAndShowMessage(firstMessage);
    msgArea.scrollTop = msgArea.scrollHeight;

    setInterval(() => {
        loadNewMessage();
    }, 15 * 1000);
})();