X/Twitter Copy Tweet Link Helper

Copy tweet links via right-click, like button, or dedicated button. Supports fixupx mode and tweet redirect toggle. Features can be enabled or disabled directly in the Tampermonkey interface, with a switchable Chinese/English menu display.

بۇ قوليازمىنى قاچىلاش؟
ئاپتورنىڭ تەۋسىيەلىگەن قوليازمىسى

سىز بەلكىم X/Twitter Optimized Tweet Buttons نى ياقتۇرۇشىڭىز مۇمكىن.

بۇ قوليازمىنى قاچىلاش
// ==UserScript==
// @name         X/Twitter Copy Tweet Link Helper
// @name:zh-TW   X/Twitter 複製推文連結助手
// @name:zh-CN   X/Twitter 复制推文连结助手
// @namespace    http://tampermonkey.net/
// @version      2.7
// @description  Copy tweet links via right-click, like button, or dedicated button. Supports fixupx mode and tweet redirect toggle. Features can be enabled or disabled directly in the Tampermonkey interface, with a switchable Chinese/English menu display.
// @description:zh-TW 透過右鍵、喜歡或按鈕複製推文鏈接,並支援fixupx模式和推文跳轉開關,可在油猴介面中直接開關指定功能,中英菜單顯示切換。
// @description:zh-CN 通过右键、喜欢或按钮复制推文链接,並支持fixupx模式和推文跳转开关,可在油猴界面中直接开关指定功能,中英菜单显示切换。
// @author       ChatGPT
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // === 預設設定值 ===
    const defaultSettings = {
        rightClickCopy: true,       // 右鍵複製推文連結
        likeCopy: true,             // 按讚時自動複製連結
        showCopyButton: true,       // 顯示🔗複製按鈕
        useFixupx: false,           // 使用 fixupx.com 格式連結
        disableClickRedirect: false,// 禁止點擊推文跳轉
        language: 'EN'              // 語言設定:EN 或 ZH
    };

    // === 設定操作介面 ===
    const settings = {
        get(key) {
            return GM_getValue(key, defaultSettings[key]);
        },
        set(key, value) {
            GM_setValue(key, value);
        }
    };

    // === 語系 ===
    const lang = {
        EN: {
            copySuccess: "Link copied!",
            copyButton: "🔗",
            rightClickCopy: 'Right-click Copy',
            likeCopy: 'Like Copy',
            showCopyButton: 'Show Copy Button',
            useFixupx: 'Use Fixupx',
            disableClickRedirect: 'Disable Tweet Click',
            language: 'Language'
        },
        ZH: {
            copySuccess: "已複製鏈結!",
            copyButton: "🔗",
            rightClickCopy: '右鍵複製',
            likeCopy: '喜歡時複製',
            showCopyButton: '顯示複製按鈕',
            useFixupx: '使用 Fixupx',
            disableClickRedirect: '禁止點擊跳轉',
            language: '語言'
        }
    };

    const getText = (key) => lang[settings.get('language')][key];

    // === 清理推文網址,移除 photo 路徑與 query 參數 ===
    function cleanTweetUrl(rawUrl) {
        try {
            const url = new URL(rawUrl);
            url.search = '';
            url.pathname = url.pathname.replace(/\/photo\/\d+$/, '');
            if (settings.get('useFixupx')) {
                url.hostname = 'fixupx.com';
            }
            return url.toString();
        } catch {
            return rawUrl;
        }
    }

    // === 複製推文連結 ===
    function copyTweetLink(tweet) {
        const anchor = tweet.querySelector('a[href*="/status/"]');
        if (!anchor) return;
        const cleanUrl = cleanTweetUrl(anchor.href);
        navigator.clipboard.writeText(cleanUrl).then(() => {
            showToast(getText('copySuccess'));
        });
    }

    // === 顯示提示訊息(toast) ===
    let toastTimer = null;
    function showToast(msg) {
        let toast = document.getElementById('x-copy-tweet-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'x-copy-tweet-toast';
            Object.assign(toast.style, {
                position: 'fixed',
                bottom: '20px',
                left: '50%',
                transform: 'translateX(-50%)',
                background: '#1da1f2',
                color: '#fff',
                padding: '8px 16px',
                borderRadius: '20px',
                zIndex: 9999,
                fontSize: '14px',
                pointerEvents: 'none'
            });
            document.body.appendChild(toast);
        }
        toast.innerText = msg;
        toast.style.display = 'block';
        if (toastTimer) clearTimeout(toastTimer);
        toastTimer = setTimeout(() => {
            toast.style.display = 'none';
        }, 1500);
    }

    // === 插入🔗按鈕至推文中 ===
    function insertCopyButton(tweet) {
        if (tweet.querySelector('.x-copy-btn')) return;

        const actionGroup = tweet.querySelector('[role="group"]');
        if (!actionGroup) return;

        const actionButtons = Array.from(actionGroup.children);
        const bookmarkContainer = actionButtons[actionButtons.length - 2];
        if (!bookmarkContainer) return;

        const btnContainer = document.createElement('div');
        btnContainer.className = 'x-copy-btn-container';
        Object.assign(btnContainer.style, {
            display: 'flex',
            flexDirection: 'row',
            alignItems: 'center',
            minHeight: '20px',
            maxWidth: '100%',
            marginRight: '8px',
            flex: '1'
        });

        const innerDiv = document.createElement('div');
        Object.assign(innerDiv.style, {
            display: 'flex',
            flexDirection: 'row',
            alignItems: 'center',
            justifyContent: 'center',
            minHeight: '20px'
        });

        const btn = document.createElement('div');
        btn.className = 'x-copy-btn';
        Object.assign(btn.style, {
            cursor: 'pointer',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            minWidth: '20px',
            minHeight: '20px',
            color: 'rgb(83, 100, 113)',
            transition: 'all 0.2s',
            borderRadius: '9999px'
        });

        const btnContent = document.createElement('div');
        Object.assign(btnContent.style, {
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            minWidth: '20px',
            minHeight: '20px'
        });

        const textSpan = document.createElement('span');
        textSpan.innerText = getText('copyButton');
        Object.assign(textSpan.style, {
            fontSize: '16px',
            lineHeight: '1'
        });

        btn.addEventListener('mouseover', () => {
            btn.style.backgroundColor = 'rgba(29, 155, 240, 0.1)';
            btn.style.color = 'rgb(29, 155, 240)';
        });

        btn.addEventListener('mouseout', () => {
            btn.style.backgroundColor = 'transparent';
            btn.style.color = 'rgb(83, 100, 113)';
        });

        btn.onclick = (e) => {
            e.stopPropagation();
            copyTweetLink(tweet);
        };

        btnContent.appendChild(textSpan);
        btn.appendChild(btnContent);
        innerDiv.appendChild(btn);
        btnContainer.appendChild(innerDiv);
        actionGroup.insertBefore(btnContainer, bookmarkContainer);

        const computedStyle = window.getComputedStyle(bookmarkContainer);
        btnContainer.style.flex = computedStyle.flex;
        btnContainer.style.justifyContent = computedStyle.justifyContent;
    }

    // === 綁定 Like 複製事件(避免重複) ===
    function bindLikeCopy(tweet) {
        if (tweet.hasAttribute('data-likecopy')) return;
        tweet.setAttribute('data-likecopy', 'true');
        const likeBtn = tweet.querySelector('[data-testid="like"]');
        if (likeBtn && !likeBtn.hasAttribute('data-likecopy-listener')) {
            likeBtn.setAttribute('data-likecopy-listener', 'true');
            likeBtn.addEventListener('click', () => {
                copyTweetLink(tweet);
            });
        }
    }

    // === 綁定右鍵複製事件(避免重複) ===
    function bindRightClickCopy(tweet) {
        if (tweet.hasAttribute('data-rightclick')) return;
        tweet.setAttribute('data-rightclick', 'true');
        tweet.addEventListener('contextmenu', (e) => {
            if (tweet.querySelector('img, video')) {
                copyTweetLink(tweet);
            }
        });
    }

    // === 禁止整篇推文點擊進入詳細頁(阻止點擊跳轉)===
    function disableTweetClickHandler(tweet) {
        if (tweet.hasAttribute('data-disableclick')) return;
        tweet.setAttribute('data-disableclick', 'true');
        tweet.addEventListener('click', (e) => {
            const target = e.target;
            // 排除功能按鈕、外部連結、輸入框
            if (
                target.closest('[role="button"]') ||
                target.closest('a[href^="http"]') ||
                target.closest('input') ||
                target.closest('textarea') ||
                target.closest('.x-copy-btn') || // 排除複製按鈕
                target.closest('[data-testid="tweetPhoto"]')  // 排除推特圖片
            ) {
                return;
            }
            // 其他情況一律阻止跳轉
            e.stopPropagation();
            e.preventDefault();
        }, true);
    }

    // === 處理新增的推文節點 ===
    function processTweetNode(node) {
        if (!(node instanceof HTMLElement)) return;
        const applyTo = node.tagName === 'ARTICLE' ? [node] : node.querySelectorAll?.('article') || [];
        for (const tweet of applyTo) {
            if (settings.get('showCopyButton')) insertCopyButton(tweet);
            if (settings.get('rightClickCopy')) bindRightClickCopy(tweet);
            if (settings.get('likeCopy')) bindLikeCopy(tweet);
            if (settings.get('disableClickRedirect')) disableTweetClickHandler(tweet);
        }
    }

    // === 初始處理目前所有推文 ===
    document.querySelectorAll('article').forEach(processTweetNode);

    // === 監聽 DOM 變動,只處理新增推文 ===
    const tweetObserver = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            mutation.addedNodes.forEach(processTweetNode);
        }
    });
    tweetObserver.observe(document.body, { childList: true, subtree: true });

    // === MenuCommand 註冊與更新 ===
    let menuIds = [];
    function updateMenuCommands() {
        menuIds.forEach(id => {
            try { GM_unregisterMenuCommand(id); } catch {}
        });
        menuIds = [];
        menuIds.push(GM_registerMenuCommand(`${getText('rightClickCopy')} ( ${settings.get('rightClickCopy') ? '✅' : '❌'} )`, toggleRightClickCopy));
        menuIds.push(GM_registerMenuCommand(`${getText('likeCopy')} ( ${settings.get('likeCopy') ? '✅' : '❌'} )`, toggleLikeCopy));
        menuIds.push(GM_registerMenuCommand(`${getText('showCopyButton')} ( ${settings.get('showCopyButton') ? '✅' : '❌'} )`, toggleShowCopyButton));
        menuIds.push(GM_registerMenuCommand(`${getText('useFixupx')} ( ${settings.get('useFixupx') ? '✅' : '❌'} )`, toggleUseFixupx));
        menuIds.push(GM_registerMenuCommand(`${getText('disableClickRedirect')} ( ${settings.get('disableClickRedirect') ? '✅' : '❌'} )`, toggleDisableClickRedirect));
        const langs = Object.keys(lang);
        const currentLangIdx = langs.indexOf(settings.get('language'));
        const nextLang = langs[(currentLangIdx + 1) % langs.length];
        let langDisplay = settings.get('language') === 'ZH' ? '中文' : 'EN';
        menuIds.push(GM_registerMenuCommand(`${getText('language')} ( ${langDisplay} )`, () => toggleLanguage(nextLang)));
    }

    updateMenuCommands();

    // === 設定切換函式 ===
    function toggleRightClickCopy() {
        settings.set('rightClickCopy', !settings.get('rightClickCopy'));
        reloadPage();
    }

    function toggleLikeCopy() {
        settings.set('likeCopy', !settings.get('likeCopy'));
        reloadPage();
    }

    function toggleShowCopyButton() {
        settings.set('showCopyButton', !settings.get('showCopyButton'));
        reloadPage();
    }

    function toggleUseFixupx() {
        settings.set('useFixupx', !settings.get('useFixupx'));
        reloadPage();
    }

    function toggleDisableClickRedirect() {
        settings.set('disableClickRedirect', !settings.get('disableClickRedirect'));
        reloadPage();
    }

    function toggleLanguage(nextLang) {
        settings.set('language', nextLang);
        reloadPage();
    }

    // === 重新載入頁面(避免即時 DOM 綁定錯亂)===
    function reloadPage() {
        location.reload();
    }
})();