// ==UserScript== // @name Discourse Topic Quick Switcher // @name:zh-CN Discourse 话题快捷切换器 // @namespace https://github.com/utags // @homepageURL https://github.com/utags/userscripts#readme // @supportURL https://github.com/utags/userscripts/issues // @version 0.1.1 // @description Enhance Discourse forums with instant topic switching, current topic highlighting, and smart theme detection // @description:zh-CN 增强 Discourse 论坛体验,提供即时话题切换、当前话题高亮和智能主题检测功能 // @author Pipecraft // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=meta.discourse.org // @match https://meta.discourse.org/* // @match https://linux.do/* // @match https://idcflare.com/* // @match https://meta.appinn.net/* // @match https://community.openai.com/* // @match https://community.cloudflare.com/* // @match https://community.wanikani.com/* // @match https://forum.cursor.com/* // @match https://forum.obsidian.md/* // @match https://forum-zh.obsidian.md/* // @noframes // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // ==/UserScript== ;(function () { 'use strict' // Configuration const CONFIG = { // Hotkey (default is backtick key `) HOTKEY: '`', // Cache key name CACHE_KEY: 'discourse_topic_list_cache', // Cache expiry time (milliseconds) - 1 hour CACHE_EXPIRY: 60 * 60 * 1000, // Whether to show floating button on topic pages SHOW_FLOATING_BUTTON: true, // Route check interval (milliseconds) ROUTE_CHECK_INTERVAL: 500, // Whether to automatically follow system dark mode AUTO_DARK_MODE: true, // Default language (en or zh-CN) DEFAULT_LANGUAGE: 'en', } // Internationalization support const I18N = { en: { viewTopicList: 'View topic list (press ` key)', topicList: 'Topic List', cacheExpired: 'Cache expired', cachedAgo: 'Cached {time} ago', searchPlaceholder: 'Search topics...', noResults: 'No matching topics found', backToList: 'Back to list', topicsCount: '{count} topics', currentTopic: 'Current topic', sourceFrom: 'Source: {source}', close: 'Close', loading: 'Loading...', refresh: 'Refresh', replies: 'Replies', views: 'Views', activity: 'Activity', language: 'Language', noCachedList: 'No cached topic list available. Please visit a topic list page first.', }, 'zh-CN': { viewTopicList: '查看话题列表(按 ` 键)', topicList: '话题列表', cacheExpired: '缓存已过期', cachedAgo: '{time}前缓存', searchPlaceholder: '搜索话题...', noResults: '未找到匹配的话题', backToList: '返回列表', topicsCount: '{count}个话题', currentTopic: '当前话题', sourceFrom: '来源:{source}', close: '关闭', loading: '加载中...', refresh: '刷新', replies: '回复', views: '浏览', activity: '活动', language: '语言', noCachedList: '没有可用的话题列表缓存。请先访问一个话题列表页面。', }, } // Get user language function getUserLanguage() { // Try to get saved language preference first const savedLanguage = GM_getValue('discourse_topic_switcher_language') if ( savedLanguage && (savedLanguage === 'en' || savedLanguage === 'zh-CN') ) { return savedLanguage } // Try to get language from browser const browserLang = navigator.language || navigator.userLanguage // Check if we support this language if (browserLang.startsWith('zh')) { return 'zh-CN' } // Default to English return CONFIG.DEFAULT_LANGUAGE } // Current language let currentLanguage = getUserLanguage() // Translate function function t(key, params = {}) { // Get the translation let text = I18N[currentLanguage][key] || I18N['en'][key] || key // Replace parameters for (const param in params) { text = text.replace(`{${param}}`, params[param]) } return text } // Status variables let isListVisible = false let cachedTopicList = null let cachedTopicListTimestamp = 0 let cachedTopicListUrl = '' let cachedTopicListTitle = '' let floatingButton = null let topicListContainer = null let lastUrl = window.location.href let urlCheckTimer = null let isDarkMode = false /** * Detect dark mode */ function detectDarkMode() { // Check system dark mode if (CONFIG.AUTO_DARK_MODE && window.matchMedia) { // Check system preference const systemDarkMode = window.matchMedia( '(prefers-color-scheme: dark)' ).matches // Check if the Discourse site is in dark mode const discourseBodyClass = document.body.classList.contains('dark-scheme') || document.documentElement.classList.contains('dark-scheme') || document.body.dataset.colorScheme === 'dark' || document.documentElement.dataset.colorScheme === 'dark' || document.documentElement.dataset.themeType === 'dark' || // linux.do document.querySelector('header picture > source')?.media === 'all' // Enable dark mode if the system or site uses it isDarkMode = systemDarkMode || discourseBodyClass console.log( `[DTQS] Dark mode detection - System: ${systemDarkMode}, Site: ${discourseBodyClass}, Final: ${isDarkMode}` ) // Add or remove dark mode class if (isDarkMode) { document.body.classList.add('topic-list-viewer-dark-mode') } else { document.body.classList.remove('topic-list-viewer-dark-mode') } } } /** * Set up dark mode listener */ function setupDarkModeListener() { if (CONFIG.AUTO_DARK_MODE && window.matchMedia) { // Listen for system dark mode changes const darkModeMediaQuery = window.matchMedia( '(prefers-color-scheme: dark)' ) // Add change listener if (darkModeMediaQuery.addEventListener) { darkModeMediaQuery.addEventListener('change', (e) => { detectDarkMode() }) } else if (darkModeMediaQuery.addListener) { // Fallback for older browsers darkModeMediaQuery.addListener((e) => { detectDarkMode() }) } // Listen for Discourse theme changes const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if ( mutation.attributeName === 'class' || mutation.attributeName === 'data-color-scheme' ) { detectDarkMode() } }) }) // Observe class changes on body and html elements observer.observe(document.body, { attributes: true }) observer.observe(document.documentElement, { attributes: true }) } } /** * Initialize the script */ function init() { // Load cached topic list from storage loadCachedTopicList() // Detect dark mode detectDarkMode() // Set up dark mode listener // setupDarkModeListener() // Initial handling of the current page handleCurrentPage() // Set up URL change detection setupUrlChangeDetection() // Add global hotkey listener addHotkeyListener() } /** * Set up URL change detection * Use multiple methods to reliably detect URL changes */ function setupUrlChangeDetection() { // Record initial URL lastUrl = window.location.href // Method 1: Listen for popstate events (handles browser back/forward buttons) window.addEventListener('popstate', () => { console.log('[DTQS] Detected popstate event') handleCurrentPage() }) // Method 2: Use MutationObserver to listen for DOM changes that might indicate a URL change const pageObserver = new MutationObserver(() => { checkUrlChange('MutationObserver') }) // Start observing DOM changes pageObserver.observe(document.body, { childList: true, subtree: true, }) // Method 3: Set up a regular check as a fallback if (urlCheckTimer) { clearInterval(urlCheckTimer) } urlCheckTimer = setInterval(() => { checkUrlChange('Interval check') }, CONFIG.ROUTE_CHECK_INTERVAL) } /** * Check if the URL has changed * @param {string} source The source that triggered the check */ function checkUrlChange(source) { const currentUrl = window.location.href if (currentUrl !== lastUrl) { console.log(`[DTQS] URL change detected (Source: ${source})`, currentUrl) lastUrl = currentUrl handleCurrentPage() } } /** * Handle the current page */ function handleCurrentPage() { // If the list is visible, hide it if (isListVisible) { hideTopicList() } // Perform different actions based on the current page type if (isTopicPage()) { // On a topic page, add the floating button console.log('[DTQS] On a topic page, show button') if (CONFIG.SHOW_FLOATING_BUTTON) { addFloatingButton() } // On a topic page, pre-render the list (if cached) if (cachedTopicList && !topicListContainer) { // Use setTimeout to ensure the DOM is fully loaded setTimeout(() => { prerenderTopicList() }, 100) } } else if (isTopicListPage()) { // On a topic list page, cache the current list console.log('[DTQS] On a list page, update cache') cacheCurrentTopicList() // Hide the button on the list page hideFloatingButton() } else { // On other pages, hide the button hideFloatingButton() // Observe the topic list element observeTopicListElement() } } /** * Check if the current page is a topic list page * @returns {boolean} Whether it is a topic list page */ function isTopicListPage() { return ( document.querySelector( '.contents table.topic-list tbody.topic-list-body' ) !== null ) } /** * Observe the appearance of the topic list element * Solves the problem that the list element may not be rendered when the page loads */ function observeTopicListElement() { // Create an observer instance const observer = new MutationObserver((mutations, obs) => { // Check if the list element has appeared if ( document.querySelector( '.contents table.topic-list tbody.topic-list-body' ) ) { console.log('[DTQS] Detected that the list element has been rendered') // If the list element appears, re-handle the current page handleCurrentPage() // The list element has been found, stop observing obs.disconnect() } }) // Configure observer options const config = { childList: true, // Observe changes to the target's child nodes subtree: true, // Observe all descendant nodes } // Start observing the document body observer.observe(document.body, config) // Set a timeout to avoid indefinite observation setTimeout(() => { observer.disconnect() }, 10000) // Stop observing after 10 seconds } /** * Check if the current page is a topic page * @returns {boolean} Whether it is a topic page */ function isTopicPage() { return window.location.pathname.includes('/t/') } /** * Get the current topic ID * @returns {number|null} The current topic ID or null */ function getCurrentTopicId() { // Extract topic ID from the URL const match = window.location.pathname.match(/\/t\/[^\/]+\/(\d+)/) return match ? parseInt(match[1]) : null } /** * Cache the current topic list */ function cacheCurrentTopicList() { // Check if the list element exists const topicListBody = document.querySelector('tbody.topic-list-body') if (topicListBody) { // If the list element exists, process it directly updateTopicListCache(topicListBody) // Listen for list content changes (when scrolling to load more) observeTopicListChanges(topicListBody) } else { // If the list element does not exist, listen for its appearance console.log('[DTQS] Waiting for the topic list element to appear') observeTopicListAppearance() } } /** * Observe the appearance of the topic list element */ function observeTopicListAppearance() { // Create an observer instance const observer = new MutationObserver((mutations, obs) => { // Check if the list element has appeared const topicListBody = document.querySelector('tbody.topic-list-body') if (topicListBody) { console.log('[DTQS] Detected that the list element has been rendered') // Process the list content processTopicList(topicListBody) // Listen for list content changes observeTopicListChanges(topicListBody) // The list element has been found, stop observing obs.disconnect() } }) // Configure observer options const config = { childList: true, // Observe changes to the target's child nodes subtree: true, // Observe all descendant nodes } // Start observing the document body observer.observe(document.body, config) } /** * Observe topic list content changes (when scrolling to load more) * @param {Element} topicListBody The topic list element */ function observeTopicListChanges(topicListBody) { // Record the current number of rows let previousRowCount = topicListBody.querySelectorAll('tr').length // Create an observer instance const observer = new MutationObserver((mutations) => { // Get the current number of rows const currentRowCount = topicListBody.querySelectorAll('tr').length // If the number of rows increases, it means more topics have been loaded if (currentRowCount > previousRowCount) { console.log( `[DTQS] Detected list update, rows increased from ${previousRowCount} to ${currentRowCount}` ) // Update the cache updateTopicListCache(topicListBody) // Update the row count record previousRowCount = currentRowCount } }) // Configure observer options const config = { childList: true, // Observe changes to the target's child nodes subtree: true, // Observe all descendant nodes } // Start observing the list element observer.observe(topicListBody, config) } /** * Update the topic list cache * @param {Element} topicListBody The topic list element */ function updateTopicListCache(topicListBody) { // Ensure the list has content const topicRows = topicListBody.querySelectorAll('tr') if (topicRows.length === 0) { console.log('[DTQS] Topic list is empty, not caching') return } console.log('[DTQS] Updating topic list cache') // Clone the node to save the complete topic list const clonedTopicList = topicListBody.cloneNode(true) // Save the current URL to show the source when the list is popped up const currentUrl = window.location.href // Get the list title let listTitle = t('topicList') // const titleElement = document.querySelector( // '.category-name, .page-title h1, .topic-list-heading h2' // ) // if (titleElement) { // listTitle = titleElement.textContent.trim() // } const title = document.title.replace(/ - .*/, '').trim() if (title) { listTitle = title } // Get current category information (if any) let categoryInfo = '' const categoryBadge = document.querySelector( '.category-name .badge-category' ) if (categoryBadge) { categoryInfo = categoryBadge.textContent.trim() } console.log( `[DTQS] Caching topic list "${listTitle}", containing ${topicRows.length} topics` ) // Save to cache cachedTopicList = clonedTopicList.outerHTML cachedTopicListTimestamp = Date.now() cachedTopicListUrl = currentUrl cachedTopicListTitle = listTitle // Save to GM storage GM_setValue(CONFIG.CACHE_KEY, { html: cachedTopicList, timestamp: cachedTopicListTimestamp, url: cachedTopicListUrl, title: cachedTopicListTitle, category: categoryInfo, topicCount: topicRows.length, }) // Remove the list container, it needs to be re-rendered if (topicListContainer) { topicListContainer.remove() topicListContainer = null } } /** * Load the cached topic list from storage */ function loadCachedTopicList() { const cache = GM_getValue(CONFIG.CACHE_KEY) if (cache) { cachedTopicList = cache.html cachedTopicListTimestamp = cache.timestamp cachedTopicListUrl = cache.url cachedTopicListTitle = cache.title } } /** * Add a floating button */ function addFloatingButton() { // If the button already exists, do not add it again if (document.getElementById('topic-list-viewer-button')) return // Create the floating button floatingButton = document.createElement('button') floatingButton.id = 'topic-list-viewer-button' floatingButton.innerHTML = '' floatingButton.title = t('viewTopicList') // Add click event floatingButton.addEventListener('click', toggleTopicList) // Add to page document.body.appendChild(floatingButton) } /** * Add a hotkey listener */ function addHotkeyListener() { document.addEventListener('keydown', function (event) { // Check if the configured hotkey is pressed if (event.key === CONFIG.HOTKEY) { // Prevent default behavior and event bubbling event.preventDefault() event.stopPropagation() // Toggle topic list display toggleTopicList() } // If the list is visible, close it with the ESC key if (isListVisible && event.key === 'Escape') { hideTopicList() } }) } /** * Hide the floating button */ function hideFloatingButton() { if (floatingButton && floatingButton.parentNode) { floatingButton.parentNode.removeChild(floatingButton) floatingButton = null } } /** * Toggle the display state of the topic list */ function toggleTopicList() { if (isListVisible) { hideTopicList() } else { showTopicList() } } /** * Navigate to the specified URL using SPA routing * @param {string} url The target URL */ function navigateWithSPA(url) { // Hide the topic list hideTopicList() // Try to use pushState for SPA navigation try { console.log(`[DTQS] Navigating to ${url} using SPA routing`) // Use history API for navigation const urlObj = new URL(url) const pathname = urlObj.pathname // Update history history.pushState({}, '', pathname) // Trigger popstate event so Discourse can handle the route change window.dispatchEvent(new Event('popstate')) // Handle the current page setTimeout(handleCurrentPage, 100) } catch (error) { // If SPA navigation fails, fall back to normal navigation console.log( `[DTQS] SPA navigation failed, falling back to normal navigation to ${url}`, error ) window.location.href = url } } /** * Pre-render the topic list */ function prerenderTopicList() { // Record start time const startTime = performance.now() // If there is no cached topic list, do not pre-render if (!cachedTopicList) { console.log('[DTQS] No cached topic list available, cannot pre-render') return } // If the container already exists, do not create it again if (topicListContainer) { return } console.log('[DTQS] Pre-rendering topic list') // Check if the cache is expired const now = Date.now() const cacheAge = now - cachedTopicListTimestamp let cacheStatus = '' if (cacheAge > CONFIG.CACHE_EXPIRY) { cacheStatus = `
${t('topicList')} | ${t('replies')} | ${t('views')} | ${t('activity')} |
---|