// ==UserScript== // @name Discourse Sidebar Feed Panel // @namespace https://linux.do/ // @version 0.6.70 // @description 将侧边栏改造为信息流面板,支持板块分类筛选、已读/未读过滤、拖拽调整宽度 // @author YsLtr // @match https://linux.do/* // @icon https://www.google.com/s2/favicons?sz=64&domain=linux.do // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @run-at document-idle // @license MIT // ==/UserScript== (function () { "use strict"; if (window.top !== window.self) return; // ========== 持久化键 ========== // 所有 GM_* 键都带 sfp_ 前缀,避免和其他 userscript 或站点本身的 // localStorage/GM 存储冲突。这里的键名一旦发布就尽量不要重命名: // 老版本用户升级时会直接读取这些值来恢复宽度、标签、筛选和刷新偏好。 const STATE_KEY = "sfp_feed_mode_enabled"; const ORDER_KEY = "sfp_current_order"; const PERIOD_KEY = "sfp_current_period"; const WIDTH_KEY = "sfp_sidebar_width"; const TAB_KEY = "sfp_current_tab"; const TAB_ORDER_KEY = "sfp_tab_order"; const FILTER_KEY = "sfp_current_filter"; const HIDE_PINNED_KEY = "sfp_hide_pinned"; const SHOW_INCOMING_HINT_KEY = "sfp_show_incoming_hint"; const AUTO_SILENT_REFRESH_KEY = "sfp_auto_silent_refresh"; const AUTO_SILENT_REFRESH_INTERVAL_KEY = "sfp_auto_silent_refresh_interval"; const AUTO_REFRESH_ENABLED_KEY = "sfp_auto_refresh_enabled"; const AUTO_REFRESH_INTERVAL_KEY = "sfp_auto_refresh_interval"; const TAG_STYLE_CACHE_KEY = "sfp_tag_style_cache_v1"; // ========== 常量 ========== // DEFAULT_WIDTH 同时也是当前最小宽度。之前的需求要求“允许压缩的最小宽度 // 改为 header-sidebar-toggle 的宽度”,但侧边栏内容在 272px 以下会破坏 // 话题卡片和设置浮层,因此这里保留信息流面板自己的最低可用宽度。 const DEFAULT_WIDTH = 272; const MIN_WIDTH = DEFAULT_WIDTH; const MAX_WIDTH = 500; // 两套刷新机制的默认值刻意不同: // - 最新活动依赖 Discourse message-bus 的增量候选,默认 0 表示用户手动点提醒; // - 其他排序没有可靠增量事件,默认 10 秒重新拉取当前列表。 const DEFAULT_AUTO_SILENT_REFRESH_INTERVAL = 0; const DEFAULT_AUTO_REFRESH_INTERVAL = 10; // 自动补页只在“筛选后当前页不够显示”时触发。窗口限速和空结果计数一起 // 防止未读/已读筛选在站点数据不足时连续请求后续页。 const AUTO_LOAD_RATE_WINDOW_MS = 5000; const AUTO_LOAD_MAX_REQUESTS_PER_WINDOW = 3; const AUTO_LOAD_MAX_EMPTY_FILTER_RESULTS = 3; const SETTINGS_BUTTON_SIZE = 28; const TAG_STYLE_CACHE_VERSION = 1; // ========== 全局状态 ========== // currentOrder 历史上曾使用 default,后续需求把“默认”和“最新活动”合并。 // 这里在启动时迁移旧值,避免旧用户升级后落到不存在的排序分支。 let feedModeEnabled = GM_getValue(STATE_KEY, false); let currentOrder = GM_getValue(ORDER_KEY, "activity"); if (currentOrder === "default") { currentOrder = "activity"; GM_setValue(ORDER_KEY, currentOrder); } let currentPeriod = GM_getValue(PERIOD_KEY, "all"); let sfpSidebarWidth = GM_getValue(WIDTH_KEY, DEFAULT_WIDTH); let currentTab = GM_getValue(TAB_KEY, "all"); let currentFilter = GM_getValue(FILTER_KEY, "all"); let hidePinned = GM_getValue(HIDE_PINNED_KEY, false); let showIncomingHint = GM_getValue(SHOW_INCOMING_HINT_KEY, true); let autoSilentRefreshEnabled = GM_getValue(AUTO_SILENT_REFRESH_KEY, false); let autoSilentRefreshInterval = Math.max(0, Number(GM_getValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL)) || DEFAULT_AUTO_SILENT_REFRESH_INTERVAL); let autoRefreshEnabled = GM_getValue(AUTO_REFRESH_ENABLED_KEY, false); let autoRefreshInterval = Math.max(1, Number(GM_getValue(AUTO_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_REFRESH_INTERVAL)) || DEFAULT_AUTO_REFRESH_INTERVAL); let currentCategoryId = null; let allTopics = []; let usersMap = {}; let loadedTopicIds = new Set(); let currentPage = 0; let hasMorePages = true; let isLoading = false; let isLoadingMore = false; let isRefreshing = false; let _pendingReload = false; // 自动刷新相关计时器都只存运行态,不持久化剩余秒数。页面切换或脚本重载后 // 重新按用户配置开始倒计时,比恢复旧倒计时更容易避免重复刷新。 let autoSilentRefreshTimer = null; let autoSilentRefreshSeconds = 0; let autoRefreshTimer = null; let autoRefreshSeconds = 0; // 自动补页按“当前查询快照”隔离。切换板块、排序、已读筛选后必须清零, // 否则上一个视图的限速或空结果会错误影响新视图。 let autoLoadTimestamps = []; let autoLoadEmptyFilterCount = 0; let autoLoadStoppedForSession = false; let autoLoadSessionKey = ""; // message-bus 只告诉我们“可能有变化的话题 id”。完整话题数据仍要从 // /latest.json?topic_ids=... 拉取;本地 cache 只用于在显示提醒前做板块范围 // 粗筛,避免每条推送都立即请求详情。 const sidebarIncomingState = { topicIds: [], topicIdSet: new Set(), topicCache: new Map(), filteredTopicIds: [], filterRefreshTimer: null, filterRefreshToken: 0, viewSettling: false, filterStable: false, applyQueued: false, }; let sidebarMessageBus = null; let sidebarLatestMessageBusCallback = null; let sidebarNewMessageBusCallback = null; let activeLoadToken = 0; let activeLoadMoreToken = 0; let activeRefreshToken = 0; let categoryMetaPromise = null; let categoryMetaLoaded = false; let tagStylePromise = null; let tagStyleLoaded = false; let siteDataPromise = null; let siteDataLoaded = false; let siteDataCache = null; let pendingTabBarScrollTab = null; let toggleBtn = null; let feedContainer = null; let feedScrollEl = null; let feedListEl = null; let feedHeaderEl = null; let feedRefreshBtn = null; let refreshBusyCount = 0; let feedBackTopBtn = null; let feedScrollAbortController = null; let resizerEl = null; let isResizing = false; let originalSidebarWidthBeforeFeed = null; let widthAnimationTimer = null; const topicHighlightTimers = new WeakMap(); // ========== 工具函数 ========== let cachedCsrfToken = null; function getCsrfToken() { if (cachedCsrfToken === null) { cachedCsrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; } return cachedCsrfToken; } function toAbsoluteSiteUrl(path) { if (!path) return ""; return new URL(path, location.origin).href; } function navigateTo(path) { const script = document.createElement("script"); script.textContent = `window.require("discourse/lib/url").default.routeTo("${path}");`; document.documentElement.appendChild(script); script.remove(); } function getDiscourse() { try { return (typeof unsafeWindow !== "undefined" && unsafeWindow.Discourse) || (typeof window !== "undefined" && window.Discourse) || (typeof Discourse !== "undefined" && Discourse) || null; } catch (e) { return null; } } function getMessageBus() { try { return getDiscourse()?.__container__?.lookup("service:message-bus") || null; } catch (e) { return null; } } function debounce(fn, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } function escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function escapeAttr(text) { return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'"); } function formatRelativeTime(dateStr) { const date = new Date(dateStr); if (Number.isNaN(date.getTime())) return ""; const now = new Date(); const diff = Math.max(0, now - date); const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 60) return `${Math.max(1, seconds)}秒前`; if (minutes < 60) return `${minutes}分钟前`; if (hours < 24) return `${hours}小时前`; if (days < 30) return `${days}天前`; const months = Math.floor(days / 30); if (months < 12) return `${months}个月前`; return `${Math.floor(months / 12)}年前`; } function getAvatarUrl(template, size) { if (!template) return ""; let url = template.replace("{size}", String(size)); if (!url.startsWith("http")) url = toAbsoluteSiteUrl(url); return url; } function waitForEmber(callback, maxWait = 15000) { const start = Date.now(); function check() { try { if ( getDiscourse()?.__container__ ) { callback(); return; } } catch (e) { /* not ready */ } if (Date.now() - start < maxWait) { setTimeout(check, 500); } else { console.warn("[SFP] Timed out waiting for Ember"); } } check(); } // ========== 分类配置 ========== const CATEGORY_CONFIG = { 4: { name: "开发调优", icon: "code", color: "#32c3c3", tabId: "develop" }, 20: { name: "开发调优, Lv1", icon: "code", color: "#32c3c3" }, 31: { name: "开发调优, Lv2", icon: "code", color: "#32c3c3" }, 88: { name: "开发调优, Lv3", icon: "code", color: "#32c3c3" }, 98: { name: "国产替代", icon: "seedling", color: "#D12C25", tabId: "domestic" }, 99: { name: "国产替代, Lv1", icon: "seedling", color: "#D12C25" }, 100: { name: "国产替代, Lv2", icon: "seedling", color: "#D12C25" }, 101: { name: "国产替代, Lv3", icon: "seedling", color: "#D12C25" }, 14: { name: "资源荟萃", icon: "square-share-nodes", color: "#12A89D", tabId: "resource" }, 83: { name: "资源荟萃, Lv1", icon: "square-share-nodes", color: "#12A89D" }, 84: { name: "资源荟萃, Lv2", icon: "square-share-nodes", color: "#12A89D" }, 85: { name: "资源荟萃, Lv3", icon: "square-share-nodes", color: "#12A89D" }, 94: { name: "网盘资源", icon: "hard-drive", color: "#16b176" }, 95: { name: "网盘资源, Lv1", icon: "hard-drive", color: "#16b176" }, 96: { name: "网盘资源, Lv2", icon: "hard-drive", color: "#16b176" }, 97: { name: "网盘资源, Lv3", icon: "hard-drive", color: "#16b176" }, 42: { name: "文档共建", icon: "book", color: "#9cb6c4", tabId: "wiki" }, 75: { name: "文档共建, Lv1", icon: "book", color: "#9cb6c4" }, 76: { name: "文档共建, Lv2", icon: "book", color: "#9cb6c4" }, 77: { name: "文档共建, Lv3", icon: "book", color: "#9cb6c4" }, 10: { name: "跳蚤市场", icon: "coins", color: "#ED207B", tabId: "trade" }, 106: { name: "积分乐园", icon: "credit-card", color: "#fcca44", tabId: "credit" }, 107: { name: "积分乐园, Lv1", icon: "credit-card", color: "#fcca44" }, 108: { name: "积分乐园, Lv2", icon: "credit-card", color: "#fcca44" }, 109: { name: "积分乐园, Lv3", icon: "credit-card", color: "#fcca44" }, 27: { name: "非我莫属", icon: "briefcase", color: "#a8c6fe", tabId: "job" }, 72: { name: "非我莫属, Lv1", icon: "briefcase", color: "#a8c6fe" }, 73: { name: "非我莫属, Lv2", icon: "briefcase", color: "#a8c6fe" }, 74: { name: "非我莫属, Lv3", icon: "briefcase", color: "#a8c6fe" }, 32: { name: "读书成诗", icon: "book-open-reader", color: "#e0d900", tabId: "reading" }, 69: { name: "读书成诗, Lv1", icon: "book-open-reader", color: "#e0d900" }, 70: { name: "读书成诗, Lv2", icon: "book-open-reader", color: "#e0d900" }, 71: { name: "读书成诗, Lv3", icon: "book-open-reader", color: "#e0d900" }, 46: { name: "扬帆起航", icon: "rocket", color: "#ff9838", tabId: "startup" }, 66: { name: "扬帆起航, Lv1", icon: "rocket", color: "#ff9838" }, 67: { name: "扬帆起航, Lv2", icon: "rocket", color: "#ff9838" }, 68: { name: "扬帆起航, Lv3", icon: "rocket", color: "#ff9838" }, 34: { name: "前沿快讯", icon: "newspaper", color: "#BB8FCE", tabId: "news" }, 78: { name: "前沿快讯, Lv1", icon: "newspaper", color: "#BB8FCE" }, 79: { name: "前沿快讯, Lv2", icon: "newspaper", color: "#BB8FCE" }, 80: { name: "前沿快讯, Lv3", icon: "newspaper", color: "#BB8FCE" }, 36: { name: "福利羊毛", icon: "piggy-bank", color: "#E45735", tabId: "welfare" }, 60: { name: "福利羊毛, Lv1", icon: "piggy-bank", color: "#E45735" }, 61: { name: "福利羊毛, Lv2", icon: "piggy-bank", color: "#E45735" }, 62: { name: "福利羊毛, Lv3", icon: "piggy-bank", color: "#E45735" }, 11: { name: "搞七捻三", icon: "droplet", color: "#3AB54A", tabId: "gossip" }, 35: { name: "搞七捻三, Lv1", icon: "droplet", color: "#3AB54A" }, 89: { name: "搞七捻三, Lv2", icon: "droplet", color: "#3AB54A" }, 21: { name: "搞七捻三, Lv3", icon: "droplet", color: "#3AB54A" }, 102: { name: "社区孵化", icon: "lightbulb", color: "#ffbb00", tabId: "incubation" }, 103: { name: "社区孵化, Lv1", icon: "lightbulb", color: "#ffbb00" }, 104: { name: "社区孵化, Lv2", icon: "lightbulb", color: "#ffbb00" }, 105: { name: "社区孵化, Lv3", icon: "lightbulb", color: "#ffbb00" }, 110: { name: "虫洞广场", icon: "hurricane", color: "#ff00f7", tabId: "square" }, 2: { name: "运营反馈", icon: "comments", color: "#808281", tabId: "feedback" }, 30: { name: "运营反馈, 活动", icon: "comments", color: "#808281" }, 63: { name: "运营反馈, Lv1", icon: "comments", color: "#808281" }, 64: { name: "运营反馈, Lv2", icon: "comments", color: "#808281" }, 65: { name: "运营反馈, Lv3", icon: "comments", color: "#808281" }, 45: { name: "深海幽域", icon: "water", color: "#45B7D1", tabId: "muted" }, 57: { name: "深海幽域, Lv1", icon: "water", color: "#45B7D1" }, 58: { name: "深海幽域, Lv2", icon: "water", color: "#45B7D1" }, 59: { name: "深海幽域, Lv3", icon: "water", color: "#45B7D1" }, }; // 有 tabId 的主分类(用于标签页渲染) const TAB_CATEGORIES = Object.entries(CATEGORY_CONFIG) .filter(([, v]) => v.tabId) .map(([id, v]) => ({ id: Number(id), ...v })); const categoryMetaById = new Map(); const tagStyleByKey = new Map(); const SAFE_ICON_RE = /^[A-Za-z0-9_-]+$/; const SAFE_COLOR_RE = /^#?[A-Fa-f0-9]{3,8}$/; function _normalizeHexColor(color, fallback = "888") { if (!color) return fallback; const raw = String(color).trim(); if (!SAFE_COLOR_RE.test(raw)) return fallback; return raw.startsWith("#") ? raw.slice(1) : raw; } function _isSafeIconName(icon) { return typeof icon === "string" && SAFE_ICON_RE.test(icon); } function _safeIconName(icon) { return _isSafeIconName(icon) ? icon : ""; } function _safeCategoryStyleType(styleType, hasIcon) { return ["icon", "emoji", "square"].includes(styleType) ? styleType : (hasIcon ? "icon" : "square"); } function _svgIcon(icon, extraClass = "") { const safeIcon = _safeIconName(icon); if (!safeIcon) return ""; const className = `fa d-icon d-icon-${safeIcon} svg-icon fa-width-auto svg-string${extraClass ? ` ${extraClass}` : ""}`; return ``; } function _categoryFallbackMeta(id) { const config = CATEGORY_CONFIG[id]; if (!config) return null; return _normalizeCategoryMeta({ id }, config, config.parent_category_id ? _getCategoryMeta(config.parent_category_id) : null); } function _normalizeCategoryMeta(raw = {}, fallback = {}, parent = null) { const id = Number(raw.id ?? fallback.id); const icon = _safeIconName(raw.icon || fallback.icon || parent?.icon || "folder"); return { id, name: raw.name || fallback.name || "", color: _normalizeHexColor(raw.color || fallback.color, "888"), text_color: _normalizeHexColor(raw.text_color || fallback.text_color, "FFFFFF"), icon, style_type: _safeCategoryStyleType(raw.style_type || fallback.style_type, !!icon), slug: raw.slug || fallback.tabId || "", parent_category_id: raw.parent_category_id || fallback.parent_category_id || null, parent_color: parent ? _normalizeHexColor(parent.color, "888") : null, parent_text_color: parent ? _normalizeHexColor(parent.text_color, "FFFFFF") : null, read_restricted: !!(raw.read_restricted || fallback.read_restricted), description_text: raw.description_text || raw.description_excerpt || raw.description || "", description_excerpt: raw.description_excerpt || raw.description_text || raw.description || "", }; } function _getCategoryMeta(id) { const numericId = Number(id); if (!Number.isFinite(numericId)) return null; return categoryMetaById.get(numericId) || _categoryFallbackMeta(numericId); } function getCategoryTabMeta(cat) { const meta = _getCategoryMeta(cat.id); return { ...cat, name: meta?.name || cat.name, icon: meta?.icon || cat.icon, color: meta?.color ? `#${meta.color}` : cat.color, }; } function _getSavedTabOrder() { const savedOrder = GM_getValue(TAB_ORDER_KEY, []); if (!Array.isArray(savedOrder)) return []; return savedOrder.map((id) => Number(id)).filter((id) => Number.isFinite(id)); } function _getOrderedTabCategories() { const savedOrder = _getSavedTabOrder(); if (!savedOrder.length) return [...TAB_CATEGORIES]; return [...TAB_CATEGORIES].sort((a, b) => { const idxA = savedOrder.indexOf(a.id); const idxB = savedOrder.indexOf(b.id); if (idxA === -1 && idxB === -1) return 0; if (idxA === -1) return 1; if (idxB === -1) return -1; return idxA - idxB; }); } function _saveTabOrderFromGrid(grid) { const order = Array.from(grid.querySelectorAll(".sfp-tab-grid-item[data-category-id]")) .map((item) => Number(item.dataset.categoryId)) .filter((id) => Number.isFinite(id)); GM_setValue(TAB_ORDER_KEY, order); } function _buildCategoryTabContent(cat) { const tabMeta = getCategoryTabMeta(cat); return `${_svgIcon(tabMeta.icon)}${escapeHtml(tabMeta.name)}`; } function _cssEscape(value) { return window.CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/["\\]/g, "\\$&"); } function _closeFloatingPanels(exceptEl = null) { document.querySelectorAll(".sfp-custom-select.open, .sfp-settings-wrap.open, .sfp-tab-shell.open").forEach((el) => { if (el !== exceptEl) el.classList.remove("open"); }); } function _scrollTabIntoView(shell, tabId = currentTab, behavior = "auto") { const bar = shell?.querySelector(".sfp-tab-bar"); const activeTab = shell?.querySelector(`.sfp-tab-bar .sfp-tab-item[data-tab="${_cssEscape(tabId)}"]`); if (!bar || !activeTab) return; const maxScrollLeft = Math.max(0, bar.scrollWidth - bar.clientWidth); const targetLeft = activeTab.offsetLeft - ((bar.clientWidth - activeTab.offsetWidth) / 2); const left = Math.min(maxScrollLeft, Math.max(0, targetLeft)); bar.scrollTo({ left, behavior }); } function _parsePreloadedPayload(raw) { if (!raw) return null; try { const decoded = raw.startsWith("%") ? decodeURIComponent(raw) : raw; return JSON.parse(decoded); } catch (e) { return null; } } function _extractPreloadedSiteData() { const candidates = [ ...document.querySelectorAll("[data-preloaded]"), ...document.querySelectorAll("script[type='application/json']"), ]; for (const el of candidates) { const payload = _parsePreloadedPayload(el.getAttribute("data-preloaded") || el.textContent || ""); const site = payload?._site || payload?.site || payload; if (site?.categories || site?.top_tags) return site; } return null; } async function loadSiteData() { if (siteDataLoaded) return siteDataCache; if (siteDataPromise) return siteDataPromise; siteDataPromise = (async () => { const preloaded = _extractPreloadedSiteData(); if (preloaded) { siteDataCache = preloaded; siteDataLoaded = true; return siteDataCache; } const resp = await fetch("/site.json", { headers: { "X-CSRF-Token": getCsrfToken() } }); if (!resp.ok) throw new Error(`site.json ${resp.status}`); siteDataCache = await resp.json(); siteDataLoaded = true; return siteDataCache; })().finally(() => { siteDataPromise = null; }); return siteDataPromise; } async function loadCategoryMetadata() { if (categoryMetaLoaded) return; if (categoryMetaPromise) return categoryMetaPromise; categoryMetaPromise = (async () => { try { const site = await loadSiteData(); const categories = Array.isArray(site?.categories) ? site.categories : []; const rawById = new Map(categories.map((cat) => [Number(cat.id), cat])); categories.forEach((cat) => { const id = Number(cat.id); if (!Number.isFinite(id)) return; const parent = cat.parent_category_id ? rawById.get(Number(cat.parent_category_id)) : null; const fallback = CATEGORY_CONFIG[id] || {}; categoryMetaById.set(id, _normalizeCategoryMeta(cat, fallback, parent)); }); categoryMetaLoaded = true; } catch (e) { console.warn("[SFP] load category metadata failed:", e); } finally { categoryMetaPromise = null; } })(); return categoryMetaPromise; } function _tagIndexKeys(tag) { const keys = []; const add = (value) => { if (value === null || value === undefined) return; const key = String(value).trim().toLowerCase(); if (key && !keys.includes(key)) keys.push(key); }; if (typeof tag === "string") { add(tag); return keys; } add(tag?.name); add(tag?.slug); add(tag?.text); add(tag?.id); return keys; } function _tagDisplayName(tag) { return typeof tag === "string" ? tag : (tag?.name || tag?.text || tag?.slug || ""); } function _normalizeTagRecord(tag) { if (typeof tag === "string") { const name = tag.trim(); return name ? { name, slug: name } : null; } if (tag && typeof tag === "object") { const name = String(tag.name || tag.text || tag.slug || tag.id || "").trim(); if (!name) return null; return { id: tag.id, name, slug: tag.slug || tag.name || name, }; } const name = String(tag || "").trim(); return name ? { name, slug: name } : null; } function _getTopTagsFromSiteData(site) { if (site?.can_tag_topics === false) return []; const topTags = Array.isArray(site?.top_tags) ? site.top_tags : []; return topTags.map(_normalizeTagRecord).filter(Boolean); } function _cacheTagStyleAliases(tags) { tags.forEach((tag) => { const style = _getTagStyle(tag); if (style) _cacheTagStyle(_tagIndexKeys(tag), style); }); } function _getTagStyle(tag) { for (const key of _tagIndexKeys(tag)) { const style = tagStyleByKey.get(key); if (style) return style; } return null; } function _sanitizeTagStyle(styleText) { const pairs = []; String(styleText || "").split(";").forEach((part) => { const [rawName, rawValue] = part.split(":"); const name = rawName?.trim(); const value = rawValue?.trim(); if ((name === "--color1" || name === "--color2") && /^#[A-Fa-f0-9]{3,8}$/.test(value)) { pairs.push(`${name}: ${value}`); } }); return pairs.join("; "); } function _cacheTagStyle(keys, style) { keys.forEach((key) => { const normalized = String(key || "").trim().toLowerCase(); if (normalized) tagStyleByKey.set(normalized, style); }); } function _loadTagStyleCache() { if (tagStyleByKey.size > 0) return true; try { const cache = GM_getValue(TAG_STYLE_CACHE_KEY, null); if (!cache || cache.version !== TAG_STYLE_CACHE_VERSION || !Array.isArray(cache.entries)) { return false; } cache.entries.forEach(([key, style]) => { if (!key || !style || typeof style !== "object") return; const icon = _safeIconName(style.icon || ""); const cssText = _sanitizeTagStyle(style.cssText || ""); if (!icon && !cssText) return; tagStyleByKey.set(String(key), { icon, cssText, hasIcon: !!icon, }); }); return tagStyleByKey.size > 0; } catch (e) { console.warn("[SFP] load tag style cache failed:", e); return false; } } function _saveTagStyleCache() { if (tagStyleByKey.size === 0) return; try { GM_setValue(TAG_STYLE_CACHE_KEY, { version: TAG_STYLE_CACHE_VERSION, savedAt: Date.now(), entries: Array.from(tagStyleByKey.entries()), }); } catch (e) { console.warn("[SFP] save tag style cache failed:", e); } } function _extractTagStylesFromDocument(doc) { const anchors = Array.from(doc.querySelectorAll("a.discourse-tag[data-tag-name]")); anchors.forEach((anchor) => { const use = anchor.querySelector("svg use"); const icon = _safeIconName((use?.getAttribute("href") || use?.getAttribute("xlink:href") || "").replace(/^#/, "")); const style = { icon, cssText: _sanitizeTagStyle(anchor.getAttribute("style") || ""), hasIcon: !!icon, }; if (!style.hasIcon && !style.cssText) return; const hrefParts = (anchor.getAttribute("href") || "").split("/").filter(Boolean); _cacheTagStyle([ anchor.dataset.tagName, anchor.textContent, hrefParts[1], hrefParts[2], ], style); }); } function _waitForIframeTags(iframe, timeoutMs = 12000) { return new Promise((resolve) => { const start = Date.now(); const tick = () => { let doc = null; try { doc = iframe.contentDocument; if (doc && doc.querySelector("a.discourse-tag[data-tag-name]")) { resolve(doc); return; } } catch (e) { resolve(null); return; } if (Date.now() - start >= timeoutMs) { resolve(doc); return; } setTimeout(tick, 250); }; tick(); }); } async function loadTagStyleIndex() { if (tagStyleLoaded) return; if (tagStylePromise) return tagStylePromise; tagStylePromise = (async () => { let iframe = null; try { let siteTags = []; try { siteTags = _getTopTagsFromSiteData(await loadSiteData()); } catch (e) { siteTags = _getTopTagsFromSiteData(_extractPreloadedSiteData()); } if (_loadTagStyleCache()) { _cacheTagStyleAliases(siteTags); tagStyleLoaded = true; return; } _extractTagStylesFromDocument(document); iframe = document.createElement("iframe"); iframe.src = "/tags"; iframe.setAttribute("aria-hidden", "true"); iframe.style.cssText = "position:absolute;width:1px;height:1px;left:-10000px;top:-10000px;border:0;visibility:hidden;pointer-events:none;"; document.body.appendChild(iframe); const doc = await _waitForIframeTags(iframe); if (doc) _extractTagStylesFromDocument(doc); _cacheTagStyleAliases(siteTags); _saveTagStyleCache(); tagStyleLoaded = true; } catch (e) { console.warn("[SFP] load tag style index failed:", e); } finally { if (iframe) iframe.remove(); tagStylePromise = null; } })(); return tagStylePromise; } // ========== CSS 注入 ========== function injectStyles() { GM_addStyle(` /* ===== 切换按钮 ===== */ .sfp-toggle-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; background: var(--primary-very-low); color: var(--primary-medium); cursor: pointer; border-radius: 6px; padding: 0; margin-left: 6px; vertical-align: middle; transition: color 0.2s, background 0.2s; flex-shrink: 0; } .sfp-toggle-btn:hover { color: var(--primary); background: var(--primary-low); } .sfp-toggle-btn.active { color: var(--secondary); background: var(--tertiary); } .sfp-toggle-btn svg { width: 18px; height: 18px; fill: currentColor; } .home-logo-wrapper-outlet .title { display: flex; align-items: center; gap: 2px; } /* ===== 侧边栏 Feed 模式 ===== */ .sidebar-wrapper:has(> .sidebar-container.sfp-feed-mode) { overflow-x: hidden !important; } .sidebar-container.sfp-feed-mode { overflow-x: hidden !important; } .sidebar-container.sfp-feed-mode .sfp-feed-container, .sidebar-container.sfp-feed-mode .sfp-feed-container * { box-sizing: border-box; } /* 隐藏所有非 feed 的直接子元素 */ .sidebar-container.sfp-feed-mode > :not(.sfp-feed-container):not(.sfp-resizer) { display: none !important; } /* 显式隐藏常见 sidebar 组件(嵌套情况兜底) */ .sidebar-container.sfp-feed-mode .sidebar-sections, .sidebar-container.sfp-feed-mode .sidebar-footer-container, .sidebar-container.sfp-feed-mode .sidebar-footer-wrapper, .sidebar-container.sfp-feed-mode .sidebar-footer, .sidebar-container.sfp-feed-mode .sidebar-custom-sections, .sidebar-container.sfp-feed-mode .sidebar-section-wrapper, .sidebar-container.sfp-feed-mode .sidebar-section-header, .sidebar-container.sfp-feed-mode .sidebar-section-link-wrapper { display: none !important; } .sidebar-container.sfp-feed-mode .sfp-feed-container { display: flex; flex-direction: column; position: relative; width: 100%; min-width: 0; height: 100%; overflow: hidden; max-width: 100%; } .sidebar-wrapper.sfp-width-animating, .sidebar-container.sfp-width-animating, #d-sidebar.sfp-width-animating { transition: width 220ms ease, max-width 220ms ease; } /* ===== 拖拽调整宽度 ===== */ .sfp-resizer { position: absolute; top: 0; right: -2px; width: 5px; height: 100%; cursor: ew-resize; z-index: 10001; transition: background 0.2s; } .sfp-resizer:hover, .sfp-resizer.sfp-resizing { background: var(--tertiary); } /* ===== Feed Header ===== */ .sfp-feed-header { position: relative; flex-shrink: 0; padding: 8px 12px; border-bottom: 1px solid var(--primary-low); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; overflow: visible; } .sfp-feed-header .sfp-header-spacer { flex: 1 1 auto; min-width: 8px; } .sfp-feed-header .sfp-refresh-btn, .sfp-feed-header .sfp-settings-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; background: var(--primary-very-low); color: var(--primary-medium); cursor: pointer; border-radius: 6px; padding: 0; flex-shrink: 0; transition: color 0.2s, background 0.2s; } .sfp-feed-header .sfp-refresh-btn:hover, .sfp-feed-header .sfp-settings-btn:hover, .sfp-settings-wrap.open .sfp-settings-btn { color: var(--tertiary); background: var(--primary-low); } .sfp-feed-header .sfp-refresh-btn.spinning svg { animation: sfp-spin 0.6s linear infinite; } .sfp-feed-header .sfp-refresh-btn.spinning { color: var(--tertiary); background: var(--primary-low); } @keyframes sfp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .sfp-feed-header .sfp-refresh-btn svg, .sfp-feed-header .sfp-settings-btn svg { width: 16px; height: 16px; fill: currentColor; } .sfp-settings-btn { position: absolute; top: 0; right: 0; z-index: 2; gap: 3px; flex-direction: column; } .sfp-settings-line { width: 14px; height: 2px; border-radius: 2px; background: currentColor; transition: transform 0.28s ease, opacity 0.2s ease; transform-origin: center; } .sfp-settings-wrap.open .sfp-settings-line-1 { transform: translateY(5px) rotate(45deg); } .sfp-settings-wrap.open .sfp-settings-line-2 { opacity: 0; transform: scaleX(0); } .sfp-settings-wrap.open .sfp-settings-line-3 { transform: translateY(-5px) rotate(-45deg); } .sfp-show-more-overlay { position: relative; z-index: 1; display: flex; justify-content: center; width: 100%; max-width: 100%; margin: 0; padding: 0; font-size: 12px; pointer-events: none; overflow: hidden; animation: sfp-show-more-enter 260ms cubic-bezier(0.2, 0.8, 0.2, 1); } .sfp-show-more-overlay .sfp-hint-text { display: inline-flex; align-items: center; justify-content: center; gap: 0.5em; max-width: calc(100% - 24px); margin: 8px 12px 6px; padding: var(--space-2, 0.5em) var(--space-4, 1em); border: none; border-radius: var(--d-border-radius-large, 20px); cursor: pointer; font-size: inherit; line-height: 1.35; text-decoration: none; white-space: nowrap; pointer-events: auto; transition: background-color 0.2s, color 0.2s; } .sfp-show-more-overlay .sfp-hint-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .sfp-show-more-overlay .sfp-hint-spinner-custom { box-sizing: border-box; display: inline-block; width: 0.85em; height: 0.85em; border: 0.15em solid currentColor; border-right-color: transparent; border-radius: 50%; flex: 0 0 auto; align-self: center; animation: sfp-spin 0.75s linear infinite; } .sfp-show-more-overlay .sfp-hint-text.loading { color: var(--primary-medium); cursor: default; } .sfp-show-more-overlay .sfp-hint-text:hover { color: var(--tertiary-hover, var(--tertiary)); } .sfp-show-more-overlay .sfp-hint-text.loading:hover { color: var(--primary-medium); } @keyframes sfp-show-more-enter { from { max-height: 0; opacity: 0; transform: translateY(-100%); } to { max-height: 48px; opacity: 1; transform: translateY(0); } } .sfp-settings-wrap { position: relative; width: 28px; height: 28px; flex-shrink: 0; overflow: visible; } .sfp-settings-shell { position: absolute; top: 0; right: 0; width: 28px; height: 28px; background: transparent; border: none; border-radius: 6px; box-shadow: none; z-index: 10003; overflow: hidden; transition: width 0.36s cubic-bezier(0.25, 1, 0.5, 1), height 0.36s cubic-bezier(0.25, 1, 0.5, 1), border-radius 0.24s ease, background 0.2s ease; } .sfp-settings-wrap.open .sfp-settings-shell { width: 204px; height: var(--sfp-settings-shell-height, 128px); overflow: visible; background: var(--primary-very-low); border: 1px solid var(--primary-low); border-radius: 8px; box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 14%, transparent); } .sfp-settings-panel { box-sizing: border-box; width: 204px; padding: 36px 10px 8px 10px; opacity: 0; visibility: hidden; transform: translateY(-8px); pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s; } .sfp-settings-wrap.open .sfp-settings-panel { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; transition: opacity 0.26s ease 0.12s, transform 0.26s ease 0.12s, visibility 0.26s 0.12s; } .sfp-setting-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 8px; font-size: 12px; color: var(--primary); line-height: 1.3; padding: 4px 0; } .sfp-setting-label { display: inline-flex; align-items: center; gap: 4px; min-width: 0; white-space: nowrap; } .sfp-setting-help-wrap { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex: 0 0 14px; pointer-events: none; } .sfp-setting-help { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; box-sizing: border-box; appearance: none; border: none; background: transparent; color: var(--primary-medium); cursor: help; padding: 0; pointer-events: auto; } .sfp-setting-help svg { width: 13px; height: 13px; display: block; fill: currentColor; pointer-events: none; } .sfp-setting-help:hover { color: var(--tertiary); outline: none; } .sfp-help-tooltip { position: fixed; z-index: 10004; width: 178px; max-width: calc(100vw - 32px); padding: 8px 9px; border: 1px solid var(--primary-low); border-radius: 6px; background: var(--secondary); box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent); color: var(--primary); font-size: 12px; font-weight: 400; line-height: 1.45; text-align: left; white-space: normal; opacity: 0; pointer-events: none; transform: translateY(-4px); transition: opacity 0.16s ease, transform 0.16s ease; } .sfp-help-tooltip.visible { opacity: 1; transform: translateY(0); } .sfp-setting-row input[type="checkbox"] { flex-shrink: 0; margin: 0; } .sfp-setting-interval { display: none; grid-template-columns: minmax(0, 1fr) 58px auto; align-items: center; column-gap: 6px; margin-top: 6px; font-size: 12px; color: var(--primary-medium); } .sfp-setting-interval.visible { display: grid; } .sfp-setting-row.hidden, .sfp-setting-interval.hidden { display: none; } .sfp-setting-interval input { width: 58px; height: 26px; padding: 2px 6px; border: 1px solid var(--primary-low); border-radius: 4px; background: var(--secondary); color: var(--primary); font-size: 12px; } /* ===== 自定义下拉 ===== */ .sfp-custom-select { position: relative; flex-shrink: 0; } .sfp-custom-select-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; font-size: 12px; height: 28px; border: none; background: var(--primary-very-low); color: var(--primary); border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none; transition: background 0.2s, color 0.2s; } .sfp-custom-select-btn:hover { background: var(--primary-low); } .sfp-custom-select-btn::after { content: ""; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid currentColor; } .sfp-custom-select-dropdown { position: fixed; min-width: 100%; background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 6px; box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent); z-index: 10002; display: none; overflow: hidden; } .sfp-custom-select.open .sfp-custom-select-dropdown { display: block; } .sfp-custom-select-option { display: block; width: 100%; padding: 6px 14px; font-size: 12px; border: none; background: none; color: var(--primary); cursor: pointer; text-align: left; white-space: nowrap; transition: background 0.15s; } .sfp-custom-select-option:hover { background: var(--primary-very-low); } .sfp-custom-select-option.selected { color: var(--tertiary); font-weight: 600; } /* ===== 分类标签栏 ===== */ .sfp-tab-shell { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 36px; align-items: stretch; width: 100%; min-width: 0; max-width: 100%; border-bottom: 1px solid var(--primary-low); flex-shrink: 0; background: var(--d-content-background, var(--secondary)); } .sfp-tab-bar { display: flex; gap: 8px; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; scrollbar-width: none; width: 100%; min-width: 0; max-width: 100%; padding: 8px 12px; margin: 0; flex-shrink: 0; background: transparent; } .sfp-tab-bar::-webkit-scrollbar { display: none; } .sfp-tab-item { display: inline-flex; align-items: center; gap: 3px; padding: 4px 12px; font-size: 13px; color: var(--primary-medium); cursor: pointer; white-space: nowrap; border-radius: 16px; background: var(--primary-very-low); transition: all 0.2s; border: 1px solid transparent; flex-shrink: 0; user-select: none; } .sfp-tab-item:hover { color: var(--primary); background: var(--primary-low); } .sfp-tab-item.active { color: var(--secondary); background: var(--tertiary); border-color: var(--tertiary); } .sfp-tab-item svg { width: 12px; height: 12px; fill: currentColor; flex-shrink: 0; } .sfp-tab-more-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; min-width: 36px; padding: 0; border: none; border-left: 1px solid var(--primary-low); background: var(--d-content-background, var(--secondary)); color: var(--primary-medium); cursor: pointer; transition: background 0.2s, color 0.2s; } .sfp-tab-more-btn:hover, .sfp-tab-shell.open .sfp-tab-more-btn { background: var(--primary-very-low); color: var(--primary); } .sfp-tab-more-btn svg { width: 16px; height: 16px; fill: currentColor; } .sfp-tab-panel { position: absolute; top: 100%; right: 8px; left: 8px; display: none; padding: 10px; max-height: min(58vh, 420px); overflow-y: auto; background: var(--secondary); border: 1px solid var(--primary-low); border-radius: 8px; box-shadow: 0 10px 28px color-mix(in srgb, var(--primary) 16%, transparent); z-index: 10002; } .sfp-tab-shell.open .sfp-tab-panel { display: block; } .sfp-tab-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; font-size: 12px; color: var(--primary-medium); line-height: 1.3; } .sfp-tab-panel-title { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } .sfp-tab-panel-title svg { width: 13px; height: 13px; fill: currentColor; flex-shrink: 0; } .sfp-tab-panel-close { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--primary-medium); cursor: pointer; } .sfp-tab-panel-close:hover { background: var(--primary-very-low); color: var(--primary); } .sfp-tab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); gap: 6px; } .sfp-tab-grid-item { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-width: 0; min-height: 32px; padding: 6px 8px; border: 1px solid transparent; border-radius: 6px; background: var(--primary-very-low); color: var(--primary-medium); cursor: pointer; font-size: 12px; line-height: 1.2; text-align: center; user-select: none; transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s; } .sfp-tab-grid-item svg { width: 12px; height: 12px; fill: currentColor; flex: 0 0 auto; } .sfp-tab-grid-item span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sfp-tab-grid-item:hover { background: var(--primary-low); color: var(--primary); } .sfp-tab-grid-item.active { background: var(--tertiary); border-color: var(--tertiary); color: var(--secondary); } .sfp-tab-grid-item.dragging { opacity: 0.45; } .sfp-tab-grid-item.drop-target { border-color: var(--tertiary); box-shadow: inset 0 0 0 1px var(--tertiary); } /* ===== 筛选栏 ===== */ .sfp-filter-bar { position: relative; z-index: 3; display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; max-width: 100%; padding: 8px 16px; margin: 0; background: var(--primary-very-low); border-bottom: 1px solid var(--primary-low); font-size: 12px; color: var(--primary-medium); flex-shrink: 0; } .sfp-filter-item { cursor: pointer; padding: 2px 6px; border-radius: 4px; transition: all 0.2s; user-select: none; } .sfp-filter-item:hover { color: var(--tertiary); background: var(--primary-low); } .sfp-filter-item.active { color: var(--secondary); background: var(--tertiary); } /* ===== Feed 滚动区 ===== */ .sfp-feed-scroll { position: relative; flex: 1; min-width: 0; max-width: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; --scrollbarBg: transparent; --scrollbarThumbBg: var(--d-selected, var(--token-color-surface-hovered)); --scrollbarWidth: var(--space-2, 0.5em); scrollbar-color: transparent var(--scrollbarBg); transition: scrollbar-color 0.25s ease-in-out; transition-delay: 0.5s; } .sfp-feed-scroll::-webkit-scrollbar { width: var(--scrollbarWidth); } .sfp-feed-scroll::-webkit-scrollbar-thumb { background-color: transparent; border-radius: calc(var(--scrollbarWidth) / 2); } .sfp-feed-scroll::-webkit-scrollbar-track { background-color: transparent; } .sfp-feed-scroll:hover { scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); transition-delay: 0s; } .sfp-feed-scroll:hover::-webkit-scrollbar-thumb { background-color: var(--scrollbarThumbBg); } .sfp-content-wrapper { position: relative; min-width: 0; max-width: 100%; min-height: 100%; } .sfp-back-top-btn { position: absolute; right: 14px; bottom: 14px; z-index: 4; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--primary-low); border-radius: 50%; background: var(--secondary); color: var(--primary-medium); box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 14%, transparent); cursor: pointer; opacity: 0; visibility: hidden; transform: translateY(8px); pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s, color 0.18s, background 0.18s; } .sfp-back-top-btn.visible { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; } .sfp-back-top-btn:hover { background: var(--primary-very-low); color: var(--tertiary); } .sfp-back-top-btn svg { width: 18px; height: 18px; fill: currentColor; } /* ===== 帖子列表项 ===== */ .sfp-topic-item { padding: 12px 20px; border-bottom: 1px solid var(--primary-very-low); cursor: pointer; transition: background 0.2s; position: relative; min-width: 0; max-width: 100%; overflow-wrap: break-word; word-break: break-word; } .sfp-topic-item:hover { background: var(--primary-very-low); } .sfp-topic-item.sfp-filter-mismatch { opacity: 0.48; filter: grayscale(0.85); } .sfp-topic-item.sfp-topic-unavailable .sfp-topic-title-line { text-decoration: line-through; } .sfp-topic-item.sfp-new-highlight { --sfp-new-highlight-color: var( --tertiary-med-or-tertiary, var(--tertiary) ); animation: sfp-new-pulse 10s ease-out forwards; position: relative; } @keyframes sfp-new-pulse { 0% { box-shadow: inset 0 0 0 2px var(--sfp-new-highlight-color); background: color-mix( in srgb, var(--sfp-new-highlight-color) 15%, transparent ); } 100% { box-shadow: inset 0 0 0 0px transparent; background: transparent; } } /* 未读圆点 — 紧跟在时间后 */ .sfp-topic-item .sfp-topic-time { font-size: 12px; color: var(--primary-medium); white-space: nowrap; margin-left: auto; flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px; line-height: 1; } .sfp-topic-item .sfp-unread-dot { width: 8px; height: 8px; flex: 0 0 8px; display: inline-block; border-radius: 50%; color: var(--tertiary-med-or-tertiary, var(--tertiary)); background: currentColor; opacity: 0.75; vertical-align: middle; } /* 头像 + 用户信息行 */ .sfp-topic-item .sfp-topic-header { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .sfp-topic-item .sfp-topic-avatar { width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; object-fit: cover; } .sfp-topic-item .sfp-topic-meta-col { display: flex; flex-direction: column; min-width: 0; flex: 1; } .sfp-topic-item .sfp-topic-user-info { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; overflow: hidden; } .sfp-topic-item .sfp-topic-username { font-size: 13px; color: var(--primary); font-weight: 500; cursor: pointer; transition: color 0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sfp-topic-item .sfp-topic-username:hover { color: var(--tertiary); } .sfp-topic-item .sfp-topic-name { font-size: 12px; color: var(--primary-medium); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* 标题 */ .sfp-topic-item .sfp-topic-title { font-size: 14px; font-weight: bold; color: var(--primary); line-height: 1.4; margin: 0; word-break: break-word; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; transition: color 0.2s; } .sfp-topic-item .sfp-topic-title:hover { color: var(--tertiary); } .sfp-topic-item.sfp-read .sfp-topic-title { color: var(--title-color--read, var(--primary-medium)); } .sfp-topic-item .sfp-topic-title-line { display: inline; } .sfp-topic-item .sfp-topic-status-badges { display: inline-flex; align-items: center; gap: 4px; margin-left: 2px; flex-shrink: 0; } .sfp-topic-item .topic-status-card { --badge-accent: var(--primary-medium); --badge-bg: var(--primary-very-low); --badge-border: var(--primary-low); display: inline-flex; align-items: center; gap: 3px; padding: 1px 6px; border: 1px solid var(--badge-border); border-radius: var(--d-border-radius, 8px); background: var(--badge-bg); color: var(--badge-accent); font-size: 11px; font-weight: 700; line-height: 1.45; margin-right: 5px; vertical-align: middle; white-space: nowrap; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--badge-accent) 8%, transparent); } .sfp-topic-item .topic-status-card.--hot { --badge-accent: var(--danger); --badge-bg: var(--danger-low, var(--d-hover, var(--tertiary-low))); --badge-border: color-mix(in srgb, var(--danger) 28%, transparent); } .sfp-topic-item .topic-status-card.--pinned { --badge-accent: var(--primary-medium); --badge-bg: var(--primary-very-low); --badge-border: var(--primary-low); } .sfp-topic-item .topic-status-card__name { color: var(--badge-accent); font-size: inherit; font-weight: inherit; line-height: inherit; margin: 0; } .sfp-topic-item .topic-status-card .d-icon { color: var(--badge-accent); width: 0.92em; height: 0.92em; flex-shrink: 0; } .sfp-topic-item .topic-statuses { float: left; } .sfp-topic-item .topic-statuses .topic-status { display: inline-flex; align-items: center; color: var(--primary-medium); margin: 0 0.18em 0 0; --icon-size: 0.86em; } .sfp-topic-item .topic-statuses .topic-status .d-icon { width: var(--icon-size); height: var(--icon-size); color: currentColor; } /* 分类 + 标签行 */ .sfp-topic-item .sfp-topic-category-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 5px; } .sfp-topic-item .sfp-category-badge { --badge-category-bg: light-dark( oklch(from var(--category-badge-color) 97% calc(c * 0.3) h), oklch(from var(--category-badge-color) 45% calc(c * 0.5) h) ); --badge-category-text: light-dark( oklch(from var(--category-badge-color) 35% calc(c * 0.6) h), oklch(from var(--category-badge-color) 95% calc(c * 0.2) h) ); display: inline-flex; align-items: center; gap: 0.33em; font-size: 11px; padding: 2px 6px; border-radius: var(--d-border-radius, 4px); background-color: var(--badge-category-bg, var(--primary-very-low)); color: var(--badge-category-text, var(--primary-medium)); flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sfp-topic-item .sfp-category-badge .badge-category { min-width: 0; align-items: center; } .sfp-topic-item .sfp-category-badge .badge-category__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--badge-category-text, var(--primary-medium)); } .sfp-topic-item .sfp-category-badge .d-icon { width: 0.9em; height: 0.9em; flex-shrink: 0; } @supports not (color: light-dark(tan, tan)) { .sfp-topic-item .sfp-category-badge { --badge-category-bg: color-mix(in srgb, var(--category-badge-color) 16%, transparent); --badge-category-text: var(--primary-high); } } /* 标签 */ .sfp-topic-item .sfp-topic-tags { display: flex; gap: 3px; flex-wrap: wrap; } .sfp-topic-item .sfp-tag { font-size: 11px; padding: 2px 6px; border-radius: var(--d-border-radius, 4px); line-height: 1.4; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; } .sfp-topic-item .sfp-tag .tag-icon .d-icon { width: 0.9em; height: 0.9em; } /* 统计行 */ .sfp-topic-item .sfp-topic-stats { display: flex; gap: 12px; margin-top: 8px; font-size: 12px; color: var(--primary-medium); } .sfp-topic-item .sfp-topic-stat { display: flex; align-items: center; gap: 4px; } .sfp-topic-item .sfp-topic-stat .d-icon { width: 1em; height: 1em; } /* ===== 加载状态 ===== */ .sfp-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--primary-medium); font-size: 13px; gap: 12px; } .sfp-spinner { width: 28px; height: 28px; border: 3px solid var(--primary-low); border-top-color: var(--tertiary); border-radius: 50%; animation: sfp-spin 0.8s linear infinite; } .sfp-empty { text-align: center; padding: 40px 10px; color: var(--primary-medium); font-size: 13px; } .sfp-load-more { padding: 14px 10px; text-align: center; font-size: 12px; color: var(--primary-medium); cursor: pointer; transition: color 0.2s; } .sfp-load-more-error { display: flex; justify-content: center; align-items: center; gap: 8px; cursor: default; } .sfp-load-more-error .sfp-load-more-retry { padding: 4px 10px; border: none; border-radius: 4px; background: var(--tertiary); color: var(--secondary); cursor: pointer; font-size: 12px; } .sfp-load-more:hover { color: var(--tertiary); } .sfp-load-more .sfp-load-more-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--primary-low); border-top-color: var(--tertiary); border-radius: 50%; animation: sfp-spin 0.8s linear infinite; vertical-align: middle; margin-right: 6px; } .sfp-no-more { padding: 14px 10px; text-align: center; font-size: 11px; color: var(--primary-low-mid); } .sfp-load-more-note { padding: 10px 10px 0; text-align: center; font-size: 12px; color: var(--primary-medium); } .sfp-error { padding: 40px 20px; text-align: center; color: var(--danger); display: flex; flex-direction: column; align-items: center; gap: 10px; } .sfp-error-icon { font-size: 32px; } .sfp-error-msg { font-size: 14px; font-weight: 600; } .sfp-error-detail { font-size: 12px; color: var(--primary-medium); word-break: break-word; } .sfp-error .sfp-retry-btn { margin-top: 6px; padding: 6px 16px; background: var(--tertiary); color: var(--secondary); border: none; border-radius: 4px; cursor: pointer; font-size: 12px; transition: opacity 0.2s; } .sfp-error .sfp-retry-btn:hover { opacity: 0.85; } `); } // ========== 切换开关 ========== function createToggle() { if (toggleBtn) return toggleBtn; const homeLogo = document.querySelector(".home-logo-wrapper-outlet"); if (!homeLogo) return null; toggleBtn = document.createElement("button"); toggleBtn.className = "sfp-toggle-btn" + (feedModeEnabled ? " active" : ""); toggleBtn.title = "切换侧边栏信息流"; toggleBtn.innerHTML = ``; toggleBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); feedModeEnabled = !feedModeEnabled; GM_setValue(STATE_KEY, feedModeEnabled); toggleBtn.classList.toggle("active", feedModeEnabled); if (feedModeEnabled) { activateFeed(); } else { deactivateFeed(); } }); // 放入 .title 内部,logo 右边 const titleEl = homeLogo.querySelector(".title"); if (titleEl) { titleEl.appendChild(toggleBtn); } else { homeLogo.appendChild(toggleBtn); } return toggleBtn; } // ========== 侧边栏宽度控制 ========== function getMinSidebarWidth() { return MIN_WIDTH; } function getSidebarElement() { return document.querySelector("#d-sidebar") || document.querySelector(".sidebar-container"); } function applySidebarWidth(width) { const clampedWidth = Math.min(MAX_WIDTH, Math.max(getMinSidebarWidth(), width)); sfpSidebarWidth = clampedWidth; const sidebar = getSidebarElement(); if (sidebar) { sidebar.style.setProperty("width", clampedWidth + "px", "important"); } document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px"); } function getSidebarWidthTransitionElements(sidebar) { const wrapper = sidebar?.classList?.contains("sidebar-wrapper") ? sidebar : sidebar?.closest?.(".sidebar-wrapper"); return [sidebar, wrapper].filter(Boolean); } function setSidebarWidthForAnimation(sidebar, width, { enforceMin = true } = {}) { const minWidth = enforceMin ? getMinSidebarWidth() : 0; const clampedWidth = Math.min(MAX_WIDTH, Math.max(minWidth, width)); sidebar.style.setProperty("width", clampedWidth + "px", "important"); document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px"); } function animateSidebarWidth(targetWidth, { cleanupAfter = false, enforceMin = true } = {}) { const sidebar = getSidebarElement(); if (!sidebar) return; if (widthAnimationTimer) { window.clearTimeout(widthAnimationTimer); widthAnimationTimer = null; } const startWidth = sidebar.getBoundingClientRect().width || DEFAULT_WIDTH; const minWidth = enforceMin ? getMinSidebarWidth() : 0; const clampedTarget = Math.min(MAX_WIDTH, Math.max(minWidth, targetWidth)); const transitionEls = getSidebarWidthTransitionElements(sidebar); setSidebarWidthForAnimation(sidebar, startWidth, { enforceMin: false }); transitionEls.forEach((el) => el.classList.add("sfp-width-animating")); window.requestAnimationFrame(() => { setSidebarWidthForAnimation(sidebar, clampedTarget, { enforceMin }); widthAnimationTimer = window.setTimeout(() => { widthAnimationTimer = null; transitionEls.forEach((el) => el.classList.remove("sfp-width-animating")); if (cleanupAfter) { restoreSidebarWidth(); } }, 260); }); } function restoreSidebarWidth() { const sidebar = getSidebarElement(); if (sidebar) { sidebar.style.removeProperty("width"); } document.documentElement.style.removeProperty("--d-sidebar-width"); } function setupResizer() { const sidebar = getSidebarElement(); if (!sidebar) return; if (resizerEl && !sidebar.contains(resizerEl)) { resizerEl.remove(); resizerEl = null; } if (resizerEl) return; resizerEl = sidebar.querySelector(":scope > .sfp-resizer") || document.createElement("div"); resizerEl.className = "sfp-resizer"; if (!resizerEl.parentElement) { sidebar.appendChild(resizerEl); } resizerEl.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); isResizing = true; const startX = e.clientX; const startWidth = sidebar.offsetWidth; resizerEl.classList.add("sfp-resizing"); getSidebarWidthTransitionElements(sidebar).forEach((el) => el.classList.remove("sfp-width-animating")); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; const onMouseMove = (e) => { if (!isResizing) return; const delta = e.clientX - startX; const newWidth = Math.min(MAX_WIDTH, Math.max(getMinSidebarWidth(), startWidth + delta)); applySidebarWidth(newWidth); }; const onMouseUp = () => { isResizing = false; resizerEl.classList.remove("sfp-resizing"); document.body.style.cursor = ""; document.body.style.userSelect = ""; GM_setValue(WIDTH_KEY, sfpSidebarWidth); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); } function removeResizer() { if (resizerEl) { resizerEl.remove(); resizerEl = null; } const sidebar = getSidebarElement(); sidebar?.querySelectorAll(":scope > .sfp-resizer").forEach((el) => el.remove()); } // ========== 激活 / 停用 ========== function activateFeed() { const sidebar = getSidebarElement(); if (!sidebar) return; if (!sidebar.classList.contains("sfp-feed-mode") || originalSidebarWidthBeforeFeed === null) { originalSidebarWidthBeforeFeed = sidebar.getBoundingClientRect().width || DEFAULT_WIDTH; } if (feedContainer && sidebar.contains(feedContainer)) { sidebar.classList.add("sfp-feed-mode"); animateSidebarWidth(sfpSidebarWidth); setupResizer(); _syncDefaultViewControls(); _updateShowMoreHint(); _updateBackTopButton(); return; } if (feedContainer) { _resetRefreshButtonBusy(); if (feedScrollAbortController) { feedScrollAbortController.abort(); feedScrollAbortController = null; } feedContainer.remove(); feedContainer = null; feedHeaderEl = null; feedRefreshBtn = null; feedScrollEl = null; feedListEl = null; feedBackTopBtn = null; } // 创建 feed 容器 feedContainer = document.createElement("div"); feedContainer.className = "sfp-feed-container"; feedHeaderEl = document.createElement("div"); feedHeaderEl.className = "sfp-feed-header"; _buildHeaderControls(feedHeaderEl); // 分类标签栏 const tabBar = _buildTabBar(); feedContainer.appendChild(feedHeaderEl); feedContainer.appendChild(tabBar); // 筛选栏 const filterBar = _buildFilterBar(); feedContainer.appendChild(filterBar); feedScrollEl = document.createElement("div"); feedScrollEl.className = "sfp-feed-scroll"; // 创建内容包装器,用于相对定位 const contentWrapper = document.createElement("div"); contentWrapper.className = "sfp-content-wrapper"; feedListEl = document.createElement("div"); feedListEl.className = "sfp-topic-list"; contentWrapper.appendChild(feedListEl); feedScrollEl.appendChild(contentWrapper); feedContainer.appendChild(feedScrollEl); feedBackTopBtn = _buildBackTopButton(); feedContainer.appendChild(feedBackTopBtn); sidebar.appendChild(feedContainer); sidebar.classList.add("sfp-feed-mode"); animateSidebarWidth(sfpSidebarWidth); setupResizer(); // 恢复当前 tab 筛选的分类 _restoreTabState(); _startSidebarIncomingTracking(); _syncDefaultViewControls(); // 始终全量加载,数据已在 deactivateFeed 中清除 loadTopics(); // 无限滚动 _setupScrollLoadMore(); } function deactivateFeed() { const sidebar = getSidebarElement(); if (!sidebar) return; activeLoadToken++; activeLoadMoreToken++; activeRefreshToken++; isLoading = false; isLoadingMore = false; isRefreshing = false; _pendingReload = false; sidebarIncomingState.applyQueued = false; if (feedScrollAbortController) { feedScrollAbortController.abort(); feedScrollAbortController = null; } _stopAutoRefresh(); _stopAutoSilentRefresh(); _stopSidebarIncomingTracking(); _resetRefreshButtonBusy(); if (feedContainer) { feedContainer.remove(); feedContainer = null; feedHeaderEl = null; feedRefreshBtn = null; feedScrollEl = null; feedListEl = null; feedBackTopBtn = null; } // 清除数据缓存,避免下次激活时显示旧数据 allTopics = []; usersMap = {}; loadedTopicIds.clear(); currentPage = 0; hasMorePages = true; _resetAutoLoadState(); sidebar.classList.remove("sfp-feed-mode"); removeResizer(); animateSidebarWidth(originalSidebarWidthBeforeFeed || DEFAULT_WIDTH, { cleanupAfter: true, enforceMin: false }); originalSidebarWidthBeforeFeed = null; } // ========== Header 控件 ========== function _buildHeaderControls(header) { // Order 自定义下拉 const orderOptions = [ { label: "最新活动", value: "activity" }, { label: "最新发布", value: "created" }, { label: "最多浏览", value: "views" }, { label: "最多回复", value: "posts" }, { label: "最多点赞", value: "likes" }, { label: "楼主点赞", value: "op_likes" }, ]; const periodOptions = [ { label: "全部", value: "all" }, { label: "每日", value: "daily" }, { label: "每周", value: "weekly" }, { label: "每月", value: "monthly" }, { label: "每季", value: "quarterly" }, { label: "每年", value: "yearly" }, ]; // Period 下拉(先创建,因为 order 切换时需要引用) const periodSelect = _buildCustomSelect(periodOptions, currentPeriod, (value) => { currentPeriod = value; GM_setValue(PERIOD_KEY, currentPeriod); _resetAutoLoadState(); loadTopics(); }); periodSelect.classList.add("sfp-period-select"); _updatePeriodVisibility(periodSelect); // Order 下拉 const orderSelect = _buildCustomSelect(orderOptions, currentOrder, (value) => { currentOrder = value; GM_setValue(ORDER_KEY, currentOrder); _updatePeriodVisibility(periodSelect); _beginSidebarIncomingViewSettling(); _syncDefaultViewControls(); _resetAutoLoadState(); loadTopics(); }); orderSelect.classList.add("sfp-order-select"); function _updatePeriodVisibility(ps) { ps.style.display = _needsPeriodForUrl(currentOrder) ? "" : "none"; } header.appendChild(orderSelect); header.appendChild(periodSelect); const spacer = document.createElement("span"); spacer.className = "sfp-header-spacer"; header.appendChild(spacer); header.appendChild(_buildSettingsControl()); // 刷新按钮 const refreshBtn = document.createElement("button"); refreshBtn.className = "sfp-refresh-btn"; refreshBtn.title = "刷新"; refreshBtn.setAttribute("aria-label", "刷新"); refreshBtn.innerHTML = ``; refreshBtn.addEventListener("click", () => { refreshCurrentView(); }); feedRefreshBtn = refreshBtn; _syncRefreshButtonBusy(); header.appendChild(refreshBtn); } function _buildSettingsControl() { const wrapper = document.createElement("span"); wrapper.className = "sfp-settings-wrap"; const isLatestActivityView = _isLatestActivityView(); const shell = document.createElement("span"); shell.className = "sfp-settings-shell"; const btn = document.createElement("button"); btn.className = "sfp-settings-btn"; btn.type = "button"; btn.title = "设置"; btn.innerHTML = ` `; const panel = document.createElement("div"); panel.className = "sfp-settings-panel"; // 设置项按当前排序视图分组,而不是一次性展示全部选项: // - 最新活动可以消费 message-bus 增量,因此提供“新活动提醒”和“静默刷新”; // - 浏览量/回复/点赞等排序没有同等可靠的增量通道,只提供普通自动刷新。 // 这样可以减少用户误以为所有排序都能无请求地接收新话题。 if (isLatestActivityView) { panel.innerHTML = `
${_buildSettingLabelHtml("新活动提醒", "在最新活动的全部列表中,按站点推送的新话题显示顶部提醒;点击提醒才把新内容加入列表。开启后会关闭自动静默刷新。")}
${_buildSettingLabelHtml("自动静默刷新", "在最新活动视图中按间隔自动应用新话题,尽量保留当前阅读位置。仅在关闭新活动提醒后可用;它只处理已收到的新活动候选,无速率限制,可按需要设置。")}
${_buildSettingLabelHtml("静默刷新间隔", "单位为秒,最小为 0。设为 0 时,有新活动会立即静默应用;大于 0 时按倒计时批量应用。")} s
`; } else { panel.innerHTML = `
${_buildSettingLabelHtml("自动刷新", "用于非最新活动的排序视图,按间隔重新拉取当前列表,并尽量保留当前阅读位置。不要设置太快,频繁请求可能触发站点速率限制。")}
${_buildSettingLabelHtml("自动刷新间隔", "单位为秒,最小为 1。到达间隔后刷新当前筛选和排序下的列表。")} s
`; } btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const shouldOpen = !wrapper.classList.contains("open"); _closeFloatingPanels(wrapper); wrapper.classList.toggle("open", shouldOpen); _syncSettingsPanelState(wrapper); }); panel.addEventListener("click", (e) => e.stopPropagation()); panel.querySelectorAll(".sfp-setting-help").forEach((helpBtn) => { const showTooltip = () => { if (!globalHelpTooltip) return; const text = helpBtn.getAttribute("data-tooltip"); if (!text) return; globalHelpTooltip.textContent = text; globalHelpTooltip.style.display = ""; globalHelpTooltip.style.visibility = "hidden"; globalHelpTooltip.classList.add("visible"); const btnRect = helpBtn.getBoundingClientRect(); const gap = 6; const tooltipHeight = globalHelpTooltip.offsetHeight; const tooltipWidth = globalHelpTooltip.offsetWidth; let left = btnRect.left; let top = btnRect.bottom + gap; if (left + tooltipWidth > window.innerWidth - 8) { left = Math.max(8, btnRect.right - tooltipWidth); } if (top + tooltipHeight > window.innerHeight - 8) { top = btnRect.top - tooltipHeight - gap; } globalHelpTooltip.style.left = left + "px"; globalHelpTooltip.style.top = top + "px"; globalHelpTooltip.style.visibility = ""; globalHelpTooltip.classList.remove("visible"); requestAnimationFrame(() => { requestAnimationFrame(() => { globalHelpTooltip.classList.add("visible"); }); }); }; const hideTooltip = () => { if (globalHelpTooltip) { globalHelpTooltip.classList.remove("visible"); globalHelpTooltip.style.display = "none"; } }; helpBtn.addEventListener("mouseenter", showTooltip); helpBtn.addEventListener("mouseleave", hideTooltip); helpBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); }); }); const incomingHintInput = panel.querySelector(".sfp-incoming-hint-input"); if (incomingHintInput) { _bindCheckboxSetting(incomingHintInput, (checked) => { showIncomingHint = checked; GM_setValue(SHOW_INCOMING_HINT_KEY, showIncomingHint); // 手动提醒和自动静默刷新是互斥体验:前者让用户决定何时插入新话题, // 后者由脚本自动合并。互斥可以避免同一批 incoming 同时出现在提醒和 // 静默队列里,导致重复刷新或顶部提示残留。 if (showIncomingHint) { _stopAutoSilentRefresh(); sidebarIncomingState.applyQueued = false; } else { _startAutoSilentRefresh(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } } _syncSettingsPanelState(wrapper); _updateShowMoreHint(); }); } const autoSilentInput = panel.querySelector(".sfp-auto-silent-input"); const silentIntervalInput = panel.querySelector(".sfp-auto-silent-refresh-interval-input"); if (autoSilentInput) { _bindCheckboxSetting(autoSilentInput, (checked) => { autoSilentRefreshEnabled = checked; GM_setValue(AUTO_SILENT_REFRESH_KEY, autoSilentRefreshEnabled); // 开启静默刷新时主动关闭新活动提醒,保持上面的互斥关系。设置面板随后 // 会重新计算可见行高度,避免隐藏间隔输入后留下空白。 if (autoSilentRefreshEnabled && showIncomingHint) { showIncomingHint = false; GM_setValue(SHOW_INCOMING_HINT_KEY, false); } _syncSettingsPanelState(wrapper); _startAutoSilentRefresh(); _updateShowMoreHint(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } }); } _bindNumberSetting(silentIntervalInput, 0, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL, (seconds) => { autoSilentRefreshInterval = seconds; GM_setValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, autoSilentRefreshInterval); _startAutoSilentRefresh(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } }); const autoRefreshInput = panel.querySelector(".sfp-auto-refresh-input"); const intervalRow = panel.querySelector(".sfp-auto-refresh-interval"); const intervalInput = panel.querySelector(".sfp-auto-refresh-interval-input"); if (autoRefreshInput) { _bindCheckboxSetting(autoRefreshInput, (checked) => { autoRefreshEnabled = checked; GM_setValue(AUTO_REFRESH_ENABLED_KEY, autoRefreshEnabled); intervalRow.classList.toggle("visible", autoRefreshEnabled); _syncSettingsPanelHeight(wrapper); _startAutoRefresh(); }); } _bindNumberSetting(intervalInput, 1, DEFAULT_AUTO_REFRESH_INTERVAL, (seconds) => { autoRefreshInterval = seconds; GM_setValue(AUTO_REFRESH_INTERVAL_KEY, autoRefreshInterval); _startAutoRefresh(); }); shell.appendChild(btn); shell.appendChild(panel); wrapper.appendChild(shell); _syncSettingsPanelState(wrapper); return wrapper; } function _buildSettingLabelHtml(label, tooltip) { return ` ${escapeHtml(label)} `; } function _syncSettingsPanelState(wrapper) { const panel = wrapper?.querySelector(".sfp-settings-panel"); if (!panel) return; const incomingHintInput = panel.querySelector(".sfp-incoming-hint-input"); const autoSilentInput = panel.querySelector(".sfp-auto-silent-input"); const autoSilentRow = panel.querySelector(".sfp-auto-silent-row"); const autoSilentIntervalRow = panel.querySelector(".sfp-auto-silent-interval"); const autoRefreshInput = panel.querySelector(".sfp-auto-refresh-input"); const autoRefreshIntervalRow = panel.querySelector(".sfp-auto-refresh-interval"); if (incomingHintInput) incomingHintInput.checked = showIncomingHint; if (autoSilentInput) autoSilentInput.checked = autoSilentRefreshEnabled; if (autoRefreshInput) autoRefreshInput.checked = autoRefreshEnabled; // “自动静默刷新”只有在关闭“新活动提醒”后才显示;这不是权限限制, // 而是为了让用户明确选择“手动应用”或“自动应用”其中一种 incoming 处理方式。 if (autoSilentRow) { autoSilentRow.classList.toggle("hidden", showIncomingHint); } if (autoSilentIntervalRow) { autoSilentIntervalRow.classList.toggle("hidden", showIncomingHint); autoSilentIntervalRow.classList.toggle("visible", !showIncomingHint && autoSilentRefreshEnabled); } if (autoRefreshIntervalRow) { autoRefreshIntervalRow.classList.toggle("visible", autoRefreshEnabled); } _syncSettingsPanelHeight(wrapper); } function _syncSettingsPanelHeight(wrapper) { const shell = wrapper?.querySelector(".sfp-settings-shell"); const panel = wrapper?.querySelector(".sfp-settings-panel"); if (!shell || !panel) return; // 设置面板是绝对定位浮层,但外层 shell 需要参与 header 布局。 // 每次显示/隐藏行后重新测量实际可见内容高度,避免动画期间按钮区域被截断。 requestAnimationFrame(() => { const visibleRows = Array.from(panel.children).filter((child) => { return child instanceof HTMLElement && getComputedStyle(child).display !== "none"; }); const contentBottom = visibleRows.reduce((bottom, row) => { return Math.max(bottom, row.offsetTop + row.offsetHeight); }, SETTINGS_BUTTON_SIZE); const panelStyle = getComputedStyle(panel); const paddingBottom = Number.parseFloat(panelStyle.paddingBottom) || 0; const height = Math.max(SETTINGS_BUTTON_SIZE, Math.ceil(contentBottom + paddingBottom)); shell.style.setProperty("--sfp-settings-shell-height", `${height}px`); }); } function _bindCheckboxSetting(input, onChange) { if (!input) return; input.addEventListener("change", () => onChange(input.checked)); } function _bindNumberSetting(input, min, fallback, onChange) { if (!input) return; input.addEventListener("change", () => { const nextValue = Math.max(min, Number(input.value) || fallback); input.value = nextValue; onChange(nextValue); }); } function _beginRefreshButtonBusy() { refreshBusyCount++; _syncRefreshButtonBusy(); let ended = false; // Call the returned function exactly once when the async refresh path settles. return () => { if (ended) return; ended = true; refreshBusyCount = Math.max(0, refreshBusyCount - 1); _syncRefreshButtonBusy(); }; } function _syncRefreshButtonBusy() { if (!feedRefreshBtn) return; const isBusy = refreshBusyCount > 0; feedRefreshBtn.classList.toggle("spinning", isBusy); feedRefreshBtn.setAttribute("aria-busy", isBusy ? "true" : "false"); } function _resetRefreshButtonBusy() { refreshBusyCount = 0; _syncRefreshButtonBusy(); } // ========== 自定义下拉组件 ========== function _buildCustomSelect(options, selectedValue, onChange) { const wrapper = document.createElement("span"); wrapper.className = "sfp-custom-select"; const btn = document.createElement("button"); btn.className = "sfp-custom-select-btn"; btn.type = "button"; const selected = options.find((o) => o.value === selectedValue) || options[0]; btn.textContent = selected.label; const dropdown = document.createElement("div"); dropdown.className = "sfp-custom-select-dropdown"; let _currentSelected = selectedValue; options.forEach((opt) => { const item = document.createElement("button"); item.className = "sfp-custom-select-option" + (opt.value === selectedValue ? " selected" : ""); item.type = "button"; item.textContent = opt.label; item.addEventListener("click", (e) => { e.stopPropagation(); if (opt.value === _currentSelected) { wrapper.classList.remove("open"); return; } btn.textContent = opt.label; dropdown.querySelectorAll(".sfp-custom-select-option").forEach((el) => el.classList.remove("selected")); item.classList.add("selected"); wrapper.classList.remove("open"); _currentSelected = opt.value; onChange(opt.value); }); dropdown.appendChild(item); }); btn.addEventListener("click", (e) => { e.stopPropagation(); const shouldOpen = !wrapper.classList.contains("open"); _closeFloatingPanels(wrapper); wrapper.classList.toggle("open", shouldOpen); const isOpen = shouldOpen; if (isOpen) { const btnRect = btn.getBoundingClientRect(); dropdown.style.top = (btnRect.bottom + 4) + "px"; dropdown.style.left = btnRect.left + "px"; dropdown.style.minWidth = btnRect.width + "px"; } }); wrapper.appendChild(btn); wrapper.appendChild(dropdown); return wrapper; } // 点击页面其他地方关闭下拉 document.addEventListener("click", () => { _closeFloatingPanels(); }); // ========== 分类标签栏 ========== function _buildTabBar() { const shell = document.createElement("div"); shell.className = "sfp-tab-shell"; const bar = document.createElement("div"); bar.className = "sfp-tab-bar"; const moreBtn = document.createElement("button"); moreBtn.type = "button"; moreBtn.className = "sfp-tab-more-btn"; moreBtn.title = "展开板块 / 排序"; moreBtn.setAttribute("aria-label", "展开板块 / 排序"); moreBtn.innerHTML = ``; const panel = document.createElement("div"); panel.className = "sfp-tab-panel"; panel.innerHTML = `
拖动板块调整顺序
`; const grid = panel.querySelector(".sfp-tab-grid"); // "全部" 标签 const allTab = document.createElement("span"); allTab.className = "sfp-tab-item" + (currentTab === "all" ? " active" : ""); allTab.dataset.tab = "all"; allTab.innerHTML = `全部`; bar.appendChild(allTab); const allGridItem = document.createElement("span"); allGridItem.className = "sfp-tab-grid-item" + (currentTab === "all" ? " active" : ""); allGridItem.dataset.tab = "all"; allGridItem.innerHTML = allTab.innerHTML; grid.appendChild(allGridItem); // 各板块标签 _getOrderedTabCategories().forEach((cat) => { const tab = document.createElement("span"); tab.className = "sfp-tab-item" + (currentTab === cat.tabId ? " active" : ""); tab.dataset.tab = cat.tabId; tab.dataset.categoryId = cat.id; tab.innerHTML = _buildCategoryTabContent(cat); bar.appendChild(tab); const gridItem = document.createElement("span"); gridItem.className = "sfp-tab-grid-item" + (currentTab === cat.tabId ? " active" : ""); gridItem.draggable = true; gridItem.dataset.tab = cat.tabId; gridItem.dataset.categoryId = cat.id; gridItem.title = getCategoryTabMeta(cat).name; gridItem.innerHTML = _buildCategoryTabContent(cat); grid.appendChild(gridItem); }); // 事件代理 — 点击切换 shell.addEventListener("click", (e) => { e.stopPropagation(); _closeFloatingPanels(shell); const closeBtn = e.target.closest(".sfp-tab-panel-close"); if (closeBtn) { e.stopPropagation(); shell.classList.remove("open"); return; } if (e.target.closest(".sfp-tab-more-btn")) { e.stopPropagation(); shell.classList.toggle("open", !shell.classList.contains("open")); return; } const tab = e.target.closest(".sfp-tab-item, .sfp-tab-grid-item"); if (!tab) return; const tabId = tab.dataset.tab; const catId = tab.dataset.categoryId ? Number(tab.dataset.categoryId) : null; const fromGrid = tab.classList.contains("sfp-tab-grid-item"); if (tabId === currentTab && catId === currentCategoryId) { if (fromGrid) { shell.classList.remove("open"); _scrollTabIntoView(shell, tabId, "smooth"); } return; } currentTab = tabId; currentCategoryId = catId; GM_setValue(TAB_KEY, currentTab); _beginSidebarIncomingViewSettling(); _syncDefaultViewControls(); _resetAutoLoadState(); shell.querySelectorAll(".sfp-tab-item, .sfp-tab-grid-item").forEach((t) => { t.classList.remove("active"); }); shell.querySelectorAll(`[data-tab="${_cssEscape(tabId)}"]`).forEach((t) => t.classList.add("active")); if (fromGrid) { pendingTabBarScrollTab = tabId; shell.classList.remove("open"); _scrollTabIntoView(shell, tabId, "smooth"); } loadTopics(); }); moreBtn.addEventListener("mousedown", (e) => e.preventDefault()); let dragItem = null; let tabOrderChanged = false; grid.addEventListener("dragstart", (e) => { const item = e.target.closest(".sfp-tab-grid-item[data-category-id]"); if (!item) return; dragItem = item; tabOrderChanged = false; item.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", item.dataset.categoryId); }); grid.addEventListener("dragover", (e) => { if (!dragItem) return; const target = e.target.closest(".sfp-tab-grid-item[data-category-id]"); if (!target || target === dragItem) return; e.preventDefault(); grid.querySelectorAll(".drop-target").forEach((el) => el.classList.remove("drop-target")); target.classList.add("drop-target"); const rect = target.getBoundingClientRect(); const before = e.clientY < rect.top + rect.height / 2; const nextNode = before ? target : target.nextSibling; if (dragItem !== nextNode) { grid.insertBefore(dragItem, nextNode); tabOrderChanged = true; } }); grid.addEventListener("drop", (e) => { if (!dragItem) return; e.preventDefault(); }); grid.addEventListener("dragend", () => { if (dragItem) dragItem.classList.remove("dragging"); grid.querySelectorAll(".drop-target").forEach((el) => el.classList.remove("drop-target")); if (tabOrderChanged) { _saveTabOrderFromGrid(grid); _rerenderTabBar(shell, { keepOpen: true }); } dragItem = null; tabOrderChanged = false; }); // 滚轮横向滚动 bar.addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.preventDefault(); bar.scrollLeft += e.deltaY; } }); shell.appendChild(bar); shell.appendChild(moreBtn); shell.appendChild(panel); return shell; } function _rerenderTabBar(oldShell, options = {}) { if (!oldShell?.parentNode) return null; const oldBar = oldShell.querySelector(".sfp-tab-bar"); const scrollLeft = oldBar?.scrollLeft || 0; const keepOpen = options.keepOpen ?? oldShell.classList.contains("open"); const newShell = _buildTabBar(); if (keepOpen) newShell.classList.add("open"); oldShell.replaceWith(newShell); const newBar = newShell.querySelector(".sfp-tab-bar"); if (options.scrollTabId) { requestAnimationFrame(() => { _scrollTabIntoView(newShell, options.scrollTabId, options.scrollBehavior || "auto"); }); } else if (newBar) { newBar.scrollLeft = scrollLeft; } return newShell; } // ========== 筛选栏 ========== function _buildFilterBar() { const bar = document.createElement("div"); bar.className = "sfp-filter-bar"; const filters = [ { label: "全部", value: "all" }, { label: "未读", value: "unseen" }, { label: "已读", value: "read" }, ]; filters.forEach((f) => { const item = document.createElement("span"); item.className = "sfp-filter-item" + (currentFilter === f.value ? " active" : ""); item.dataset.filter = f.value; item.textContent = f.label; bar.appendChild(item); }); // 分隔 const sep = document.createElement("span"); sep.style.cssText = "color:var(--primary-low,#ddd);margin:0 2px;"; sep.textContent = "|"; bar.appendChild(sep); // 隐藏置顶开关 const pinnedToggle = document.createElement("span"); pinnedToggle.className = "sfp-filter-item" + (hidePinned ? " active" : ""); pinnedToggle.dataset.filter = "hide-pinned"; pinnedToggle.textContent = "隐藏置顶"; bar.appendChild(pinnedToggle); bar.addEventListener("click", (e) => { const item = e.target.closest(".sfp-filter-item"); if (!item) return; const filterVal = item.dataset.filter; if (filterVal === "hide-pinned") { hidePinned = !hidePinned; GM_setValue(HIDE_PINNED_KEY, hidePinned); item.classList.toggle("active", hidePinned); _resetAutoLoadState(); renderTopics(); return; } else { if (filterVal === currentFilter) return; currentFilter = filterVal; GM_setValue(FILTER_KEY, currentFilter); _beginSidebarIncomingViewSettling(); _syncDefaultViewControls(); _resetAutoLoadState(); bar.querySelectorAll(".sfp-filter-item[data-filter]:not([data-filter=\"hide-pinned\"])").forEach((i) => i.classList.remove("active")); item.classList.add("active"); renderTopics(); _finishSidebarIncomingViewSettling(); } }); return bar; } // ========== 恢复标签栏状态 ========== function _restoreTabState() { if (currentTab === "all") { currentCategoryId = null; return; } const cat = TAB_CATEGORIES.find((c) => c.tabId === currentTab); if (cat) { currentCategoryId = cat.id; } else { currentTab = "all"; currentCategoryId = null; } } function _refreshCategoryTabs() { const shell = feedContainer?.querySelector(".sfp-tab-shell"); if (!shell) return; const scrollTabId = pendingTabBarScrollTab; pendingTabBarScrollTab = null; _rerenderTabBar(shell, { scrollTabId, scrollBehavior: scrollTabId ? "smooth" : "auto" }); } function _removeShowMoreHint() { const contentWrapper = feedScrollEl?.querySelector(".sfp-content-wrapper"); if (!contentWrapper) return; contentWrapper.querySelector(".sfp-show-more-overlay")?.remove(); contentWrapper.classList.remove("sfp-has-show-more"); } function _beginSidebarIncomingViewSettling() { sidebarIncomingState.viewSettling = true; sidebarIncomingState.filterStable = false; sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filterRefreshToken++; if (sidebarIncomingState.filterRefreshTimer) { clearTimeout(sidebarIncomingState.filterRefreshTimer); sidebarIncomingState.filterRefreshTimer = null; } _removeShowMoreHint(); } function _finishSidebarIncomingViewSettling() { sidebarIncomingState.viewSettling = false; sidebarIncomingState.filterStable = false; _recomputeSidebarIncomingFilteredTopicIds(); _scheduleSidebarIncomingFilterRefresh(); _updateShowMoreHint(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } } function _updateShowMoreHint({ skipIncomingFilterRefresh = false } = {}) { if (!feedScrollEl) return; const contentWrapper = feedScrollEl.querySelector(".sfp-content-wrapper"); if (!contentWrapper) return; // Discourse 原生 show-more 支持 latest 及分类 latest 列表;这里仅用 // message-bus payload 做板块范围计数,避免提醒前拉取话题详情。 // 0 秒静默刷新会立即应用新话题,不需要显示手动提醒;有效间隔会批量应用, // 间隔期间保留数量提示。 if ( sidebarIncomingState.viewSettling || isLoading || !_canShowSidebarIncomingHint() || (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) ) { _removeShowMoreHint(); return; } if (!sidebarIncomingState.filterStable) { return; } if (!skipIncomingFilterRefresh) { _scheduleSidebarIncomingFilterRefresh(); } // 提醒条只显示已经通过当前板块范围过滤的候选数量。未读/已读这种依赖完整 // 话题字段的筛选会在点击应用时再次确认,避免仅凭 message-bus payload 误判。 const existing = contentWrapper.querySelector(".sfp-show-more-overlay"); const newCount = sidebarIncomingState.filteredTopicIds.length; if (newCount <= 0) { if (existing) existing.remove(); contentWrapper.classList.remove("sfp-has-show-more"); return; } const overlay = existing || document.createElement("div"); overlay.className = "show-more has-topics sfp-show-more-overlay"; let hint = overlay.querySelector(".sfp-hint-text"); if (!hint) { hint = document.createElement("a"); hint.className = "sfp-hint-text alert alert-info clickable"; hint.href = "#"; hint.addEventListener("click", async (e) => { e.preventDefault(); if (hint.classList.contains("loading")) return; _setShowMoreHintLoading(hint, true); try { await _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "show more" }); } finally { _setShowMoreHintLoading(hint, false); } }); overlay.appendChild(hint); } let label = hint.querySelector(".sfp-hint-label"); if (!label) { label = document.createElement("span"); label.className = "sfp-hint-label"; hint.appendChild(label); } label.textContent = `查看 ${newCount} 个新的或更新的话题`; contentWrapper.classList.add("sfp-has-show-more"); if (!existing) { contentWrapper.insertBefore(overlay, contentWrapper.firstChild); } } function _setShowMoreHintLoading(hint, loading) { hint.classList.toggle("loading", loading); hint.setAttribute("aria-busy", loading ? "true" : "false"); hint.setAttribute("aria-disabled", loading ? "true" : "false"); const existingSpinner = hint.querySelector(".sfp-hint-spinner-custom"); if (loading) { if (!existingSpinner) { const spinner = document.createElement("div"); spinner.className = "sfp-hint-spinner-custom"; hint.appendChild(spinner); } } else if (existingSpinner) { existingSpinner.remove(); } } function _isLatestActivityView(query = FeedQuery.snapshot()) { return query.order === "activity"; } function _canUseSidebarIncomingRefresh(query = FeedQuery.snapshot()) { return _isLatestActivityView(query); } function _canShowSidebarIncomingHint(query = FeedQuery.snapshot()) { return showIncomingHint && _canUseSidebarIncomingRefresh(query) && query.filter === "all"; } function _isAutoSilentRefreshActive(query = FeedQuery.snapshot()) { return autoSilentRefreshEnabled && !showIncomingHint && _canUseSidebarIncomingRefresh(query); } function _topicMatchesCategoryScope(topic, query = FeedQuery.snapshot()) { if (query.tab === "all" || !query.categoryId) return true; let categoryId = Number(topic?.category_id); const targetCategoryId = Number(query.categoryId); if (!Number.isFinite(categoryId) || !Number.isFinite(targetCategoryId)) return false; while (Number.isFinite(categoryId)) { if (categoryId === targetCategoryId) return true; const parentId = Number(_getCategoryMeta(categoryId)?.parent_category_id); if (!Number.isFinite(parentId) || parentId === categoryId) break; categoryId = parentId; } return false; } function _topicMatchesLocalFilter(topic, query = FeedQuery.snapshot()) { if (query.filter === "unseen") return _hasUnreadMarker(topic); if (query.filter === "read") return !_hasUnreadMarker(topic); return true; } function _topicMatchesIncomingView(topic, query = FeedQuery.snapshot()) { return _topicMatchesIncomingCandidate(topic, query) && _topicMatchesLocalFilter(topic, query); } function _topicMatchesIncomingCandidate(topic, query = FeedQuery.snapshot()) { return _canUseSidebarIncomingRefresh(query) && _topicMatchesCategoryScope(topic, query); } function _recomputeSidebarIncomingFilteredTopicIds(query = FeedQuery.snapshot()) { if (!_canUseSidebarIncomingRefresh(query)) { sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filterStable = false; return sidebarIncomingState.filteredTopicIds; } // 这里故意只用 incoming candidate 条件,不套完整本地筛选。 // cache 里的 payload 可能缺少 last_read_post_number/new_posts 等字段; // 完整筛选留给 _applySidebarIncomingTopics 拉取详情后处理。 sidebarIncomingState.filteredTopicIds = sidebarIncomingState.topicIds.filter((id) => { const topic = sidebarIncomingState.topicCache.get(Number(id)); return topic && _topicMatchesIncomingCandidate(topic, query); }); return sidebarIncomingState.filteredTopicIds; } function _scheduleSidebarIncomingFilterRefresh() { if (sidebarIncomingState.viewSettling || isLoading || isRefreshing) return; if (!_canUseSidebarIncomingRefresh() || sidebarIncomingState.topicIds.length === 0) return; if (sidebarIncomingState.filterRefreshTimer) return; sidebarIncomingState.filterRefreshTimer = setTimeout(() => { sidebarIncomingState.filterRefreshTimer = null; _refreshSidebarIncomingFilter().catch((e) => { console.warn("[SFP] incoming filter refresh error:", e); }); }, 150); } async function _refreshSidebarIncomingFilter() { const requestQuery = FeedQuery.snapshot(); if (sidebarIncomingState.viewSettling || isLoading || isRefreshing) { return sidebarIncomingState.filteredTopicIds; } if (!_canUseSidebarIncomingRefresh(requestQuery)) { _recomputeSidebarIncomingFilteredTopicIds(requestQuery); _updateShowMoreHint({ skipIncomingFilterRefresh: true }); return []; } const incomingTopicIds = sidebarIncomingState.topicIds.slice(); const token = ++sidebarIncomingState.filterRefreshToken; if (incomingTopicIds.length === 0) { sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filterStable = true; _updateShowMoreHint({ skipIncomingFilterRefresh: true }); return []; } await loadCategoryMetadata(); if (token !== sidebarIncomingState.filterRefreshToken || !FeedQuery.isCurrent(requestQuery)) return sidebarIncomingState.filteredTopicIds; _recomputeSidebarIncomingFilteredTopicIds(requestQuery); sidebarIncomingState.filterStable = true; _updateShowMoreHint({ skipIncomingFilterRefresh: true }); return sidebarIncomingState.filteredTopicIds; } function _syncDefaultViewControls() { _recomputeSidebarIncomingFilteredTopicIds(); _updateSettingsControl(); _updateShowMoreHint(); _startAutoSilentRefresh(); _startAutoRefresh(); _scheduleSidebarIncomingFilterRefresh(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0 && sidebarIncomingState.topicIds.length > 0) { _queueSidebarIncomingApply(); } } function _updateSettingsControl() { if (!feedHeaderEl) return; const oldSettings = feedHeaderEl.querySelector(".sfp-settings-wrap"); if (!oldSettings) return; const hasLatestActivityPanel = !!oldSettings.querySelector(".sfp-incoming-hint-row"); if (hasLatestActivityPanel === _isLatestActivityView()) { _syncSettingsPanelState(oldSettings); return; } const isOpen = oldSettings.classList.contains("open"); const nextSettings = _buildSettingsControl(); if (isOpen) nextSettings.classList.add("open"); oldSettings.replaceWith(nextSettings); } function _startSidebarIncomingTracking() { if (sidebarLatestMessageBusCallback || sidebarNewMessageBusCallback) return; const messageBus = getMessageBus(); if (!messageBus?.subscribe) { console.warn("[SFP] message-bus service unavailable; incoming topics will not auto-update"); return; } sidebarMessageBus = messageBus; sidebarLatestMessageBusCallback = (data) => _handleSidebarIncomingMessage(data); sidebarNewMessageBusCallback = (data) => _handleSidebarIncomingMessage(data); // 同时订阅 /latest 和 /new:Discourse 在不同列表和站点配置下可能通过 // 其中任一频道广播新话题或最新活动。lastId 使用当前 bus 实例读取, // 避免重新订阅时误用已经清掉的全局 sidebarMessageBus。 messageBus.subscribe("/latest", sidebarLatestMessageBusCallback, _getMessageBusLastId(messageBus, "/latest")); messageBus.subscribe("/new", sidebarNewMessageBusCallback, _getMessageBusLastId(messageBus, "/new")); } function _getMessageBusLastId(messageBus, channel) { const candidates = [ messageBus?.lastId?.(channel), messageBus?.lastIdForChannel?.(channel), messageBus?.lastIds?.[channel], messageBus?.last_ids?.[channel], messageBus?.channels?.[channel]?.lastId, ]; for (const candidate of candidates) { const lastId = Number(candidate); if (Number.isFinite(lastId)) return lastId; } return -1; } function _stopSidebarIncomingTracking() { if (sidebarMessageBus?.unsubscribe) { if (sidebarLatestMessageBusCallback) { sidebarMessageBus.unsubscribe("/latest", sidebarLatestMessageBusCallback); } if (sidebarNewMessageBusCallback) { sidebarMessageBus.unsubscribe("/new", sidebarNewMessageBusCallback); } } sidebarMessageBus = null; sidebarLatestMessageBusCallback = null; sidebarNewMessageBusCallback = null; sidebarIncomingState.applyQueued = false; if (sidebarIncomingState.filterRefreshTimer) { clearTimeout(sidebarIncomingState.filterRefreshTimer); sidebarIncomingState.filterRefreshTimer = null; } } function _handleSidebarIncomingMessage(data) { if (!data || !["latest", "new_topic"].includes(data.message_type)) return; if (!data.topic_id) return; if (data.payload?.archetype && data.payload.archetype !== "regular") return; // 推送 payload 不一定包含完整话题字段,但通常足够判断 id、分类和 archetype。 // 先记录候选,真正插入列表前再拉完整数据,避免把不完整 payload 直接渲染。 _addSidebarIncomingTopicId(data.topic_id); if (data.payload) { sidebarIncomingState.topicCache.set(Number(data.topic_id), { ...(sidebarIncomingState.topicCache.get(Number(data.topic_id)) || {}), ...data.payload, id: Number(data.topic_id), }); } sidebarIncomingState.filterStable = false; if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } else { if (_canUseSidebarIncomingRefresh()) { _recomputeSidebarIncomingFilteredTopicIds(); sidebarIncomingState.filterStable = true; _updateShowMoreHint({ skipIncomingFilterRefresh: true }); } } } function _addSidebarIncomingTopicId(topicId) { const numericId = Number(topicId); if (!Number.isFinite(numericId) || sidebarIncomingState.topicIdSet.has(numericId)) return; sidebarIncomingState.topicIdSet.add(numericId); sidebarIncomingState.topicIds.push(numericId); sidebarIncomingState.filterStable = false; } function _removeSidebarIncomingTopicIds(topicIds) { if (!topicIds?.length) return; const toRemove = new Set(topicIds.map((id) => Number(id)).filter(Number.isFinite)); if (toRemove.size === 0) return; sidebarIncomingState.topicIds = sidebarIncomingState.topicIds.filter((id) => !toRemove.has(Number(id))); sidebarIncomingState.topicIdSet = new Set(sidebarIncomingState.topicIds); toRemove.forEach((id) => sidebarIncomingState.topicCache.delete(Number(id))); _recomputeSidebarIncomingFilteredTopicIds(); } function _queueSidebarIncomingApply() { if (sidebarIncomingState.viewSettling) return; if (!_canUseSidebarIncomingRefresh()) return; if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) return; if (sidebarIncomingState.applyQueued) return; // 0 秒静默刷新表示“有 incoming 就尽快应用”。这里排入 microtask, // 让同一轮 message-bus 回调中的多个 topic id 先合并,再发起一次批量请求。 sidebarIncomingState.applyQueued = true; Promise.resolve().then(async () => { sidebarIncomingState.applyQueued = false; if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) return; await _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "auto silent refresh", preserveViewport: true }); }); } function _flushQueuedSidebarIncomingApply() { if (!sidebarIncomingState.applyQueued) return; if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) { sidebarIncomingState.applyQueued = false; return; } sidebarIncomingState.applyQueued = false; _queueSidebarIncomingApply(); } function _getAutoLoadSessionKey() { return FeedQuery.key(FeedQuery.snapshot()); } function _resetAutoLoadState() { autoLoadTimestamps = []; autoLoadEmptyFilterCount = 0; autoLoadStoppedForSession = false; autoLoadSessionKey = _getAutoLoadSessionKey(); } function _ensureAutoLoadSession() { const nextKey = _getAutoLoadSessionKey(); if (nextKey !== autoLoadSessionKey) { _resetAutoLoadState(); } } function _canRunAutoLoad() { _ensureAutoLoadSession(); if (autoLoadStoppedForSession) return false; const now = Date.now(); autoLoadTimestamps = autoLoadTimestamps.filter((ts) => now - ts < AUTO_LOAD_RATE_WINDOW_MS); return autoLoadTimestamps.length < AUTO_LOAD_MAX_REQUESTS_PER_WINDOW; } function _recordAutoLoadRequest() { _ensureAutoLoadSession(); autoLoadTimestamps.push(Date.now()); } function _recordAutoLoadFilterResult(filteredNewCount) { _ensureAutoLoadSession(); if (filteredNewCount > 0) { autoLoadEmptyFilterCount = 0; return; } autoLoadEmptyFilterCount++; if (autoLoadEmptyFilterCount >= AUTO_LOAD_MAX_EMPTY_FILTER_RESULTS) { autoLoadStoppedForSession = true; } } const FeedQuery = { snapshot() { return { tab: currentTab, categoryId: currentCategoryId, order: currentOrder, period: currentPeriod, filter: currentFilter, hidePinned, }; }, key(query) { // 查询 key 覆盖所有会影响 API URL 或本地筛选的状态。异步请求返回时用 // isCurrent 比较 key,丢弃用户切换视图前发出的旧响应。 return [ query.tab, query.categoryId || "", query.order, query.period, query.filter, query.hidePinned ? "hide-pinned" : "show-pinned", ].join("|"); }, isCurrent(query) { return this.key(query) === this.key(this.snapshot()); }, buildUrl(query, page) { const useTopList = _usesPeriodScopedTopList(query.order, query.period); // Discourse 的 top 周期只在 /top.json 或分类 /l/top.json 上有完整语义。 // period=all 的“最多浏览/回复/点赞”等排序继续走 latest.json?order=..., // 保留此前版本的 ranked order 行为。 if (query.tab !== "all" && query.categoryId) { const cat = CATEGORY_CONFIG[query.categoryId]; const tabId = cat?.tabId || query.tab; const params = [`page=${page}`]; if (useTopList) { params.push(`period=${encodeURIComponent(query.period)}`); params.push(`order=${encodeURIComponent(query.order)}`); return `/c/${tabId}/${query.categoryId}/l/top.json?${params.join("&")}`; } params.push(`order=${encodeURIComponent(query.order)}`); return `/c/${tabId}/${query.categoryId}/l/latest.json?${params.join("&")}`; } if (useTopList) { const params = [ `period=${encodeURIComponent(query.period)}`, `order=${encodeURIComponent(query.order)}`, `page=${page}`, ]; return `/top.json?${params.join("&")}`; } const params = [ `order=${encodeURIComponent(query.order)}`, `page=${page}`, ]; if (query.period !== "all" && _needsPeriodForUrl(query.order)) { params.push(`period=${encodeURIComponent(query.period)}`); } return `/latest.json?${params.join("&")}`; }, }; // ========== 数据加载 ========== async function fetchFeedTopics(query, page) { const url = FeedQuery.buildUrl(query, page); const csrfToken = getCsrfToken(); const headers = { "X-CSRF-Token": csrfToken }; const resp = await fetch(url, { headers }); if (!resp.ok) throw new Error(`API error: ${resp.status}`); return resp.json(); } async function fetchFeedTopicsByIds(topicIds) { const ids = Array.from(new Set(topicIds.map((id) => Number(id)).filter(Number.isFinite))); if (ids.length === 0) return null; const csrfToken = getCsrfToken(); const headers = { "X-CSRF-Token": csrfToken }; const resp = await fetch(`/latest.json?topic_ids=${ids.join(",")}`, { headers }); if (!resp.ok) throw new Error(`API error: ${resp.status}`); return resp.json(); } function _needsPeriodForUrl(order) { return ["views", "posts", "likes", "op_likes"].includes(order); } function _usesPeriodScopedTopList(order, period) { return period !== "all" && _needsPeriodForUrl(order); } function _startTagStyleIndexLoad() { if (tagStyleLoaded || tagStylePromise) return; loadTagStyleIndex().then(() => { if (feedModeEnabled && feedListEl && allTopics.length > 0) { renderTopics(); } }); } async function loadTopics() { if (isLoading) { _pendingReload = true; return; } const requestQuery = FeedQuery.snapshot(); const requestToken = ++activeLoadToken; activeLoadMoreToken++; activeRefreshToken++; _beginSidebarIncomingViewSettling(); isLoading = true; isLoadingMore = false; isRefreshing = false; _pendingReload = false; currentPage = 0; hasMorePages = true; allTopics = []; loadedTopicIds.clear(); _updateShowMoreHint(); _resetAutoLoadState(); if (feedListEl) { feedListEl.innerHTML = `
加载中...
`; } _updateBackTopButton(); try { _startTagStyleIndexLoad(); await loadCategoryMetadata(); if (requestToken !== activeLoadToken || !FeedQuery.isCurrent(requestQuery)) return; _refreshCategoryTabs(); const data = await fetchFeedTopics(requestQuery, 0); if (requestToken !== activeLoadToken || !FeedQuery.isCurrent(requestQuery)) return; _processUsers(data); if (data?.topic_list?.topics) { const topics = data.topic_list.topics; topics.forEach((t) => loadedTopicIds.add(t.id)); allTopics = topics; hasMorePages = !!data.topic_list.more_topics_url; renderTopics(); _removeSidebarIncomingTopicIds(topics.map((topic) => topic.id)); _updateShowMoreHint(); } else { if (feedListEl) feedListEl.innerHTML = `
暂无话题
`; hasMorePages = false; } _startAutoRefresh(); } catch (e) { if (requestToken !== activeLoadToken || !FeedQuery.isCurrent(requestQuery)) return; console.error("[SFP] loadTopics error:", e); if (feedListEl) { feedListEl.innerHTML = `
⚠️
加载失败
${escapeHtml(e.message)}
`; feedListEl.querySelector(".sfp-retry-btn")?.addEventListener("click", () => loadTopics()); } } finally { if (requestToken === activeLoadToken) { isLoading = false; _flushQueuedSidebarIncomingApply(); if (_pendingReload) { _pendingReload = false; loadTopics(); } else { _finishSidebarIncomingViewSettling(); } } } } async function loadMoreTopics({ source = "manual" } = {}) { if (isLoading || isLoadingMore || !hasMorePages) return; const isAutoLoad = source === "auto"; if (isAutoLoad && !_canRunAutoLoad()) return; const requestQuery = FeedQuery.snapshot(); const requestToken = ++activeLoadMoreToken; const nextPage = currentPage + 1; isLoadingMore = true; if (isAutoLoad) _recordAutoLoadRequest(); _showLoadMoreSpinner(); try { await loadCategoryMetadata(); if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return; const data = await fetchFeedTopics(requestQuery, nextPage); if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return; _processUsers(data); if (data?.topic_list?.topics) { const topics = data.topic_list.topics; currentPage = nextPage; const newTopics = topics.filter((t) => { if (loadedTopicIds.has(t.id)) return false; loadedTopicIds.add(t.id); return true; }); hasMorePages = !!data.topic_list.more_topics_url; if (newTopics.length === 0) { if (hasMorePages) { _renderPaginationFooter({ note: !isAutoLoad ? "下一页无符合条件的话题" : "", }); } else { _showNoMore(); } } else { allTopics = allTopics.concat(newTopics); // 增量追加,应用当前筛选,保留滚动位置 const filteredNew = _applyFilter(newTopics); filteredNew.forEach((topic) => { const item = createTopicItem(topic); feedListEl.appendChild(item); }); if (isAutoLoad) _recordAutoLoadFilterResult(filteredNew.length); _renderPaginationFooter({ note: !isAutoLoad && filteredNew.length === 0 ? "下一页无符合条件的话题" : "", }); } } else { hasMorePages = false; _showNoMore(); } } catch (e) { if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return; console.error("[SFP] loadMoreTopics error:", e); _showLoadMoreError(e); } finally { if (requestToken === activeLoadMoreToken) { isLoadingMore = false; _flushQueuedSidebarIncomingApply(); } } } function _processUsers(data) { if (data?.users) { data.users.forEach((u) => { usersMap[u.id] = u; }); } } async function refreshCurrentView() { return _refreshCurrentView({ logPrefix: "manual refresh", }); } function _captureFeedScrollAnchor() { if (!feedScrollEl || !feedListEl) return null; if (feedScrollEl.scrollTop <= 1) return null; const scrollRect = feedScrollEl.getBoundingClientRect(); const items = feedListEl.querySelectorAll(".sfp-topic-item[data-topic-id]"); for (const item of items) { const itemRect = item.getBoundingClientRect(); if (itemRect.bottom > scrollRect.top + 1) { return { topicId: item.dataset.topicId, offsetTop: itemRect.top - scrollRect.top, scrollTop: feedScrollEl.scrollTop, scrollHeight: feedScrollEl.scrollHeight, }; } } return { topicId: null, offsetTop: 0, scrollTop: feedScrollEl.scrollTop, scrollHeight: feedScrollEl.scrollHeight, }; } function _restoreFeedScrollAnchor(anchor) { if (!anchor || !feedScrollEl || !feedListEl) return; const restore = () => { if (!feedScrollEl || !feedListEl) return; if (anchor.topicId) { const item = feedListEl.querySelector(`.sfp-topic-item[data-topic-id="${anchor.topicId}"]`); if (item) { const scrollRect = feedScrollEl.getBoundingClientRect(); const itemRect = item.getBoundingClientRect(); feedScrollEl.scrollTop += itemRect.top - scrollRect.top - anchor.offsetTop; _updateBackTopButton(); return; } } feedScrollEl.scrollTop = anchor.scrollTop + (feedScrollEl.scrollHeight - anchor.scrollHeight); _updateBackTopButton(); }; restore(); requestAnimationFrame(restore); } function _mergeAndRenderTopics(fetchedTopics, { mode = "prepend", moreTopicsUrl = "", incomingCandidateIds = [], filterTopic = null, preserveViewport = false, } = {}) { const shouldFilterTopics = mode !== "replace-head" && typeof filterTopic === "function"; const topics = shouldFilterTopics ? fetchedTopics.filter(filterTopic) : fetchedTopics; if (topics.length === 0) { if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds); _updateShowMoreHint(); return false; } const topicMap = new Map(topics.map((topic) => [topic.id, topic])); let highlightTopicIds = []; if (mode === "replace-head") { // 手动刷新或普通自动刷新拿到的是列表头部,应该同步 more_topics_url; // incoming prepend 只是在现有列表前插入候选话题,不能据此重置分页状态。 highlightTopicIds = topics .filter((topic) => !loadedTopicIds.has(topic.id) || sidebarIncomingState.topicIdSet.has(Number(topic.id))) .map((topic) => topic.id); allTopics = topics.concat(allTopics.filter((topic) => !topicMap.has(topic.id))); hasMorePages = !!moreTopicsUrl; } else { highlightTopicIds = topics.map((topic) => topic.id); allTopics = topics.concat(allTopics.filter((topic) => !topicMap.has(topic.id))); } topics.forEach((topic) => loadedTopicIds.add(topic.id)); const scrollAnchor = _captureFeedScrollAnchor(); renderTopics(highlightTopicIds, { preserveProtected: preserveViewport }); if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds); _updateShowMoreHint(); _restoreFeedScrollAnchor(scrollAnchor); return true; } async function _refreshCurrentView({ requireDefaultView = false, logPrefix = "refresh", preserveViewport = false } = {}) { if (isLoading || isLoadingMore || isRefreshing) return false; if (requireDefaultView && !_canUseSidebarIncomingRefresh()) return false; if (!feedListEl) return false; const requestQuery = FeedQuery.snapshot(); const requestToken = ++activeRefreshToken; const endRefreshBusy = _beginRefreshButtonBusy(); isRefreshing = true; try { await loadCategoryMetadata(); if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return false; const data = await fetchFeedTopics(requestQuery, 0); if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return false; _processUsers(data); if (!data?.topic_list?.topics) return false; return _mergeAndRenderTopics(data.topic_list.topics, { mode: "replace-head", moreTopicsUrl: data.topic_list.more_topics_url, incomingCandidateIds: data.topic_list.topics.map((topic) => topic.id), preserveViewport, }); } catch (e) { console.warn(`[SFP] ${logPrefix} error:`, e); return false; } finally { if (requestToken === activeRefreshToken) { isRefreshing = false; _resetAutoRefreshCountdown(); _flushQueuedSidebarIncomingApply(); } endRefreshBusy(); } } // ========== 静默刷新 ========== // 仅在 latest/latest category 语义可匹配的最新活动视图中按 incoming 事件启用。 // 这条路径不重新拉整页,而是按 message-bus 收集到的 topic id 批量取详情, // 然后插入到当前列表顶部;这样能减少刷新时对阅读位置的扰动。 async function _applySidebarIncomingTopics({ requireDefaultView = false, logPrefix = "incoming", queueIfBusy = true, preserveViewport = false } = {}) { if (sidebarIncomingState.viewSettling || isLoading || isLoadingMore || isRefreshing) { if (queueIfBusy) sidebarIncomingState.applyQueued = true; return; } if (requireDefaultView && !_canUseSidebarIncomingRefresh()) return; if (!feedListEl) return; const endRefreshBusy = _beginRefreshButtonBusy(); let requestToken = null; try { const incomingTopicIds = (await _refreshSidebarIncomingFilter()).slice(); if (incomingTopicIds.length === 0) { _updateShowMoreHint(); return; } if (isLoading || isLoadingMore || isRefreshing) { if (queueIfBusy) sidebarIncomingState.applyQueued = true; return; } const requestQuery = FeedQuery.snapshot(); requestToken = ++activeRefreshToken; isRefreshing = true; await loadCategoryMetadata(); if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return; const data = await fetchFeedTopicsByIds(incomingTopicIds); if (requestToken !== activeRefreshToken || !FeedQuery.isCurrent(requestQuery)) return; if (!data?.topic_list?.topics) return; _processUsers(data); _mergeAndRenderTopics(data.topic_list.topics, { mode: "prepend", incomingCandidateIds: incomingTopicIds, filterTopic: (topic) => _topicMatchesIncomingView(topic, requestQuery), preserveViewport, }); } catch (e) { console.warn(`[SFP] ${logPrefix} error:`, e); } finally { if (requestToken === activeRefreshToken) { isRefreshing = false; _flushQueuedSidebarIncomingApply(); } endRefreshBusy(); } } function _startAutoSilentRefresh() { _stopAutoSilentRefresh(); if (!_isAutoSilentRefreshActive()) return; if (autoSilentRefreshInterval <= 0) return; // 大于 0 的静默刷新按倒计时批量应用 incoming。0 秒场景由 // _queueSidebarIncomingApply 处理,避免同时存在 interval 和 microtask 两条触发链。 _resetAutoSilentRefreshCountdown(); autoSilentRefreshTimer = setInterval(() => { autoSilentRefreshSeconds--; if (autoSilentRefreshSeconds <= 0) { _resetAutoSilentRefreshCountdown(); if (feedModeEnabled && !isLoading && !isLoadingMore && !isRefreshing) { if (_canUseSidebarIncomingRefresh()) { _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "auto silent refresh interval", queueIfBusy: false, preserveViewport: true, }); } else { _refreshCurrentView({ logPrefix: "auto silent refresh interval", preserveViewport: true, }); } } } }, 1000); } function _resetAutoSilentRefreshCountdown() { if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval > 0) { autoSilentRefreshSeconds = autoSilentRefreshInterval; } } function _stopAutoSilentRefresh() { if (autoSilentRefreshTimer) { clearInterval(autoSilentRefreshTimer); autoSilentRefreshTimer = null; } } // ========== 自动刷新 ========== function _startAutoRefresh() { _stopAutoRefresh(); if (_isLatestActivityView()) return; if (!autoRefreshEnabled) return; // 非最新活动排序没有可靠 incoming 增量,只能按当前查询重新拉取列表头部。 // refreshCurrentView 会保存滚动锚点,尽量避免自动刷新把正在看的内容挤走。 _resetAutoRefreshCountdown(); autoRefreshTimer = setInterval(() => { autoRefreshSeconds--; if (autoRefreshSeconds <= 0) { _resetAutoRefreshCountdown(); if (feedModeEnabled && !isLoading && !isLoadingMore) { _refreshCurrentView({ logPrefix: "auto refresh", preserveViewport: true }); } } }, 1000); } function _resetAutoRefreshCountdown() { if (autoRefreshEnabled) { autoRefreshSeconds = autoRefreshInterval; } } function _stopAutoRefresh() { if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } } function _getVisibleOrHoveredTopicItems() { if (!feedScrollEl || !feedListEl) return []; if (feedScrollEl.scrollTop <= 1) return []; const scrollRect = feedScrollEl.getBoundingClientRect(); const protectedItems = []; const protectedIds = new Set(); feedListEl.querySelectorAll(".sfp-topic-item[data-topic-id]").forEach((item) => { const itemRect = item.getBoundingClientRect(); const isVisible = itemRect.bottom > scrollRect.top && itemRect.top < scrollRect.bottom; const isHovered = item.matches(":hover"); if (!isVisible && !isHovered) return; const topicId = Number(item.dataset.topicId); if (!Number.isFinite(topicId) || protectedIds.has(topicId)) return; protectedIds.add(topicId); protectedItems.push({ topicId, element: item }); }); return protectedItems; } function _triggerTopicHighlight(item) { if (!item) return; const oldTimer = topicHighlightTimers.get(item); if (oldTimer) clearTimeout(oldTimer); item.classList.remove("sfp-new-highlight"); void item.offsetWidth; item.classList.add("sfp-new-highlight"); const timer = setTimeout(() => { item.classList.remove("sfp-new-highlight"); topicHighlightTimers.delete(item); }, 10000); topicHighlightTimers.set(item, timer); } function _topicStatsHtml(topic) { const replies = Math.max(0, (topic.posts_count || 1) - 1); const views = topic.views >= 1000 ? (topic.views / 1000).toFixed(1) + "k" : (topic.views || 0); const likes = topic.like_count || 0; return ` ${_svgIcon("comment")} ${replies} ${_svgIcon("far-eye")} ${views} ${_svgIcon("heart")} ${likes} `; } function _topicStatusBadgesHtml(topic) { const statusBadges = []; if (topic.is_hot) { statusBadges.push('

热门

'); } if (topic.pinned || topic.pinned_globally) { statusBadges.push('

已置顶

'); } return statusBadges.length ? `${statusBadges.join("")}` : ""; } function _topicTimeHtml(topic) { const timeStr = formatRelativeTime(topic.bumped_at || topic.last_posted_at || topic.created_at); const unreadDotHtml = _hasUnreadMarker(topic) ? '' : ""; return `${timeStr}${unreadDotHtml}`; } function _isTopicExplicitlyUnavailable(topic) { return !!topic && ( topic.deleted_at || topic.deleted || topic.hidden || topic.visible === false ); } function _patchProtectedTopicItem(item, topic, { isNew = false, filterMatched = true } = {}) { if (!item || !topic) return; item.classList.toggle("sfp-pinned", !!(topic.pinned || topic.pinned_globally)); item.classList.toggle("sfp-read", _isTopicRead(topic)); item.classList.toggle("sfp-filter-mismatch", !filterMatched || _isTopicExplicitlyUnavailable(topic)); item.classList.toggle("sfp-topic-unavailable", _isTopicExplicitlyUnavailable(topic)); const header = item.querySelector(".sfp-topic-header"); const time = item.querySelector(".sfp-topic-time"); if (time) time.innerHTML = _topicTimeHtml(topic); const badgesHtml = _topicStatusBadgesHtml(topic); const existingBadges = item.querySelector(".sfp-topic-status-badges"); if (badgesHtml) { if (existingBadges) { existingBadges.outerHTML = badgesHtml; } else if (header && time) { time.insertAdjacentHTML("beforebegin", badgesHtml); } } else if (existingBadges) { existingBadges.remove(); } const stats = item.querySelector(".sfp-topic-stats"); if (stats) stats.innerHTML = _topicStatsHtml(topic); if (isNew) _triggerTopicHighlight(item); } function _renderTopicsPreservingProtected(newTopicIds = []) { if (!feedListEl) return false; const protectedItems = _getVisibleOrHoveredTopicItems(); if (protectedItems.length === 0) return false; const newTopicIdSet = new Set(newTopicIds.map((id) => Number(id)).filter(Number.isFinite)); const protectedIds = new Set(protectedItems.map(({ topicId }) => topicId)); const filtered = _applyFilter(allTopics); const filteredIds = new Set(filtered.map((topic) => Number(topic.id))); const topicById = new Map(allTopics.map((topic) => [Number(topic.id), topic])); const nonProtectedTopics = filtered.filter((topic) => !protectedIds.has(Number(topic.id))); let nextNonProtectedIndex = 0; const currentItems = Array.from(feedListEl.querySelectorAll(".sfp-topic-item[data-topic-id]")); _removePaginationFooter(); currentItems.forEach((oldItem) => { const oldTopicId = Number(oldItem.dataset.topicId); if (protectedIds.has(oldTopicId)) { const topic = topicById.get(oldTopicId); if (topic) { _patchProtectedTopicItem(oldItem, topic, { isNew: newTopicIdSet.has(oldTopicId), filterMatched: filteredIds.has(oldTopicId), }); } return; } if (nextNonProtectedIndex >= nonProtectedTopics.length) { oldItem.remove(); return; } const topic = nonProtectedTopics[nextNonProtectedIndex++]; const topicId = Number(topic.id); oldItem.replaceWith(createTopicItem(topic, newTopicIdSet.has(topicId))); }); while (nextNonProtectedIndex < nonProtectedTopics.length) { const topic = nonProtectedTopics[nextNonProtectedIndex++]; const topicId = Number(topic.id); feedListEl.appendChild(createTopicItem(topic, newTopicIdSet.has(topicId))); } _renderPaginationFooter(); _updateBackTopButton(); return true; } // ========== 渲染 ========== function renderTopics(newTopicIds = [], { preserveProtected = false } = {}) { if (!feedListEl) return; if (preserveProtected && _renderTopicsPreservingProtected(newTopicIds)) return; feedListEl.innerHTML = ""; if (allTopics.length === 0) { feedListEl.innerHTML = `
暂无话题
`; _updateBackTopButton(); return; } // 客户端筛选 let filtered = _applyFilter(allTopics); if (filtered.length === 0) { // 构建更精确的空状态消息 let emptyMsg = "无匹配话题"; if (currentFilter === "unseen") { emptyMsg = currentTab !== "all" ? "该板块暂无未读话题" : "暂无未读话题"; } else if (currentFilter === "read") { emptyMsg = "暂无已读话题"; } // 有数据但筛选后为空 → 显示当前页提示,分页控件由底部统一渲染 if (hasMorePages && !isLoadingMore) { feedListEl.innerHTML = `
当前页${emptyMsg}
`; } else { feedListEl.innerHTML = `
${emptyMsg}
`; } } else { filtered.forEach((topic) => { const item = createTopicItem(topic, newTopicIds.includes(topic.id)); feedListEl.appendChild(item); }); } _renderPaginationFooter(); _updateBackTopButton(); } // ========== 话题 URL / 已读状态 / 客户端筛选 ========== function _topicBaseUrl(topic) { const slug = topic.slug || "topic"; return `/t/${slug}/${topic.id}`; } function _hasLastReadPostNumber(topic) { return topic.last_read_post_number !== null && topic.last_read_post_number !== undefined && topic.last_read_post_number !== ""; } function _topicListUrl(topic) { const baseUrl = _topicBaseUrl(topic); if (!_hasLastReadPostNumber(topic)) return baseUrl; const lastRead = Number(topic.last_read_post_number); const highest = Number(topic.highest_post_number); if (!Number.isFinite(lastRead)) return baseUrl; let postNumber = lastRead + 1; if (Number.isFinite(highest) && postNumber > highest) { postNumber = highest; } if (postNumber < 1) postNumber = 1; return `${baseUrl}/${postNumber}`; } // Discourse topic 列表的已读信号不稳定:优先保留楼层号语义,再用 API 字段兜底。 function _isTopicRead(topic) { if (!topic || !topic.id) return false; if (_hasLastReadPostNumber(topic)) { const baseUrl = _topicBaseUrl(topic); const url = _topicListUrl(topic); return url !== baseUrl && url.startsWith(`${baseUrl}/`); } if (topic.unseen === true) return false; if (Number(topic.new_posts) > 0 || Number(topic.unread_posts) > 0) return false; if (topic.is_seen === true || topic.unseen === false) return true; if (Number(topic.new_posts) === 0 && Number(topic.unread_posts) === 0) return true; return false; } function _hasUnreadMarker(topic) { return !_isTopicRead(topic); } function _applyReadMarker(topic) { topic.unread_posts = 0; topic.new_posts = 0; topic.unseen = false; topic.is_seen = true; if (topic.highest_post_number) { topic.last_read_post_number = topic.highest_post_number; } } function markTopicAsRead(topic, itemElement) { if (!_hasUnreadMarker(topic)) return; _applyReadMarker(topic); const existing = allTopics.find((t) => t.id === topic.id); if (existing && existing !== topic) _applyReadMarker(existing); itemElement.classList.add("sfp-read"); const dot = itemElement.querySelector(".sfp-unread-dot"); if (dot) dot.remove(); } // 未读/已读筛选复用 _isTopicRead,避免和渲染、点击后本地 patch 的语义分叉。 function _applyFilter(topics) { if (!hidePinned && currentFilter === "all") return topics; let result = topics; if (hidePinned) { result = result.filter((t) => !t.pinned && !t.pinned_globally); } if (currentFilter === "unseen") { result = result.filter((t) => _hasUnreadMarker(t)); } if (currentFilter === "read") { result = result.filter((t) => !_hasUnreadMarker(t)); } return result; } function _buildCategoryBadge(categoryId) { const meta = _getCategoryMeta(categoryId); if (!meta?.name) return ""; const styleParts = [ `--category-badge-color: #${_normalizeHexColor(meta.color, "888")}`, `--category-badge-text-color: #${_normalizeHexColor(meta.text_color, "FFFFFF")}`, ]; if (meta.parent_category_id && meta.parent_color) { styleParts.push(`--parent-category-badge-color: #${_normalizeHexColor(meta.parent_color, "888")}`); styleParts.push(`--parent-category-badge-text-color: #${_normalizeHexColor(meta.parent_text_color, "FFFFFF")}`); } const styleType = _safeCategoryStyleType(meta.style_type, !!meta.icon); const categoryClasses = ["badge-category"]; if (meta.read_restricted) categoryClasses.push("restricted"); if (meta.parent_category_id) categoryClasses.push("--has-parent"); categoryClasses.push(`--style-${styleType}`); const dataParent = meta.parent_category_id ? ` data-parent-category-id="${Number(meta.parent_category_id)}"` : ""; const title = meta.description_text || meta.description_excerpt || ""; const titleAttr = title ? ` title="${escapeAttr(title)}"` : ""; const iconHtml = styleType === "icon" && meta.icon ? _svgIcon(meta.icon) : ""; const lockHtml = meta.read_restricted ? _svgIcon("lock") : ""; return `${iconHtml}${lockHtml}${escapeHtml(meta.name)}`; } function _buildTagBadge(tag) { const tagName = _tagDisplayName(tag); if (!tagName) return ""; const tagStyle = _getTagStyle(tag); const classes = ["discourse-tag", "box", "sfp-tag"]; if (tagStyle?.hasIcon) classes.push("discourse-tag--tag-icons-style"); const styleAttr = tagStyle?.cssText ? ` style="${tagStyle.cssText}"` : ""; const iconHtml = tagStyle?.hasIcon ? `${_svgIcon(tagStyle.icon)}` : ""; return `${iconHtml}${escapeHtml(tagName)}`; } // ========== 创建帖子项 ========== function createTopicItem(topic, isNew = false) { const item = document.createElement("div"); item.className = "sfp-topic-item"; item.dataset.topicId = topic.id; if (topic.pinned || topic.pinned_globally) { item.classList.add("sfp-pinned"); } if (_isTopicRead(topic)) { item.classList.add("sfp-read"); } if (isNew) { _triggerTopicHighlight(item); } // 获取用户信息 let avatarUrl = ""; let name = ""; let username = ""; if (topic.posters && topic.posters.length > 0) { const userId = topic.posters[0].user_id; const user = usersMap[userId]; if (user) { name = user.name || ""; username = user.username || ""; if (user.avatar_template) { avatarUrl = getAvatarUrl(user.avatar_template, 45); } } } // 头像 HTML const avatarHtml = avatarUrl ? `${escapeHtml(username)}` : ""; // 显示名称 const displayName = name && name !== username ? `${escapeHtml(name)}` : ""; const statusBadgesHtml = _topicStatusBadgesHtml(topic); // 标题 const closedHtml = topic.closed ? '' : ""; // 分类 const categoryHtml = _buildCategoryBadge(topic.category_id); // 标签 let tagsHtml = ""; if (topic.tags && topic.tags.length > 0) { const tagItems = topic.tags.slice(0, 3).map((tag) => { return _buildTagBadge(tag); }).join(""); tagsHtml = `${tagItems}`; } item.innerHTML = `
${avatarHtml}
${statusBadgesHtml} ${_topicTimeHtml(topic)}
${closedHtml}${escapeHtml(topic.unicode_title?.trim() || topic.title)}
${categoryHtml} ${tagsHtml}
${_topicStatsHtml(topic)}
`; // 点击跳转 item.addEventListener("click", (e) => { if (e.button !== 0) return; const targetUrl = _topicListUrl(topic); markTopicAsRead(topic, item); navigateTo(targetUrl); }); // 中键新标签页 item.addEventListener("auxclick", (e) => { if (e.button === 1) { e.preventDefault(); const targetUrl = _topicListUrl(topic); markTopicAsRead(topic, item); window.open(toAbsoluteSiteUrl(targetUrl), "_blank"); } }); return item; } // ========== 回到顶部 ========== function _buildBackTopButton() { const btn = document.createElement("button"); btn.className = "sfp-back-top-btn"; btn.type = "button"; btn.title = "回到顶部"; btn.setAttribute("aria-label", "回到顶部"); btn.innerHTML = ``; btn.addEventListener("click", () => { if (!feedScrollEl) return; feedScrollEl.scrollTo({ top: 0, behavior: "smooth" }); _updateBackTopButton(); }); return btn; } function _updateBackTopButton() { if (!feedBackTopBtn || !feedScrollEl) return; const show = feedScrollEl.scrollTop > feedScrollEl.clientHeight; feedBackTopBtn.classList.toggle("visible", show); } // ========== 无限滚动加载 ========== function _setupScrollLoadMore() { if (!feedScrollEl) return; if (feedScrollAbortController) feedScrollAbortController.abort(); feedScrollAbortController = typeof AbortController === "function" ? new AbortController() : null; const listenerOptions = feedScrollAbortController ? { passive: false, signal: feedScrollAbortController.signal } : { passive: false }; const passiveListenerOptions = feedScrollAbortController ? { passive: true, signal: feedScrollAbortController.signal } : { passive: true }; feedScrollEl.addEventListener("wheel", (e) => { if (!feedScrollEl || e.deltaY === 0) return; const { scrollTop, scrollHeight, clientHeight } = feedScrollEl; const canScroll = scrollHeight > clientHeight; const atTop = scrollTop <= 0; const atBottom = Math.ceil(scrollTop + clientHeight) >= scrollHeight; const scrollingUp = e.deltaY < 0; const scrollingDown = e.deltaY > 0; if (!canScroll || (scrollingUp && atTop) || (scrollingDown && atBottom)) { e.preventDefault(); e.stopPropagation(); } }, listenerOptions); feedScrollEl.addEventListener("scroll", _updateBackTopButton, passiveListenerOptions); feedScrollEl.addEventListener("scroll", debounce(() => { if (!feedScrollEl || !hasMorePages || isLoadingMore) return; const { scrollTop, scrollHeight, clientHeight } = feedScrollEl; if (scrollHeight - scrollTop - clientHeight < 200) { loadMoreTopics({ source: "auto" }); } }, 300), passiveListenerOptions); } // ========== 加载更多辅助 ========== function _renderPaginationFooter({ note = "" } = {}) { if (!feedListEl) return; _removePaginationFooter(); if (note) { const noteEl = document.createElement("div"); noteEl.className = "sfp-load-more-note"; noteEl.textContent = note; feedListEl.appendChild(noteEl); } if (hasMorePages) { const loadMoreEl = document.createElement("div"); loadMoreEl.className = "sfp-load-more"; loadMoreEl.textContent = "加载更多"; loadMoreEl.addEventListener("click", () => { loadMoreEl.remove(); loadMoreTopics({ source: "manual" }); }); feedListEl.appendChild(loadMoreEl); return; } _appendNoMore(); } function _showLoadMoreSpinner() { _removePaginationFooter(); const el = document.createElement("div"); el.className = "sfp-load-more"; el.innerHTML = `加载中...`; if (feedListEl) feedListEl.appendChild(el); } function _removePaginationFooter() { feedListEl?.querySelectorAll(".sfp-load-more, .sfp-no-more, .sfp-load-more-note").forEach((el) => el.remove()); } function _showNoMore() { _removePaginationFooter(); _appendNoMore(); } function _appendNoMore() { const el = document.createElement("div"); el.className = "sfp-no-more"; el.textContent = "— 已经到底了 —"; if (feedListEl) feedListEl.appendChild(el); } function _showLoadMoreError(error) { _removePaginationFooter(); const el = document.createElement("div"); el.className = "sfp-load-more sfp-load-more-error"; el.innerHTML = ` 请求失败 `; el.querySelector(".sfp-load-more-retry")?.addEventListener("click", () => { loadMoreTopics({ source: "manual" }); }); if (feedListEl) feedListEl.appendChild(el); console.warn("[SFP] load more failed:", error); } // ========== RouteWatcher(轻量,仅感知路由,不重建 feed) ========== const RouteWatcher = (() => { let lastUrl = location.href; let observer = null; function start() { const origPush = history.pushState; history.pushState = function () { origPush.apply(this, arguments); _checkUrlChange(); }; const origReplace = history.replaceState; history.replaceState = function () { origReplace.apply(this, arguments); _checkUrlChange(); }; window.addEventListener("popstate", () => _checkUrlChange()); observer = new MutationObserver(() => { if (location.href !== lastUrl) { _checkUrlChange(); } if (feedModeEnabled) { const sidebar = getSidebarElement(); const resizerMissing = !resizerEl || !sidebar?.contains(resizerEl); if (sidebar && (!feedContainer || !sidebar.contains(feedContainer) || !sidebar.classList.contains("sfp-feed-mode") || resizerMissing)) { activateFeed(); } } }); const target = document.querySelector("#main-outlet") || document.querySelector(".d-header") || document.body; observer.observe(target, { childList: true, subtree: true }); } function _checkUrlChange() { const newUrl = location.href; if (newUrl !== lastUrl) { lastUrl = newUrl; if (feedModeEnabled) { _updateShowMoreHint(); } } } function stop() { if (observer) observer.disconnect(); } return { start, stop }; })(); // ========== 初始化 ========== let globalHelpTooltip = null; function init() { injectStyles(); globalHelpTooltip = document.createElement("div"); globalHelpTooltip.className = "sfp-help-tooltip"; globalHelpTooltip.setAttribute("role", "tooltip"); globalHelpTooltip.style.display = "none"; document.body.appendChild(globalHelpTooltip); waitForEmber(() => { createToggle(); RouteWatcher.start(); if (feedModeEnabled) { setTimeout(() => activateFeed(), 300); } else { removeResizer(); restoreSidebarWidth(); } }); } init(); })();