Twitter Scroll Refresh

Refresh Twitter timeline by scrolling up at the top of the page

// ==UserScript==
// @name         Twitter Scroll Refresh
// @name:zh-CN   Twitter 滚轮刷新
// @namespace    https://github.com/Xeron2000/twitter-scroll-refresh
// @version      1.2.1
// @description  Refresh Twitter timeline by scrolling up at the top of the page
// @description:zh-CN  在Twitter顶部向上滚动时自动刷新获取新帖子
// @author       Xeron
// @match        https://x.com/home
// @icon         https://abs.twimg.com/favicons/twitter.2.ico
// @homepageURL  https://github.com/Xeron2000/twitter-scroll-refresh
// @supportURL   https://github.com/Xeron2000/twitter-scroll-refresh/issues
// @license      MIT
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

/**
 * Twitter Scroll Refresh
 *
 * A userscript that allows refreshing Twitter/X timeline by scrolling up at the top.
 * 一个通过在顶部向上滚动来刷新Twitter/X时间线的用户脚本。
 *
 * @version 1.2.1
 * @author Xeron
 * @license MIT
 * @repository https://github.com/Xeron2000/twitter-scroll-refresh
 */

(function() {
    'use strict';

    // ====== Configuration / 配置 ======
    const CONFIG = {
        SCROLL_THRESHOLD: GM_getValue('scrollThreshold', 30), // 滚动阈值
        REFRESH_COOLDOWN: GM_getValue('refreshCooldown', 1500), // 刷新冷却时间(毫秒)
        TOP_OFFSET: GM_getValue('topOffset', 10), // 顶部偏移量
        SHOW_NOTIFICATIONS: GM_getValue('showNotifications', true), // 显示通知
        DEBUG_MODE: GM_getValue('debugMode', false), // 调试模式
        LANGUAGE: GM_getValue('language', 'auto') // 语言设置
    };

    // ====== Internationalization / 国际化 ======
    const MESSAGES = {
        en: {
            refreshTriggered: 'Refreshing timeline...',
            scrollToRefresh: 'Scroll up at top to refresh',
            settingsTitle: 'Twitter Scroll Refresh Settings',
            scrollThreshold: 'Scroll Threshold',
            refreshCooldown: 'Refresh Cooldown (ms)',
            topOffset: 'Top Offset (px)',
            showNotifications: 'Show Notifications',
            debugMode: 'Debug Mode',
            language: 'Language',
            languageAuto: 'Auto (Follow Browser)',
            languageEn: 'English',
            languageZhCn: '中文简体',
            save: 'Save',
            cancel: 'Cancel',
            saved: 'Settings saved!'
        },
        'zh-CN': {
            refreshTriggered: '正在刷新时间线...',
            scrollToRefresh: '在顶部向上滚动可刷新',
            settingsTitle: 'Twitter滚轮刷新设置',
            scrollThreshold: '滚动阈值',
            refreshCooldown: '刷新冷却时间 (毫秒)',
            topOffset: '顶部偏移量 (像素)',
            showNotifications: '显示通知',
            debugMode: '调试模式',
            language: '语言',
            languageAuto: '自动 (跟随浏览器)',
            languageEn: 'English',
            languageZhCn: '中文简体',
            save: '保存',
            cancel: '取消',
            saved: '设置已保存!'
        }
    };

    // ====== Utility Functions / 工具函数 ======

    /**
     * Get current language
     * 获取当前语言
     */
    function getCurrentLanguage() {
        if (CONFIG.LANGUAGE !== 'auto') return CONFIG.LANGUAGE;
        return navigator.language.startsWith('zh') ? 'zh-CN' : 'en';
    }

    /**
     * Get localized message
     * 获取本地化消息
     */
    function getMessage(key) {
        const lang = getCurrentLanguage();
        return MESSAGES[lang]?.[key] || MESSAGES.en[key] || key;
    }

    /**
     * Debug logger
     * 调试日志
     */
    function debugLog(...args) {
        if (CONFIG.DEBUG_MODE) {
            console.log('[Twitter Scroll Refresh]', ...args);
        }
    }

    /**
     * Show notification
     * 显示通知
     */
    function showNotification(message, duration = 2000) {
        if (!CONFIG.SHOW_NOTIFICATIONS) return;

        // Remove existing notification
        const existing = document.getElementById('twitter-scroll-refresh-notification');
        if (existing) existing.remove();

        const notification = document.createElement('div');
        notification.id = 'twitter-scroll-refresh-notification';
        notification.style.cssText = `
            position: fixed;
            top: 20px;
            right: 20px;
            background: rgba(29, 161, 242, 0.95);
            color: white;
            padding: 12px 16px;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 500;
            z-index: 10001;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
            backdrop-filter: blur(10px);
            opacity: 0;
            transform: translateX(100%);
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            pointer-events: none;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        `;
        notification.textContent = message;
        document.body.appendChild(notification);

        // Animate in
        requestAnimationFrame(() => {
            notification.style.opacity = '1';
            notification.style.transform = 'translateX(0)';
        });

        // Auto remove
        setTimeout(() => {
            if (notification.parentNode) {
                notification.style.opacity = '0';
                notification.style.transform = 'translateX(100%)';
                setTimeout(() => {
                    if (notification.parentNode) {
                        notification.remove();
                    }
                }, 300);
            }
        }, duration);
    }

    // ====== Main Logic / 主要逻辑 ======

    let isAtTop = false;
    let lastScrollTime = 0;
    let refreshing = false;
    let wheelStartTime = 0;

    /**
     * Check if page is at top
     * 检查是否在页面顶部
     */
    function checkIfAtTop() {
        return window.scrollY <= CONFIG.TOP_OFFSET;
    }

    /**
     * Find and execute refresh action
     * 查找并执行刷新操作
     */
    async function performRefresh() {
        if (refreshing) {
            debugLog('Refresh already in progress, skipping');
            return;
        }

        refreshing = true;
        debugLog('Refresh triggered');

        if (CONFIG.SHOW_NOTIFICATIONS) {
            showNotification(getMessage('refreshTriggered'));
        }

        try {
            // Method 1: Click Home tab if on home page
            // 方法1: 如果在主页则点击主页标签
            if (window.location.pathname === '/home') {
                const homeButton = document.querySelector('[data-testid="AppTabBar_Home_Link"]');
                if (homeButton) {
                    debugLog('Clicking home button');
                    homeButton.click();
                    return;
                }
            }

            // Method 2: Look for refresh/reload buttons
            // 方法2: 查找刷新/重载按钮
            const refreshSelectors = [
                '[aria-label*="refresh" i]',
                '[aria-label*="reload" i]',
                '[aria-label*="刷新"]',
                '[aria-label*="重新加载"]',
                '[data-testid*="refresh"]',
                'button[title*="refresh" i]',
                'button[title*="刷新"]'
            ];

            for (const selector of refreshSelectors) {
                const button = document.querySelector(selector);
                if (button && button.offsetParent !== null) { // Check if visible
                    debugLog('Clicking refresh button:', selector);
                    button.click();
                    return;
                }
            }

            // Method 3: Simulate '.' key press for timeline refresh
            // 方法3: 模拟按下'.'键刷新时间线
            debugLog('Using keyboard shortcut');
            const keyEvent = new KeyboardEvent('keydown', {
                key: '.',
                code: 'Period',
                keyCode: 190,
                which: 190,
                bubbles: true,
                cancelable: true
            });
            document.dispatchEvent(keyEvent);

            // Method 4: Look for "Show new posts" type buttons
            // 方法4: 查找"显示新帖子"类型的按钮
            setTimeout(() => {
                const newPostsSelectors = [
                    '[role="button"]:has-text("Show")',
                    '[role="button"]:has-text("显示")',
                    'button:contains("new")',
                    'button:contains("新")'
                ];

                // Use a more robust text search
                const buttons = document.querySelectorAll('button, [role="button"]');
                for (const button of buttons) {
                    const text = button.textContent?.toLowerCase() || '';
                    if ((text.includes('show') && text.includes('new')) ||
                        (text.includes('显示') && text.includes('新'))) {
                        debugLog('Clicking new posts button');
                        button.click();
                        return;
                    }
                }
            }, 200);

        } catch (error) {
            debugLog('Error during refresh:', error);
        } finally {
            setTimeout(() => {
                refreshing = false;
                debugLog('Refresh cooldown completed');
            }, CONFIG.REFRESH_COOLDOWN);
        }
    }

    /**
     * Handle wheel event
     * 处理滚轮事件
     */
    function handleWheel(event) {
        const currentTime = Date.now();

        // Check if at top
        if (checkIfAtTop()) {
            if (!isAtTop) {
                isAtTop = true;
                wheelStartTime = currentTime;
                debugLog('Reached top of page');
            }

            // Check for upward scroll with sufficient velocity
            if (event.deltaY < -CONFIG.SCROLL_THRESHOLD) {
                // Prevent default scrolling when at top and scrolling up
                event.preventDefault();

                // Throttle refresh attempts
                if (currentTime - lastScrollTime > CONFIG.REFRESH_COOLDOWN) {
                    debugLog('Upward scroll detected, triggering refresh');
                    performRefresh();
                    lastScrollTime = currentTime;
                }
            }
        } else {
            if (isAtTop) {
                isAtTop = false;
                debugLog('Left top of page');
            }
        }
    }

    /**
     * Handle scroll event
     * 处理滚动事件
     */
    function handleScroll() {
        const wasAtTop = isAtTop;
        isAtTop = checkIfAtTop();

        if (isAtTop !== wasAtTop) {
            debugLog('Top status changed:', isAtTop);
        }
    }

    // ====== Settings UI / 设置界面 ======

    /**
     * Create settings dialog
     * 创建设置对话框
     */
    function createSettingsDialog() {
        // Remove existing dialog
        const existing = document.getElementById('twitter-scroll-refresh-settings');
        if (existing) existing.remove();

        const dialog = document.createElement('div');
        dialog.id = 'twitter-scroll-refresh-settings';
        dialog.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0, 0, 0, 0.7);
            z-index: 10002;
            display: flex;
            align-items: center;
            justify-content: center;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        `;

        const content = document.createElement('div');
        content.style.cssText = `
            background: white;
            border-radius: 12px;
            padding: 24px;
            width: 420px;
            max-width: 90vw;
            box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
        `;

        const inputStyle = `
            width: 100%;
            padding: 10px 12px;
            border: 1px solid #e1e8ed;
            border-radius: 6px;
            font-size: 14px;
            color: #14171a;
            background: white;
            box-sizing: border-box;
            transition: border-color 0.2s ease;
        `;

        const inputFocusStyle = `
            outline: none;
            border-color: #1da1f2;
            box-shadow: 0 0 0 2px rgba(29, 161, 242, 0.1);
        `;

        content.innerHTML = `
            <h2 style="margin: 0 0 24px 0; color: #14171a; font-size: 20px; font-weight: 700; text-align: center;">
                ${getMessage('settingsTitle')}
            </h2>
            <form id="settings-form">
                <div style="margin-bottom: 18px;">
                    <label style="display: block; margin-bottom: 8px; color: #657786; font-weight: 500; font-size: 13px;">
                        ${getMessage('language')}
                    </label>
                    <select id="language" style="${inputStyle} cursor: pointer;">
                        <option value="auto" ${CONFIG.LANGUAGE === 'auto' ? 'selected' : ''}>${getMessage('languageAuto')}</option>
                        <option value="en" ${CONFIG.LANGUAGE === 'en' ? 'selected' : ''}>${getMessage('languageEn')}</option>
                        <option value="zh-CN" ${CONFIG.LANGUAGE === 'zh-CN' ? 'selected' : ''}>${getMessage('languageZhCn')}</option>
                    </select>
                </div>
                <div style="margin-bottom: 18px;">
                    <label style="display: block; margin-bottom: 8px; color: #657786; font-weight: 500; font-size: 13px;">
                        ${getMessage('scrollThreshold')}
                    </label>
                    <input type="number" id="scrollThreshold" value="${CONFIG.SCROLL_THRESHOLD}"
                           style="${inputStyle}" min="10" max="100" step="5">
                </div>
                <div style="margin-bottom: 18px;">
                    <label style="display: block; margin-bottom: 8px; color: #657786; font-weight: 500; font-size: 13px;">
                        ${getMessage('refreshCooldown')}
                    </label>
                    <input type="number" id="refreshCooldown" value="${CONFIG.REFRESH_COOLDOWN}"
                           style="${inputStyle}" min="500" max="5000" step="100">
                </div>
                <div style="margin-bottom: 18px;">
                    <label style="display: block; margin-bottom: 8px; color: #657786; font-weight: 500; font-size: 13px;">
                        ${getMessage('topOffset')}
                    </label>
                    <input type="number" id="topOffset" value="${CONFIG.TOP_OFFSET}"
                           style="${inputStyle}" min="0" max="50" step="5">
                </div>
                <div style="margin-bottom: 18px; padding: 12px; background: #f7f9fa; border-radius: 8px;">
                    <label style="display: flex; align-items: center; color: #14171a; font-weight: 500; cursor: pointer;">
                        <input type="checkbox" id="showNotifications" ${CONFIG.SHOW_NOTIFICATIONS ? 'checked' : ''}
                               style="margin-right: 10px; transform: scale(1.1);">
                        ${getMessage('showNotifications')}
                    </label>
                </div>
                <div style="margin-bottom: 24px; padding: 12px; background: #f7f9fa; border-radius: 8px;">
                    <label style="display: flex; align-items: center; color: #14171a; font-weight: 500; cursor: pointer;">
                        <input type="checkbox" id="debugMode" ${CONFIG.DEBUG_MODE ? 'checked' : ''}
                               style="margin-right: 10px; transform: scale(1.1);">
                        ${getMessage('debugMode')}
                    </label>
                </div>
                <div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px;">
                    <button type="button" id="cancel-btn" style="
                        padding: 10px 20px;
                        border: 1px solid #e1e8ed;
                        background: white;
                        color: #657786;
                        border-radius: 6px;
                        cursor: pointer;
                        font-weight: 500;
                        font-size: 14px;
                        transition: all 0.2s ease;
                    ">${getMessage('cancel')}</button>
                    <button type="submit" id="save-btn" style="
                        padding: 10px 20px;
                        border: none;
                        background: #1da1f2;
                        color: white;
                        border-radius: 6px;
                        cursor: pointer;
                        font-weight: 500;
                        font-size: 14px;
                        transition: all 0.2s ease;
                    ">${getMessage('save')}</button>
                </div>
            </form>
            <style>
                #twitter-scroll-refresh-settings input:focus,
                #twitter-scroll-refresh-settings select:focus {
                    ${inputFocusStyle}
                }
                #twitter-scroll-refresh-settings #cancel-btn:hover {
                    background: #f7f9fa;
                    border-color: #1da1f2;
                    color: #1da1f2;
                }
                #twitter-scroll-refresh-settings #save-btn:hover {
                    background: #1991da;
                }
                #twitter-scroll-refresh-settings select option {
                    color: #14171a;
                    background: white;
                    padding: 8px;
                }
            </style>
        `;

        dialog.appendChild(content);
        document.body.appendChild(dialog);

        // Event handlers
        document.getElementById('cancel-btn').onclick = () => dialog.remove();
        document.getElementById('settings-form').onsubmit = (e) => {
            e.preventDefault();

            // Check if language changed
            const newLanguage = document.getElementById('language').value;
            const languageChanged = CONFIG.LANGUAGE !== newLanguage;

            // Save settings
            CONFIG.LANGUAGE = newLanguage;
            CONFIG.SCROLL_THRESHOLD = parseInt(document.getElementById('scrollThreshold').value);
            CONFIG.REFRESH_COOLDOWN = parseInt(document.getElementById('refreshCooldown').value);
            CONFIG.TOP_OFFSET = parseInt(document.getElementById('topOffset').value);
            CONFIG.SHOW_NOTIFICATIONS = document.getElementById('showNotifications').checked;
            CONFIG.DEBUG_MODE = document.getElementById('debugMode').checked;

            // Save to GM storage
            GM_setValue('language', CONFIG.LANGUAGE);
            GM_setValue('scrollThreshold', CONFIG.SCROLL_THRESHOLD);
            GM_setValue('refreshCooldown', CONFIG.REFRESH_COOLDOWN);
            GM_setValue('topOffset', CONFIG.TOP_OFFSET);
            GM_setValue('showNotifications', CONFIG.SHOW_NOTIFICATIONS);
            GM_setValue('debugMode', CONFIG.DEBUG_MODE);

            showNotification(getMessage('saved'));
            dialog.remove();

            // If language changed, suggest page refresh for full effect
            if (languageChanged) {
                setTimeout(() => {
                    if (confirm(getCurrentLanguage() === 'zh-CN' ?
                        '语言设置已更改,建议刷新页面以完全应用新语言设置。是否现在刷新?' :
                        'Language setting has been changed. It is recommended to refresh the page to fully apply the new language setting. Refresh now?')) {
                        location.reload();
                    }
                }, 1000);
            }
        };

        // Close on backdrop click
        dialog.onclick = (e) => {
            if (e.target === dialog) dialog.remove();
        };
    }

    // ====== Initialization / 初始化 ======

    /**
     * Initialize the script
     * 初始化脚本
     */
    function initialize() {
        debugLog('Initializing Twitter Scroll Refresh v1.2.1');

        // Add event listeners
        document.addEventListener('wheel', handleWheel, { passive: false });
        document.addEventListener('scroll', handleScroll, { passive: true });

        // Register menu command for settings
        GM_registerMenuCommand('⚙️ Settings / 设置', createSettingsDialog);

        // Show initialization notification
        setTimeout(() => {
            if (CONFIG.SHOW_NOTIFICATIONS) {
                showNotification(getMessage('scrollToRefresh'), 3000);
            }
        }, 1000);

        debugLog('Script initialized successfully');
    }

    // Wait for page to be ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initialize);
    } else {
        initialize();
    }

    // Export for debugging (if needed)
    if (CONFIG.DEBUG_MODE) {
        window.TwitterScrollRefresh = {
            config: CONFIG,
            performRefresh,
            showSettings: createSettingsDialog
        };
    }

})();