// ==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 = `
热门
'); } 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 = `