// ==UserScript== // @name Discourse Sidebar Feed Panel // @namespace https://linux.do/ // @version 2.2.3 // @description 将 Discourse 原生侧边栏改造为信息流面板,支持分类筛选、已读/未读过滤、拖拽调整宽度 // @author YsLtr // @match https://linux.do/* // @match https://www.nodeloc.com/* // @match https://forum.chrultrabook.com/* // @match https://community.openai.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=linux.do // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @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_activity_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 CATEGORY_DATA_CACHE_KEY = "sfp_category_data_cache_v1"; const TAG_STYLE_CACHE_KEY = "sfp_tag_style_cache_v1"; const SITE_STORAGE_PREFIX = "sfp_site"; const LEGACY_LINUXDO_ORIGIN = "https://linux.do"; const STORAGE_MISSING = "__SFP_STORAGE_MISSING__"; const SITE_SCOPED_STORAGE_KEYS = [ STATE_KEY, ORDER_KEY, PERIOD_KEY, WIDTH_KEY, TAB_KEY, TAB_ORDER_KEY, FILTER_KEY, HIDE_PINNED_KEY, SHOW_INCOMING_HINT_KEY, AUTO_SILENT_REFRESH_KEY, AUTO_SILENT_REFRESH_INTERVAL_KEY, AUTO_REFRESH_ENABLED_KEY, AUTO_REFRESH_INTERVAL_KEY, CATEGORY_DATA_CACHE_KEY, TAG_STYLE_CACHE_KEY, ]; function _siteStorageKey(key) { return `${SITE_STORAGE_PREFIX}:${encodeURIComponent(location.origin)}:${key}`; } function _getSiteValue(key, fallback) { return GM_getValue(_siteStorageKey(key), fallback); } function _setSiteValue(key, value) { GM_setValue(_siteStorageKey(key), value); } function _deleteSiteValue(key) { if (typeof GM_deleteValue === "function") GM_deleteValue(_siteStorageKey(key)); } function _deleteLegacyValue(key) { if (typeof GM_deleteValue === "function") GM_deleteValue(key); } function _migrateLegacyLinuxDoStorage() { if (location.origin !== LEGACY_LINUXDO_ORIGIN) return; SITE_SCOPED_STORAGE_KEYS.forEach((key) => { const legacyValue = GM_getValue(key, STORAGE_MISSING); if (legacyValue === STORAGE_MISSING) return; const scopedKey = _siteStorageKey(key); const scopedValue = GM_getValue(scopedKey, STORAGE_MISSING); if (scopedValue === STORAGE_MISSING) { GM_setValue(scopedKey, legacyValue); } _deleteLegacyValue(key); }); } _migrateLegacyLinuxDoStorage(); // ========== 常量 ========== // 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_REFRESH_IDLE_LIMIT_MS = 10 * 60 * 1000; // 自动补页只在“筛选后当前页不够显示”时触发。窗口限速和空结果计数一起 // 防止未读/已读筛选在站点数据不足时连续请求后续页。 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 CATEGORY_DATA_CACHE_VERSION = 1; const TAG_STYLE_CACHE_VERSION = 1; // ========== 全局状态 ========== // currentOrder 历史上曾使用 default,后续需求把“默认”和“最新活动”合并。 // 这里在启动时迁移旧值,避免旧用户升级后落到不存在的排序分支。 let feedModeEnabled = _getSiteValue(STATE_KEY, false); let currentOrder = _getSiteValue(ORDER_KEY, "activity"); if (currentOrder === "default") { currentOrder = "activity"; _setSiteValue(ORDER_KEY, currentOrder); } let currentPeriod = _getSiteValue(PERIOD_KEY, "all"); let sfpSidebarWidth = _getSiteValue(WIDTH_KEY, DEFAULT_WIDTH); let currentTab = _getSiteValue(TAB_KEY, "all"); let currentFilter = _getSiteValue(FILTER_KEY, "all"); let hidePinned = _getSiteValue(HIDE_PINNED_KEY, false); let showIncomingHint = _getSiteValue(SHOW_INCOMING_HINT_KEY, true); let autoSilentRefreshEnabled = _getSiteValue(AUTO_SILENT_REFRESH_KEY, false); let autoSilentRefreshInterval = Math.max(0, Number(_getSiteValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL)) || DEFAULT_AUTO_SILENT_REFRESH_INTERVAL); let autoRefreshEnabled = _getSiteValue(AUTO_REFRESH_ENABLED_KEY, false); let autoRefreshInterval = Math.max(1, Number(_getSiteValue(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 topicPageSize = 30; 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 只用于在显示提醒前做板块范围 // 粗筛,避免每条推送都立即请求详情。topicIds 保留完整累计候选; // resident limit 只限制每次实际加载的最新候选数量,不能截断提醒计数。 const sidebarIncomingState = { topicIds: [], topicIdSet: new Set(), topicCache: new Map(), filteredTopicIds: [], filteredLoadTopicIds: [], filterRefreshTimer: null, filterRefreshToken: 0, viewSettling: false, filterStable: false, applyQueued: false, }; let sidebarMessageBus = null; let sidebarLatestMessageBusCallback = null; let sidebarNewMessageBusCallback = null; // 话题删除/恢复生命周期跟踪与 incoming 跟踪相互独立:incoming 只在最新活动 // 视图启用,而 /delete 等事件对任何排序视图都值得即时标记。 let sidebarLifecycleMessageBus = null; let sidebarLifecycleMessageBusCallback = null; let activeLoadToken = 0; let activeLoadMoreToken = 0; let activeRefreshToken = 0; let lastPageActivityAt = Date.now(); let categoryMetaPromise = null; let categoryMetaLoaded = false; let tagStylePromise = null; let tagStyleLoaded = false; let siteDataPromise = null; let siteDataLoaded = false; let siteDataCache = null; let categoriesAndLatestPromise = null; let categoriesAndLatestLoaded = false; let categoriesAndLatestCache = 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 feedScrollAbortController = null; let lastHeadActionAwayState = 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 clampNumber(value, min, max, fallback) { const numeric = Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.min(max, Math.max(min, Math.round(numeric))); } 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, "'"); } const I18N = { "zh-CN": { all: "全部", orderActivity: "最新活动", orderCreated: "最新发布", orderViews: "最多浏览", orderPosts: "最多回复", orderLikes: "最多点赞", orderOpLikes: "楼主点赞", periodAll: "全部", periodDaily: "每日", periodWeekly: "每周", periodMonthly: "每月", periodQuarterly: "每季", periodYearly: "每年", filterAll: "全部", filterUnseen: "未读", filterRead: "已读", hidePinned: "隐藏置顶", toggleTitle: "切换侧边栏信息流", refresh: "刷新", settings: "设置", incomingHintLabel: "新活动数量", incomingHintTip: "在刷新按钮中显示当前板块范围内的新活动候选数量。离开首屏时点击数量会先回到顶部,再应用这些候选。", autoSilentLabel: "自动静默刷新", autoSilentTip: "在最新活动视图且停留首屏时按间隔自动应用新话题;离开首屏后暂停,只累计候选。", hidePinnedTip: "只在最新活动视图加载首页时,排除列表顶部已读的置顶话题。新活动队列和加载更多不会应用这个设置。", silentIntervalLabel: "静默刷新间隔", silentIntervalTip: "单位为秒,最小为 0。设为 0 时,有新活动会立即静默应用;大于 0 时按倒计时批量应用。", autoRefreshLabel: "自动刷新", autoRefreshTip: "用于非最新活动的排序视图,停留首屏时按间隔重新拉取当前列表。不要设置太快,频繁请求可能触发站点速率限制。", autoRefreshIntervalLabel: "自动刷新间隔", autoRefreshIntervalTip: "单位为秒,最小为 1。到达间隔后刷新当前筛选和排序下的列表。", secondsSuffix: "s", helpSuffix: "说明", tabMore: "展开板块 / 排序", tabPanelTitle: "拖动板块调整顺序", close: "关闭", loading: "加载中...", emptyTopics: "暂无话题", loadFailed: "加载失败", retry: "重试", noMatchingTopics: "无匹配话题", noUnreadInCategory: "该板块暂无未读话题", noUnread: "暂无未读话题", noRead: "暂无已读话题", currentPagePrefix: "当前页", nextPageNoMatch: "下一页无符合条件的话题", applyIncoming: "应用 {count} 个新的或更新的话题", backToTop: "回到顶部", hot: "热门", pinned: "已置顶", topicUnavailable: "话题异常", topicUnavailableTip: "此话题已从服务器列表中消失,可能被取消公开、隐藏或删除。点击可自行确认实际情况。", closedTitle: "此话题已被关闭;不再接受新回复", loadMore: "加载更多", noMore: "— 已经到底了 —", requestFailed: "请求失败", }, en: { all: "All", orderActivity: "Latest activity", orderCreated: "Newest", orderViews: "Most viewed", orderPosts: "Most replies", orderLikes: "Most liked", orderOpLikes: "OP likes", periodAll: "All", periodDaily: "Daily", periodWeekly: "Weekly", periodMonthly: "Monthly", periodQuarterly: "Quarterly", periodYearly: "Yearly", filterAll: "All", filterUnseen: "Unread", filterRead: "Read", hidePinned: "Hide pinned", toggleTitle: "Toggle sidebar feed", refresh: "Refresh", settings: "Settings", incomingHintLabel: "New activity count", incomingHintTip: "Show incoming candidates for the current category scope in the refresh button. Away from the head, clicking the count returns to the top before applying them.", autoSilentLabel: "Auto silent refresh", autoSilentTip: "In the latest activity view, automatically apply incoming topics while the feed is at the head. Away from the head, pause applying and keep accumulating candidates.", hidePinnedTip: "Only in the latest activity view, exclude read pinned topics at the top when loading the first page. Incoming topics and load more are not affected.", silentIntervalLabel: "Silent interval", silentIntervalTip: "Seconds. Minimum 0. With 0, incoming activity is applied immediately; otherwise candidates are batched by countdown.", autoRefreshLabel: "Auto refresh", autoRefreshTip: "For non-latest sorting views, re-fetch the current list while the feed is at the head. Avoid very short intervals to reduce rate-limit risk.", autoRefreshIntervalLabel: "Refresh interval", autoRefreshIntervalTip: "Seconds. Minimum 1. Refreshes the current filter and sorting view when the interval elapses.", secondsSuffix: "s", helpSuffix: " help", tabMore: "Expand categories / order", tabPanelTitle: "Drag categories to reorder", close: "Close", loading: "Loading...", emptyTopics: "No topics", loadFailed: "Load failed", retry: "Retry", noMatchingTopics: "No matching topics", noUnreadInCategory: "No unread topics in this category", noUnread: "No unread topics", noRead: "No read topics", currentPagePrefix: "Current page: ", nextPageNoMatch: "Next page has no matching topics", applyIncoming: "Apply {count} new or updated topics", backToTop: "Back to top", hot: "Hot", pinned: "Pinned", topicUnavailable: "Unavailable", topicUnavailableTip: "This topic disappeared from the server list; it may be unlisted, hidden, or deleted. Click to check what happened.", closedTitle: "This topic is closed; it no longer accepts replies", loadMore: "Load more", noMore: "No more topics", requestFailed: "Request failed", }, }; function getUiLocale() { const candidates = [ document.documentElement?.getAttribute("lang"), document.documentElement?.getAttribute("xml:lang"), getDiscourse()?.SiteSettings?.default_locale, navigator.language, ]; const language = candidates.find((value) => String(value || "").trim()) || "en"; return /^zh/i.test(language) ? "zh-CN" : "en"; } function t(key, params = {}) { const messages = I18N[getUiLocale()] || I18N.en; const template = messages[key] || I18N.en[key] || key; return String(template).replace(/\{(\w+)\}/g, (_, name) => { return params[name] === undefined ? `{${name}}` : String(params[name]); }); } 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 (getUiLocale() !== "zh-CN") { const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); if (seconds < 60) return rtf.format(-Math.max(1, seconds), "second"); if (minutes < 60) return rtf.format(-minutes, "minute"); if (hours < 24) return rtf.format(-hours, "hour"); if (days < 30) return rtf.format(-days, "day"); const months = Math.floor(days / 30); if (months < 12) return rtf.format(-months, "month"); return rtf.format(-Math.floor(months / 12), "year"); } 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 getUserProfileUrl(username) { if (!username) return ""; return `/u/${encodeURIComponent(username)}`; } function openPathInNewTab(path) { window.open(toAbsoluteSiteUrl(path), "_blank", "noopener,noreferrer"); } function handlePointerNavigation(e, path, { onPrimaryActivate = null, onMiddleActivate = null } = {}) { if (!path) return false; const isPrimaryClick = e.type === "click" && e.button === 0; const isMiddleClick = e.type === "auxclick" && e.button === 1; if (!isPrimaryClick && !isMiddleClick) return false; e.preventDefault(); e.stopPropagation(); if (isPrimaryClick && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) { onPrimaryActivate?.(); openPathInNewTab(path); return true; } if (isPrimaryClick) { onPrimaryActivate?.(); navigateTo(path); return true; } onMiddleActivate?.(); openPathInNewTab(path); return true; } 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(); } function waitForStableHeaderMount(callback) { waitForEmber(() => { const run = () => { requestAnimationFrame(() => { requestAnimationFrame(() => { callback(); }); }); }; if (document.readyState === "complete") { run(); return; } window.addEventListener("load", run, { once: true }); }); } // ========== 站点数据 / 分类 ========== const ORDER_OPTION_DEFS = [ { labelKey: "orderActivity", value: "activity", capability: "latest" }, { labelKey: "orderCreated", value: "created", capability: "new" }, { labelKey: "orderViews", value: "views", capability: "top" }, { labelKey: "orderPosts", value: "posts", capability: "top" }, { labelKey: "orderLikes", value: "likes", capability: "top" }, { labelKey: "orderOpLikes", value: "op_likes", capability: "top" }, ]; const PERIOD_OPTION_DEFS = [ { labelKey: "periodAll", value: "all" }, { labelKey: "periodDaily", value: "daily" }, { labelKey: "periodWeekly", value: "weekly" }, { labelKey: "periodMonthly", value: "monthly" }, { labelKey: "periodQuarterly", value: "quarterly" }, { labelKey: "periodYearly", value: "yearly" }, ]; const FILTER_OPTION_DEFS = [ { labelKey: "filterAll", value: "all" }, { labelKey: "filterUnseen", value: "unseen" }, { labelKey: "filterRead", value: "read" }, ]; const categoryMetaById = new Map(); let tabCategories = []; let siteCapabilities = _createDefaultSiteCapabilities(); let siteControlsSignature = ""; 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 _categoryColorMarkerHtml(color) { return ``; } function _createDefaultSiteCapabilities() { return { orderValues: new Set(ORDER_OPTION_DEFS.map((option) => option.value)), periodValues: new Set(PERIOD_OPTION_DEFS.map((option) => option.value)), filterValues: new Set(FILTER_OPTION_DEFS.map((option) => option.value)), rawFilters: new Set(), rawTopMenuItems: new Set(), }; } function _stringSet(values) { return new Set((Array.isArray(values) ? values : []) .map((value) => String(value || "").trim()) .filter(Boolean)); } function _updateSiteCapabilities(site) { const rawFilters = _stringSet(site?.filters); const rawTopMenuItems = _stringSet(site?.top_menu_items); _stringSet(site?.anonymous_top_menu_items).forEach((item) => rawTopMenuItems.add(item)); rawTopMenuItems.forEach((item) => rawFilters.add(item)); const supportsLatest = rawFilters.size === 0 || rawFilters.has("latest"); const supportsNew = rawFilters.has("new"); const supportsTop = rawFilters.has("top"); const orderValues = new Set(); if (supportsLatest) orderValues.add("activity"); if (supportsNew || supportsLatest) orderValues.add("created"); if (supportsTop) { ["views", "posts", "likes", "op_likes"].forEach((order) => orderValues.add(order)); } if (orderValues.size === 0) orderValues.add("activity"); const sitePeriods = _stringSet(site?.periods); const periodValues = sitePeriods.size > 0 ? new Set(PERIOD_OPTION_DEFS.map((option) => option.value).filter((value) => sitePeriods.has(value))) : new Set(PERIOD_OPTION_DEFS.map((option) => option.value)); if (periodValues.size === 0) periodValues.add("all"); const filterValues = new Set(["all"]); if (rawFilters.has("unseen") || rawFilters.has("unread") || rawFilters.has("new")) { filterValues.add("unseen"); } if (rawFilters.has("read")) filterValues.add("read"); siteCapabilities = { orderValues, periodValues, filterValues, rawFilters, rawTopMenuItems, }; } function _getOrderOptions() { return ORDER_OPTION_DEFS .filter((option) => siteCapabilities.orderValues.has(option.value)) .map((option) => ({ label: t(option.labelKey), value: option.value })); } function _getPeriodOptions() { return PERIOD_OPTION_DEFS .filter((option) => siteCapabilities.periodValues.has(option.value)) .map((option) => ({ label: t(option.labelKey), value: option.value })); } function _getFilterOptions() { return FILTER_OPTION_DEFS .filter((option) => siteCapabilities.filterValues.has(option.value)) .map((option) => ({ label: t(option.labelKey), value: option.value })); } function _categoryTabId(id) { return `cat-${Number(id)}`; } function _findTabCategoryByTabId(tabId) { return tabCategories.find((cat) => { return cat.tabId === tabId || cat.legacyTabIds?.includes(tabId); }) || null; } function _categoryIdFromPath(value) { if (!value || typeof value !== "string") return null; let path = value; try { path = new URL(value, location.origin).pathname; } catch (e) { path = value.split("?")[0].split("#")[0]; } const parts = path.split("/").filter(Boolean); if (parts[0] !== "c") return null; for (let index = parts.length - 1; index >= 1; index--) { const id = Number(parts[index]); if (Number.isInteger(id) && id > 0) return id; } return null; } function _categoryListCategories(data) { return Array.isArray(data?.category_list?.categories) ? data.category_list.categories : []; } function _mergeCategoryRecords(...categoryLists) { const rawById = new Map(); categoryLists.forEach((categoryList) => { if (!Array.isArray(categoryList)) return; categoryList.forEach((cat) => { const id = Number(cat?.id); if (!Number.isFinite(id)) return; rawById.set(id, { ...(rawById.get(id) || {}), ...cat, id, }); }); }); return Array.from(rawById.values()); } function _collectNavigationCategoryIds(site, rawById, categoryListCategories = []) { const ids = []; const seen = new Set(); const addId = (value) => { const id = Number(value); if (!Number.isInteger(id) || id <= 0 || !rawById.has(id) || seen.has(id)) return; seen.add(id); ids.push(id); }; const addRecord = (record) => { if (!record) return; if (typeof record === "number" || typeof record === "string") { addId(record); const pathId = _categoryIdFromPath(String(record)); if (pathId) addId(pathId); return; } if (typeof record !== "object") return; addId(record.category_id ?? record.categoryId ?? record.category?.id); const recordType = String(record.type || record.section_type || "").toLowerCase(); const isCategoryRecord = recordType === "category" || record.topic_count !== undefined || record.parent_category_id !== undefined || record.read_restricted !== undefined; if (isCategoryRecord && record.id !== undefined) { addId(record.id); } [record.value, record.url, record.href, record.path, record.link, record.route].forEach((path) => { const pathId = _categoryIdFromPath(path); if (pathId) addId(pathId); }); }; const addRecords = (records) => { if (!Array.isArray(records)) return; records.forEach(addRecord); }; [ site?.navigation_menu_categories, site?.navigation_menu_site_categories, site?.default_navigation_menu_categories, site?.anonymous_default_navigation_menu_categories, ].forEach(addRecords); [ site?.anonymous_sidebar_sections, site?.sidebar_sections, site?.navigation_menu_sections, ].forEach((sections) => { if (!Array.isArray(sections)) return; sections.forEach((section) => { addRecord(section); addRecords(section?.links); }); }); if (ids.length === 0) addRecords(categoryListCategories); return ids; } function _buildTabCategories(site, navigationCategories, rawById) { return _collectNavigationCategoryIds(site, rawById, navigationCategories) .map((id) => _getCategoryMeta(id)) .filter((meta) => meta?.name) .map((meta) => ({ id: meta.id, tabId: _categoryTabId(meta.id), legacyTabIds: [meta.slug].filter(Boolean), name: meta.name, icon: meta.icon, color: `#${meta.color}`, slug: meta.slug, })); } function _normalizeCurrentSiteState() { let changed = false; const orderOptions = _getOrderOptions(); const periodOptions = _getPeriodOptions(); const filterOptions = _getFilterOptions(); if (!siteCapabilities.orderValues.has(currentOrder)) { currentOrder = orderOptions[0]?.value || "activity"; _setSiteValue(ORDER_KEY, currentOrder); changed = true; } if (!siteCapabilities.periodValues.has(currentPeriod)) { currentPeriod = siteCapabilities.periodValues.has("all") ? "all" : (periodOptions[0]?.value || "all"); _setSiteValue(PERIOD_KEY, currentPeriod); changed = true; } if (!siteCapabilities.filterValues.has(currentFilter)) { currentFilter = filterOptions[0]?.value || "all"; _setSiteValue(FILTER_KEY, currentFilter); changed = true; } if (currentTab === "all") { currentCategoryId = null; } else { const tab = _findTabCategoryByTabId(currentTab); if (tab) { currentCategoryId = tab.id; if (currentTab !== tab.tabId) { currentTab = tab.tabId; _setSiteValue(TAB_KEY, currentTab); changed = true; } } else { currentTab = "all"; currentCategoryId = null; _setSiteValue(TAB_KEY, currentTab); changed = true; } } return changed; } function _siteControlsStateSignature() { return JSON.stringify({ locale: getUiLocale(), orders: _getOrderOptions().map((option) => option.value), periods: _getPeriodOptions().map((option) => option.value), filters: _getFilterOptions().map((option) => option.value), tabs: tabCategories.map((cat) => `${cat.id}:${cat.name}:${cat.icon}:${cat.color}`), currentTab, currentOrder, currentPeriod, currentFilter, }); } function _syncSiteDerivedControls({ force = false } = {}) { const nextSignature = _siteControlsStateSignature(); if (!force && nextSignature === siteControlsSignature) return; siteControlsSignature = nextSignature; if (feedHeaderEl) { feedHeaderEl.replaceChildren(); _buildHeaderControls(feedHeaderEl); } const tabShell = feedContainer?.querySelector(".sfp-tab-shell"); if (tabShell) _rerenderTabBar(tabShell); const filterBar = feedContainer?.querySelector(".sfp-filter-bar"); if (filterBar) filterBar.replaceWith(_buildFilterBar()); } function _normalizeCategoryMeta(raw = {}, parent = null) { const id = Number(raw.id); const icon = _safeIconName(raw.icon || ""); return { id, name: raw.name || "", color: _normalizeHexColor(raw.color, "888"), text_color: _normalizeHexColor(raw.text_color, "FFFFFF"), icon, style_type: _safeCategoryStyleType(raw.style_type, !!icon), slug: raw.slug || "", parent_category_id: raw.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, 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) || null; } function _cloneJsonArray(value) { if (!Array.isArray(value)) return []; try { return JSON.parse(JSON.stringify(value)); } catch (e) { return []; } } function _createCategoryDataCacheSource(site = {}, navigationCategories = []) { return { site: { categories: _cloneJsonArray(site?.categories), filters: _cloneJsonArray(site?.filters), periods: _cloneJsonArray(site?.periods), top_menu_items: _cloneJsonArray(site?.top_menu_items), anonymous_top_menu_items: _cloneJsonArray(site?.anonymous_top_menu_items), top_tags: _cloneJsonArray(site?.top_tags), can_tag_topics: site?.can_tag_topics, navigation_menu_categories: _cloneJsonArray(site?.navigation_menu_categories), navigation_menu_site_categories: _cloneJsonArray(site?.navigation_menu_site_categories), default_navigation_menu_categories: _cloneJsonArray(site?.default_navigation_menu_categories), anonymous_default_navigation_menu_categories: _cloneJsonArray(site?.anonymous_default_navigation_menu_categories), anonymous_sidebar_sections: _cloneJsonArray(site?.anonymous_sidebar_sections), sidebar_sections: _cloneJsonArray(site?.sidebar_sections), navigation_menu_sections: _cloneJsonArray(site?.navigation_menu_sections), }, navigationCategories: _cloneJsonArray(navigationCategories), }; } function _applyCategoryDataSource(source, { primeSiteData = false } = {}) { const site = source?.site || {}; const navigationCategories = Array.isArray(source?.navigationCategories) ? source.navigationCategories : []; const categories = _mergeCategoryRecords(site?.categories, navigationCategories); const rawById = new Map(categories.map((cat) => [Number(cat.id), cat])); _updateSiteCapabilities(site); categoryMetaById.clear(); 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; categoryMetaById.set(id, _normalizeCategoryMeta(cat, parent)); }); tabCategories = _buildTabCategories(site, navigationCategories, rawById); _normalizeCurrentSiteState(); if (primeSiteData && !siteDataLoaded) { siteDataCache = site; siteDataLoaded = true; } categoryMetaLoaded = true; } function _loadCategoryDataCache() { try { const cache = _getSiteValue(CATEGORY_DATA_CACHE_KEY, null); if (!cache || cache.version !== CATEGORY_DATA_CACHE_VERSION || !cache.source) return null; const source = cache.source; if (!source.site || !Array.isArray(source.site.categories) || !Array.isArray(source.navigationCategories)) { return null; } return source; } catch (e) { console.warn("[SFP] load category data cache failed:", e); return null; } } function _saveCategoryDataCache(source) { try { _setSiteValue(CATEGORY_DATA_CACHE_KEY, { version: CATEGORY_DATA_CACHE_VERSION, savedAt: Date.now(), source, }); } catch (e) { console.warn("[SFP] save category data cache failed:", e); } } function _resetRuntimeCategoryData() { categoryMetaPromise = null; categoryMetaLoaded = false; categoryMetaById.clear(); tabCategories = []; siteDataPromise = null; siteDataLoaded = false; siteDataCache = null; categoriesAndLatestPromise = null; categoriesAndLatestLoaded = false; categoriesAndLatestCache = null; } function _resetRuntimeTagStyleData() { tagStylePromise = null; tagStyleLoaded = false; tagStyleByKey.clear(); } function clearDataCaches({ reload = false } = {}) { _deleteSiteValue(CATEGORY_DATA_CACHE_KEY); _deleteSiteValue(TAG_STYLE_CACHE_KEY); _resetRuntimeCategoryData(); _resetRuntimeTagStyleData(); console.info("[SFP] category data and tag style caches cleared"); if (reload && feedModeEnabled) { _resetAutoLoadState(); loadTopics(); } } 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 = _getSiteValue(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 [...tabCategories]; return [...tabCategories].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)); _setSiteValue(TAB_ORDER_KEY, order); } function _buildCategoryTabContent(cat) { const tabMeta = getCategoryTabMeta(cat); const iconHtml = tabMeta.icon ? _svgIcon(tabMeta.icon) : _categoryColorMarkerHtml(tabMeta.color); return `${iconHtml}${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 loadCategoriesAndLatestData() { if (categoriesAndLatestLoaded) return categoriesAndLatestCache; if (categoriesAndLatestPromise) return categoriesAndLatestPromise; categoriesAndLatestPromise = (async () => { const resp = await fetch("/categories_and_latest.json", { headers: { "X-CSRF-Token": getCsrfToken() } }); if (!resp.ok) throw new Error(`categories_and_latest.json ${resp.status}`); categoriesAndLatestCache = await resp.json(); categoriesAndLatestLoaded = true; return categoriesAndLatestCache; })().finally(() => { categoriesAndLatestPromise = null; }); return categoriesAndLatestPromise; } async function loadCategoryMetadata() { if (categoryMetaLoaded) return; if (categoryMetaPromise) return categoryMetaPromise; categoryMetaPromise = (async () => { try { const cachedSource = _loadCategoryDataCache(); if (cachedSource) { _applyCategoryDataSource(cachedSource, { primeSiteData: true }); return; } const [siteResult, categoriesAndLatestResult] = await Promise.allSettled([ loadSiteData(), loadCategoriesAndLatestData(), ]); const site = siteResult.status === "fulfilled" ? siteResult.value : null; const categoriesAndLatest = categoriesAndLatestResult.status === "fulfilled" ? categoriesAndLatestResult.value : null; if (siteResult.status === "rejected") console.warn("[SFP] load site data failed:", siteResult.reason); if (categoriesAndLatestResult.status === "rejected") console.warn("[SFP] load categories_and_latest failed:", categoriesAndLatestResult.reason); if (!site && !categoriesAndLatest) throw new Error("site category metadata unavailable"); const navigationCategories = _categoryListCategories(categoriesAndLatest); const source = _createCategoryDataCacheSource(site || {}, navigationCategories); _applyCategoryDataSource(source); _saveCategoryDataCache(source); } catch (e) { console.warn("[SFP] load category metadata failed:", e); tabCategories = []; _normalizeCurrentSiteState(); categoryMetaLoaded = true; } 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 = _getSiteValue(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 { _setSiteValue(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(--secondary); 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); } .sfp-feed-header .sfp-refresh-btn.sfp-back-top-enter:not(.sfp-has-incoming-count) svg { animation: sfp-back-top-enter 0.18s ease; } .sfp-feed-header .sfp-refresh-btn .sfp-refresh-count { display: inline-block; min-width: 3ch; color: var(--tertiary); font-size: 13px; font-weight: 700; line-height: 1; text-align: center; white-space: nowrap; } @keyframes sfp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes sfp-back-top-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .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-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(--secondary); 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: absolute; top: calc(100% + 4px); left: 0; 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-category-color-marker { width: 0.72em; height: 0.72em; border-radius: 50%; background: var(--sfp-category-marker-color, currentColor); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent); flex: 0 0 auto; } .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 .sfp-category-color-marker { width: 0.75em; height: 0.75em; } .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; overscroll-behavior-y: contain; touch-action: pan-y pinch-zoom; --scrollbarBg: transparent; --scrollbarThumbBg: var(--d-selected, var(--token-color-surface-hovered)); --scrollbarWidth: var(--space-2, 0.5em); scrollbar-gutter: stable; scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg); } .sfp-feed-scroll::-webkit-scrollbar { width: var(--scrollbarWidth); } .sfp-feed-scroll::-webkit-scrollbar-thumb { background-color: var(--scrollbarThumbBg); border-radius: calc(var(--scrollbarWidth) / 2); } .sfp-feed-scroll::-webkit-scrollbar-track { background-color: transparent; } .sfp-content-wrapper { position: relative; min-width: 0; max-width: 100%; min-height: 100%; } /* ===== 帖子列表项 ===== */ .sfp-topic-item { display: block; padding: 12px 20px; border-bottom: 1px solid var(--primary-very-low); color: inherit; cursor: pointer; text-decoration: none; transition: background 0.2s; position: relative; min-width: 0; max-width: 100%; overflow-wrap: break-word; word-break: break-word; } .sfp-topic-item:visited, .sfp-topic-item:hover, .sfp-topic-item:focus { color: inherit; text-decoration: none; } .sfp-topic-item:hover { background: var(--primary-very-low); } .sfp-topic-item:focus-visible { outline: 2px solid var(--tertiary); outline-offset: -2px; } .sfp-topic-item.sfp-filter-mismatch { opacity: 0.48; filter: grayscale(0.85); } .sfp-topic-item.sfp-topic-unavailable { opacity: 0.62; } .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-unread-dot.sfp-unread-dot--hidden { visibility: hidden; } /* 头像 + 用户信息行 */ .sfp-topic-item .sfp-topic-header { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .sfp-topic-item .sfp-topic-user-link { display: inline-flex; align-items: center; gap: 5px; min-width: 0; color: inherit; text-decoration: none; cursor: pointer; } /* 防止父级 topic 链接的 hover/focus 样式把用户入口改成普通文本。 */ .sfp-topic-item .sfp-topic-user-link:hover, .sfp-topic-item .sfp-topic-user-link:focus { color: inherit; text-decoration: none; } .sfp-topic-item .sfp-topic-user-link:focus-visible { outline: 2px solid var(--tertiary); outline-offset: 2px; border-radius: 4px; } .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.--unavailable { --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__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 = t("toggleTitle"); toggleBtn.innerHTML = ``; toggleBtn.addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); feedModeEnabled = !feedModeEnabled; _setSiteValue(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 = ""; _setSiteValue(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(); _startSidebarTopicLifecycleTracking(); _syncDefaultViewControls(); _syncIncomingHeadAction(); _syncHeadActionState(); return; } if (feedContainer) { _resetRefreshButtonBusy(); if (feedScrollAbortController) { feedScrollAbortController.abort(); feedScrollAbortController = null; } feedContainer.remove(); feedContainer = null; feedHeaderEl = null; feedRefreshBtn = null; feedScrollEl = null; feedListEl = 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); sidebar.appendChild(feedContainer); sidebar.classList.add("sfp-feed-mode"); animateSidebarWidth(sfpSidebarWidth); setupResizer(); // 恢复当前 tab 筛选的分类 _restoreTabState(); _syncDefaultViewControls(); // 始终全量加载,数据已在 deactivateFeed 中清除 loadTopics(); // 无限滚动 _setupScrollLoadMore(); _startSidebarTopicLifecycleTracking(); } 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(); _stopSidebarTopicLifecycleTracking(); _resetRefreshButtonBusy(); feedRefreshBtn?.classList.remove("sfp-back-top-enter"); if (feedContainer) { feedContainer.remove(); feedContainer = null; feedHeaderEl = null; feedRefreshBtn = null; feedScrollEl = null; feedListEl = 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) { const orderOptions = _getOrderOptions(); const periodOptions = _getPeriodOptions(); // Period 下拉(先创建,因为 order 切换时需要引用) const periodSelect = _buildCustomSelect(periodOptions, currentPeriod, (value) => { currentPeriod = value; _setSiteValue(PERIOD_KEY, currentPeriod); _resetAutoLoadState(); loadTopics(); }); periodSelect.classList.add("sfp-period-select"); _updatePeriodVisibility(periodSelect); // Order 下拉 const orderSelect = _buildCustomSelect(orderOptions, currentOrder, (value) => { currentOrder = value; _setSiteValue(ORDER_KEY, currentOrder); _updatePeriodVisibility(periodSelect); _beginSidebarIncomingViewSettling(); _syncDefaultViewControls(); _resetAutoLoadState(); loadTopics(); }); orderSelect.classList.add("sfp-order-select"); function _updatePeriodVisibility(ps) { ps.style.display = _needsPeriodForUrl(currentOrder) && periodOptions.length > 1 ? "" : "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.type = "button"; refreshBtn.title = t("refresh"); refreshBtn.setAttribute("aria-label", t("refresh")); refreshBtn.innerHTML = _refreshIconHtml(); refreshBtn.addEventListener("click", _handleHeadActionClick); 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 = t("settings"); btn.innerHTML = ` `; const panel = document.createElement("div"); panel.className = "sfp-settings-panel"; // 设置项按当前排序视图分组,而不是一次性展示全部选项: // - 最新活动可以消费 message-bus 增量,因此提供“新活动提醒”和“静默刷新”; // - 浏览量/回复/点赞等排序没有同等可靠的增量通道,只提供普通自动刷新。 // 这样可以减少用户误以为所有排序都能无请求地接收新话题。 if (isLatestActivityView) { panel.innerHTML = `
${_buildSettingLabelHtml(t("incomingHintLabel"), t("incomingHintTip"))}
${_buildSettingLabelHtml(t("hidePinned"), t("hidePinnedTip"))}
${_buildSettingLabelHtml(t("autoSilentLabel"), t("autoSilentTip"))}
${_buildSettingLabelHtml(t("silentIntervalLabel"), t("silentIntervalTip"))} ${escapeHtml(t("secondsSuffix"))}
`; } else { panel.innerHTML = `
${_buildSettingLabelHtml(t("autoRefreshLabel"), t("autoRefreshTip"))}
${_buildSettingLabelHtml(t("autoRefreshIntervalLabel"), t("autoRefreshIntervalTip"))} ${escapeHtml(t("secondsSuffix"))}
`; } 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; _setSiteValue(SHOW_INCOMING_HINT_KEY, showIncomingHint); _syncSettingsPanelState(wrapper); _syncSidebarIncomingTracking(); _syncHeadActionState(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } }); } const hidePinnedInput = panel.querySelector(".sfp-hide-pinned-input"); if (hidePinnedInput) { _bindCheckboxSetting(hidePinnedInput, (checked) => { hidePinned = checked; _setSiteValue(HIDE_PINNED_KEY, hidePinned); _syncSettingsPanelState(wrapper); _resetAutoLoadState(); loadTopics(); }); } 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; _setSiteValue(AUTO_SILENT_REFRESH_KEY, autoSilentRefreshEnabled); _syncSettingsPanelState(wrapper); _syncSidebarIncomingTracking(); _startAutoSilentRefresh(); _syncHeadActionState(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } }); } _bindNumberSetting(silentIntervalInput, 0, DEFAULT_AUTO_SILENT_REFRESH_INTERVAL, (seconds) => { autoSilentRefreshInterval = seconds; _setSiteValue(AUTO_SILENT_REFRESH_INTERVAL_KEY, autoSilentRefreshInterval); _startAutoSilentRefresh(); _syncHeadActionState(); 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; _setSiteValue(AUTO_REFRESH_ENABLED_KEY, autoRefreshEnabled); intervalRow.classList.toggle("visible", autoRefreshEnabled); _syncSettingsPanelHeight(wrapper); _startAutoRefresh(); _syncHeadActionState(); }); } _bindNumberSetting(intervalInput, 1, DEFAULT_AUTO_REFRESH_INTERVAL, (seconds) => { autoRefreshInterval = seconds; _setSiteValue(AUTO_REFRESH_INTERVAL_KEY, autoRefreshInterval); _startAutoRefresh(); _syncHeadActionState(); }); 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 hidePinnedInput = panel.querySelector(".sfp-hide-pinned-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 (hidePinnedInput) hidePinnedInput.checked = hidePinned; if (autoSilentInput) autoSilentInput.checked = autoSilentRefreshEnabled; if (autoRefreshInput) autoRefreshInput.checked = autoRefreshEnabled; if (autoSilentRow) { autoSilentRow.classList.remove("hidden"); } if (autoSilentIntervalRow) { autoSilentIntervalRow.classList.remove("hidden"); autoSilentIntervalRow.classList.toggle("visible", 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, max = Infinity) { if (!input) return; input.addEventListener("change", () => { const nextValue = clampNumber(input.value, min, max, 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; const wasBusy = feedRefreshBtn.classList.contains("spinning"); feedRefreshBtn.classList.toggle("spinning", isBusy); feedRefreshBtn.setAttribute("aria-busy", isBusy ? "true" : "false"); // busy 状态翻转时重同步头部按钮:旋转期间保持刷新图标,结束后再切换回箭头形态。 if (wasBusy !== isBusy) { _scheduleHeadActionStateSync(); } } function _resetRefreshButtonBusy() { refreshBusyCount = 0; _syncRefreshButtonBusy(); } // ========== 自定义下拉组件 ========== function _buildCustomSelect(options, selectedValue, onChange) { const safeOptions = options.length ? options : [{ label: "", value: selectedValue || "" }]; 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 = safeOptions.find((o) => o.value === selectedValue) || safeOptions[0]; btn.textContent = selected.label; const dropdown = document.createElement("div"); dropdown.className = "sfp-custom-select-dropdown"; let _currentSelected = selectedValue; safeOptions.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); }); 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 = t("tabMore"); moreBtn.setAttribute("aria-label", t("tabMore")); moreBtn.innerHTML = ``; const panel = document.createElement("div"); panel.className = "sfp-tab-panel"; panel.innerHTML = `
${escapeHtml(t("tabPanelTitle"))}
`; 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 = `${escapeHtml(t("all"))}`; 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; _setSiteValue(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 = _getFilterOptions(); 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); }); bar.addEventListener("click", (e) => { const item = e.target.closest(".sfp-filter-item"); if (!item) return; const filterVal = item.dataset.filter; if (filterVal === currentFilter) return; currentFilter = filterVal; _setSiteValue(FILTER_KEY, currentFilter); _beginSidebarIncomingViewSettling(); _syncDefaultViewControls(); _resetAutoLoadState(); bar.querySelectorAll(".sfp-filter-item[data-filter]").forEach((i) => i.classList.remove("active")); item.classList.add("active"); renderTopics(); _finishSidebarIncomingViewSettling(); }); return bar; } // ========== 恢复标签栏状态 ========== function _restoreTabState() { if (currentTab === "all") { currentCategoryId = null; return; } const cat = _findTabCategoryByTabId(currentTab); if (cat) { currentCategoryId = cat.id; currentTab = cat.tabId; } else if (categoryMetaLoaded) { currentTab = "all"; currentCategoryId = null; } else { 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 _isAwayFromHead() { return !!feedScrollEl && feedScrollEl.scrollTop > feedScrollEl.clientHeight; } function _isAtFeedHead() { return !feedScrollEl || feedScrollEl.scrollTop <= 1; } function _getIncomingCountDisplayValue(query = FeedQuery.snapshot()) { if (!showIncomingHint || !_canShowSidebarIncomingHint(query)) return 0; const isAway = !_isAtFeedHead() && (lastHeadActionAwayState === true || _isAwayFromHead()); if (!isAway && _isAutoSilentRefreshActive(query)) return 0; return sidebarIncomingState.filterStable ? sidebarIncomingState.filteredTopicIds.length : 0; } function _formatIncomingCount(count) { return count > 999 ? "999" : String(count); } function _refreshIconHtml() { return ``; } function _backTopIconHtml() { return ``; } function _restartAutomaticRefreshTimers() { if (isLoading || isLoadingMore || isRefreshing) return; _startAutoSilentRefresh(); _startAutoRefresh(); if (_isAtFeedHead() && _isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } } function _isAutoSilentRefreshTimerExpected() { return _isAutoSilentRefreshActive() && autoSilentRefreshInterval > 0; } function _isAutoRefreshTimerExpected() { return autoRefreshEnabled && !_isLatestActivityView() && _isAtFeedHead(); } function _restoreMissingAutomaticRefreshTimers() { if (isLoading || isLoadingMore || isRefreshing) return; if (_isAutoSilentRefreshTimerExpected() && !autoSilentRefreshTimer) { _startAutoSilentRefresh(); } if (_isAutoRefreshTimerExpected() && !autoRefreshTimer) { _startAutoRefresh(); } } function _playBackTopEnterAnimation() { if (!feedRefreshBtn) return; if (feedRefreshBtn.classList.contains("sfp-back-top-enter")) return; feedRefreshBtn.classList.add("sfp-back-top-enter"); setTimeout(() => { feedRefreshBtn?.classList.remove("sfp-back-top-enter"); }, 220); } function _syncHeadActionState({ restartAuto = true } = {}) { if (!feedRefreshBtn) return; const isAtHead = _isAtFeedHead(); const isAway = isAtHead ? false : (lastHeadActionAwayState === true || _isAwayFromHead()); // 刷新旋转期间不切换成箭头形态,避免箭头跟着旋转;结束后由 busy 翻转触发的重同步再切换。 const isBusy = refreshBusyCount > 0; const incomingCount = _getIncomingCountDisplayValue(); const showsCount = incomingCount > 0; const nextAction = showsCount ? "incoming" : (isAway && !isBusy ? "back-top" : "refresh"); const currentAction = feedRefreshBtn.dataset.action || ""; const nextIncomingCount = showsCount ? String(incomingCount) : ""; const shouldUpdateActionHtml = currentAction !== nextAction || (showsCount && feedRefreshBtn.dataset.incomingCount !== nextIncomingCount); const nextTitle = showsCount ? t("applyIncoming", { count: incomingCount }) : (nextAction === "back-top" ? t("backToTop") : t("refresh")); const nextHtml = showsCount ? `${escapeHtml(_formatIncomingCount(incomingCount))}` : (nextAction === "back-top" ? _backTopIconHtml() : _refreshIconHtml()); feedRefreshBtn.classList.toggle("sfp-away-from-head", isAway); feedRefreshBtn.classList.toggle("sfp-has-incoming-count", showsCount); if (currentAction !== nextAction) { feedRefreshBtn.dataset.action = nextAction; } if (nextIncomingCount) { feedRefreshBtn.dataset.incomingCount = nextIncomingCount; } else { delete feedRefreshBtn.dataset.incomingCount; } if (feedRefreshBtn.title !== nextTitle) { feedRefreshBtn.title = nextTitle; feedRefreshBtn.setAttribute("aria-label", nextTitle); } if (shouldUpdateActionHtml) { feedRefreshBtn.innerHTML = nextHtml; // 箭头实际显示时才播放入场动画;旋转期间被抑制的切换会在刷新结束后补播。 if (nextAction === "back-top") { _playBackTopEnterAnimation(); } } if (nextAction !== "back-top") { feedRefreshBtn.classList.remove("sfp-back-top-enter"); } _syncRefreshButtonBusy(); if (lastHeadActionAwayState !== isAway) { lastHeadActionAwayState = isAway; if (isAway) { _stopAutoRefresh(); _stopAutoSilentRefresh(); } else if (restartAuto && isAtHead) { _restartAutomaticRefreshTimers(); } } else if (!isAway && restartAuto && isAtHead) { _restoreMissingAutomaticRefreshTimers(); } } function _scheduleHeadActionStateSync() { if (_scheduleHeadActionStateSync.scheduled) return; _scheduleHeadActionStateSync.scheduled = true; requestAnimationFrame(() => { _scheduleHeadActionStateSync.scheduled = false; _syncHeadActionState(); }); } function _waitForFeedScrollHead(timeoutMs = 1200) { if (!feedScrollEl) return Promise.resolve("interrupted"); if (_isAtFeedHead()) return Promise.resolve("reached"); return new Promise((resolve) => { const startedAt = Date.now(); let settledFrames = 0; let done = false; const cleanup = () => { if (done) return; done = true; feedScrollEl?.removeEventListener("scroll", onScroll); feedScrollEl?.removeEventListener("wheel", onInterrupt); feedScrollEl?.removeEventListener("pointerdown", onInterrupt); feedScrollEl?.removeEventListener("touchstart", onInterrupt); window.removeEventListener("keydown", onInterrupt, true); }; const finish = (result) => { cleanup(); resolve(result); }; const onInterrupt = () => { if (feedScrollEl) { feedScrollEl.scrollTo({ top: feedScrollEl.scrollTop, behavior: "auto" }); } finish(_isAtFeedHead() ? "reached" : "interrupted"); }; const onScroll = () => { if (!feedScrollEl) { finish("interrupted"); return; } if (_isAtFeedHead()) { settledFrames++; if (settledFrames >= 2) finish("reached"); } else { settledFrames = 0; } }; const tick = () => { if (done) return; onScroll(); if (done) return; if (Date.now() - startedAt >= timeoutMs) { finish(_isAtFeedHead() ? "reached" : "timeout"); return; } requestAnimationFrame(tick); }; feedScrollEl.addEventListener("scroll", onScroll, { passive: true }); feedScrollEl.addEventListener("wheel", onInterrupt, { passive: true }); feedScrollEl.addEventListener("pointerdown", onInterrupt, { passive: true }); feedScrollEl.addEventListener("touchstart", onInterrupt, { passive: true }); window.addEventListener("keydown", onInterrupt, true); requestAnimationFrame(tick); }); } async function _returnToHead({ animated = true, restartAuto = true } = {}) { if (!feedScrollEl) return false; feedScrollEl.scrollTo({ top: 0, behavior: animated ? "smooth" : "auto" }); if (!animated) { _syncHeadActionState({ restartAuto }); return _isAtFeedHead(); } const scrollResult = await _waitForFeedScrollHead(); if (scrollResult === "timeout" && feedScrollEl && !_isAtFeedHead()) { feedScrollEl.scrollTop = 0; } _syncHeadActionState({ restartAuto }); return scrollResult !== "interrupted" && _isAtFeedHead(); } async function _handleHeadActionClick(event) { if (!feedRefreshBtn || feedRefreshBtn.getAttribute("aria-busy") === "true") return; const action = feedRefreshBtn.dataset.action || "refresh"; if (event?.detail >= 2 && action === "back-top") { if (!_isAtFeedHead()) { await _returnToHead({ animated: false }); } else { _syncHeadActionState(); } return; } if (action === "incoming") { if (!_isAtFeedHead()) { const reachedHead = await _returnToHead({ animated: true, restartAuto: false }); if (!reachedHead) { _syncHeadActionState(); return; } } await _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "head action incoming", queueIfBusy: false, resetFeedDepth: true, }); _restartAutomaticRefreshTimers(); _syncHeadActionState(); return; } if (action === "back-top") { await _returnToHead({ animated: true }); return; } await refreshCurrentView(); _syncHeadActionState(); } function _beginSidebarIncomingViewSettling() { sidebarIncomingState.viewSettling = true; sidebarIncomingState.filterStable = false; sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filteredLoadTopicIds = []; sidebarIncomingState.filterRefreshToken++; if (sidebarIncomingState.filterRefreshTimer) { clearTimeout(sidebarIncomingState.filterRefreshTimer); sidebarIncomingState.filterRefreshTimer = null; } _syncHeadActionState(); } function _finishSidebarIncomingViewSettling() { sidebarIncomingState.viewSettling = false; sidebarIncomingState.filterStable = false; _recomputeSidebarIncomingFilteredTopicIds(); _scheduleSidebarIncomingFilterRefresh(); _syncIncomingHeadAction(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 0) { _queueSidebarIncomingApply(); } } function _syncIncomingHeadAction({ skipIncomingFilterRefresh = false } = {}) { if (!skipIncomingFilterRefresh) { _scheduleSidebarIncomingFilterRefresh(); } _syncHeadActionState(); } function _isLatestActivityView(query = FeedQuery.snapshot()) { return query.order === "activity"; } function _canUseSidebarIncomingRefresh(query = FeedQuery.snapshot()) { return _isLatestActivityView(query); } function _shouldUseSidebarIncomingQueue(query = FeedQuery.snapshot()) { return _canUseSidebarIncomingRefresh(query) && (showIncomingHint || autoSilentRefreshEnabled); } function _canShowSidebarIncomingHint(query = FeedQuery.snapshot()) { return showIncomingHint && _canUseSidebarIncomingRefresh(query) && query.filter === "all"; } function _isAutoSilentRefreshActive(query = FeedQuery.snapshot()) { return autoSilentRefreshEnabled && _isAtFeedHead() && _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.filteredLoadTopicIds = []; sidebarIncomingState.filterStable = false; return sidebarIncomingState.filteredTopicIds; } // 这里故意只用 incoming candidate 条件,不套完整本地筛选。 // cache 里的 payload 可能缺少 last_read_post_number/new_posts 等字段; // 也可能完全没有 payload。无 cache 的 id 仍要进入 apply,才能通过 // /latest.json?topic_ids=... 补完整数据后再做最终筛选。 const filteredTopicIds = []; const filteredLoadTopicIds = []; const loadLimit = _incomingLoadTopicLimit(); sidebarIncomingState.topicIds.forEach((id) => { const topic = sidebarIncomingState.topicCache.get(Number(id)); if (topic && !_topicMatchesIncomingCandidate(topic, query)) return; filteredTopicIds.push(id); filteredLoadTopicIds.push(id); if (filteredLoadTopicIds.length > loadLimit) filteredLoadTopicIds.shift(); }); sidebarIncomingState.filteredTopicIds = filteredTopicIds; sidebarIncomingState.filteredLoadTopicIds = filteredLoadTopicIds; 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); _syncIncomingHeadAction({ skipIncomingFilterRefresh: true }); return []; } const incomingTopicIds = sidebarIncomingState.topicIds.slice(); const token = ++sidebarIncomingState.filterRefreshToken; if (incomingTopicIds.length === 0) { sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filteredLoadTopicIds = []; sidebarIncomingState.filterStable = true; _syncIncomingHeadAction({ skipIncomingFilterRefresh: true }); return []; } await loadCategoryMetadata(); if (token !== sidebarIncomingState.filterRefreshToken || !FeedQuery.isCurrent(requestQuery)) return sidebarIncomingState.filteredTopicIds; _recomputeSidebarIncomingFilteredTopicIds(requestQuery); sidebarIncomingState.filterStable = true; _syncIncomingHeadAction({ skipIncomingFilterRefresh: true }); return sidebarIncomingState.filteredTopicIds; } function _syncDefaultViewControls() { _syncSidebarIncomingTracking(); _recomputeSidebarIncomingFilteredTopicIds(); _updateSettingsControl(); _syncIncomingHeadAction(); _syncHeadActionState(); _scheduleSidebarIncomingFilterRefresh(); if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval === 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 (!_shouldUseSidebarIncomingQueue()) return; 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 _syncSidebarIncomingTracking() { // Message-bus tracking only maintains incoming candidates; auto silent // refresh uses a separate timer to decide when those candidates are applied. if (feedModeEnabled && _shouldUseSidebarIncomingQueue()) { _startSidebarIncomingTracking(); } else { _stopSidebarIncomingTracking(); } } 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; _clearSidebarIncomingCandidates(); } // ========== 话题删除/恢复即时标记 ========== // Discourse 的 /delete(软删除)、/destroy(彻底删除)无条件广播, // /recover 对应恢复;开启 experimental_topic_category_change_notification // 的站点还会对 unlist/relist 复用这两个频道。默认站点手动 unlist 和 // 举报隐藏不广播,只能靠刷新时的凭空消失检测兜底,两条路径互补。 function _startSidebarTopicLifecycleTracking() { if (!feedModeEnabled) return; if (sidebarLifecycleMessageBusCallback) return; const messageBus = getMessageBus(); if (!messageBus?.subscribe) { // 无 message-bus 时静默降级:仅刷新检测仍可工作。 return; } sidebarLifecycleMessageBus = messageBus; sidebarLifecycleMessageBusCallback = (data) => _handleSidebarTopicLifecycleMessage(data); messageBus.subscribe("/delete", sidebarLifecycleMessageBusCallback, _getMessageBusLastId(messageBus, "/delete")); messageBus.subscribe("/recover", sidebarLifecycleMessageBusCallback, _getMessageBusLastId(messageBus, "/recover")); messageBus.subscribe("/destroy", sidebarLifecycleMessageBusCallback, _getMessageBusLastId(messageBus, "/destroy")); } function _stopSidebarTopicLifecycleTracking() { if (sidebarLifecycleMessageBus?.unsubscribe && sidebarLifecycleMessageBusCallback) { sidebarLifecycleMessageBus.unsubscribe("/delete", sidebarLifecycleMessageBusCallback); sidebarLifecycleMessageBus.unsubscribe("/recover", sidebarLifecycleMessageBusCallback); sidebarLifecycleMessageBus.unsubscribe("/destroy", sidebarLifecycleMessageBusCallback); } sidebarLifecycleMessageBus = null; sidebarLifecycleMessageBusCallback = null; } function _handleSidebarTopicLifecycleMessage(data) { if (!data?.topic_id) return; const topicId = Number(data.topic_id); if (!Number.isFinite(topicId)) return; const topic = allTopics.find((candidate) => Number(candidate.id) === topicId); if (data.message_type === "recover") { if (!topic?.sfpUnavailable) return; topic.sfpUnavailable = false; delete topic.sfpUnavailablePushed; if (feedListEl) renderTopics(); return; } if (data.message_type !== "delete" && data.message_type !== "destroy") return; if (!topic || topic.sfpUnavailable) return; _markTopicUnavailable(topic); if (feedListEl) renderTopics(); } function _handleSidebarIncomingMessage(data) { if (!data || !["latest", "new_topic"].includes(data.message_type)) return; if (!data.topic_id) return; if (!_shouldUseSidebarIncomingQueue()) { _clearSidebarIncomingCandidates(); return; } if (data.payload?.archetype && data.payload.archetype !== "regular") return; // 推送 payload 不一定包含完整话题字段,但通常足够判断 id、分类和 archetype。 // 先记录候选,真正插入列表前再拉完整数据,避免把不完整 payload 直接渲染。 const queuedIncoming = _touchSidebarIncomingTopicId(data.topic_id); if (queuedIncoming && 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; _syncIncomingHeadAction({ skipIncomingFilterRefresh: true }); } } } function _clearSidebarIncomingCandidates() { // Idempotent cleanup for disabled incoming tracking and stale callbacks. sidebarIncomingState.topicIds = []; sidebarIncomingState.topicIdSet = new Set(); sidebarIncomingState.topicCache.clear(); sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filteredLoadTopicIds = []; sidebarIncomingState.filterStable = false; sidebarIncomingState.applyQueued = false; if (sidebarIncomingState.filterRefreshTimer) { clearTimeout(sidebarIncomingState.filterRefreshTimer); sidebarIncomingState.filterRefreshTimer = null; } sidebarIncomingState.filterRefreshToken++; _syncHeadActionState(); } function _resetSidebarIncomingCandidatesForQueryChange() { sidebarIncomingState.topicIds = []; sidebarIncomingState.topicIdSet = new Set(); sidebarIncomingState.topicCache.clear(); sidebarIncomingState.filteredTopicIds = []; sidebarIncomingState.filteredLoadTopicIds = []; sidebarIncomingState.filterStable = false; sidebarIncomingState.applyQueued = false; if (sidebarIncomingState.filterRefreshTimer) { clearTimeout(sidebarIncomingState.filterRefreshTimer); sidebarIncomingState.filterRefreshTimer = null; } sidebarIncomingState.filterRefreshToken++; _syncHeadActionState(); } function _touchSidebarIncomingTopicId(topicId) { const numericId = Number(topicId); if (!Number.isFinite(numericId)) return false; if (sidebarIncomingState.topicIdSet.has(numericId)) { const existingIndex = sidebarIncomingState.topicIds.indexOf(numericId); if (existingIndex !== -1) sidebarIncomingState.topicIds.splice(existingIndex, 1); } else { sidebarIncomingState.topicIdSet.add(numericId); } sidebarIncomingState.topicIds.push(numericId); sidebarIncomingState.filterStable = false; return true; } function _getSidebarIncomingLoadTopicIds() { const loadLimit = _incomingLoadTopicLimit(); const expectedLength = Math.min(sidebarIncomingState.filteredTopicIds.length, loadLimit); if (sidebarIncomingState.filteredLoadTopicIds.length !== expectedLength) { sidebarIncomingState.filteredLoadTopicIds = sidebarIncomingState.filteredTopicIds.length > loadLimit ? sidebarIncomingState.filteredTopicIds.slice(-loadLimit) : sidebarIncomingState.filteredTopicIds.slice(); } return sidebarIncomingState.filteredLoadTopicIds.slice(); } function _incomingLoadTopicLimit() { return Math.max(1, topicPageSize); } 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 _removeSidebarIncomingTopicsForQuery(query = FeedQuery.snapshot()) { if (!sidebarIncomingState.topicIds.length) return; const topicIds = sidebarIncomingState.topicIds.filter((id) => { const topic = sidebarIncomingState.topicCache.get(Number(id)); return topic && _topicMatchesIncomingCandidate(topic, query); }); _removeSidebarIncomingTopicIds(topicIds); } function _queueSidebarIncomingApply() { if (sidebarIncomingState.viewSettling) return; if (!_canUseSidebarIncomingRefresh()) return; if (!_isAutoSilentRefreshActive() || autoSilentRefreshInterval > 0) return; if (_shouldSkipAutomaticRefresh()) return; if (sidebarIncomingState.topicIds.length === 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" }); }); } 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; } } function _categoryPath(categoryId) { const targetCategoryId = Number(categoryId); if (!Number.isFinite(targetCategoryId)) return ""; const chain = []; const seen = new Set(); let meta = _getCategoryMeta(targetCategoryId); while (meta && !seen.has(meta.id)) { seen.add(meta.id); chain.unshift(meta); const parentId = Number(meta.parent_category_id); if (!Number.isFinite(parentId) || parentId === meta.id) break; meta = _getCategoryMeta(parentId); } const slugs = chain.map((cat) => cat.slug).filter(Boolean); if (slugs.length === 0) slugs.push("category"); return `/c/${slugs.map((slug) => encodeURIComponent(slug)).join("/")}/${targetCategoryId}`; } const FeedQuery = { snapshot() { return { tab: currentTab, categoryId: currentCategoryId, order: currentOrder, period: currentPeriod, filter: currentFilter, }; }, key(query) { // 查询 key 覆盖所有会影响 API URL 或本地筛选的状态。异步请求返回时用 // isCurrent 比较 key,丢弃用户切换视图前发出的旧响应。 return [ query.tab, query.categoryId || "", query.order, query.period, query.filter, ].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 categoryPath = _categoryPath(query.categoryId); const params = [ `page=${page}`, "include_subcategories=true", ]; if (useTopList) { params.push(`period=${encodeURIComponent(query.period)}`); params.push(`order=${encodeURIComponent(query.order)}`); return `${categoryPath}/l/top.json?${params.join("&")}`; } params.push(`order=${encodeURIComponent(query.order)}`); return `${categoryPath}/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 _shouldExcludeTopReadPinnedTopics(query, page) { return hidePinned && page === 0 && _isLatestActivityView(query); } function _excludeTopReadPinnedTopics(topics, query, page) { if (!_shouldExcludeTopReadPinnedTopics(query, page) || !Array.isArray(topics)) { return topics; } let firstNonPinnedIndex = 0; while (firstNonPinnedIndex < topics.length && _isPinnedTopic(topics[firstNonPinnedIndex])) { firstNonPinnedIndex++; } if (firstNonPinnedIndex === 0) return topics; const topPinnedTopics = topics.slice(0, firstNonPinnedIndex); const visibleTopPinnedTopics = topPinnedTopics.filter((topic) => _hasUnreadMarker(topic)); if (visibleTopPinnedTopics.length === topPinnedTopics.length) return topics; return visibleTopPinnedTopics.concat(topics.slice(firstNonPinnedIndex)); } 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; } let requestQuery = null; const requestToken = ++activeLoadToken; activeLoadMoreToken++; activeRefreshToken++; _beginSidebarIncomingViewSettling(); _resetSidebarIncomingCandidatesForQueryChange(); isLoading = true; isLoadingMore = false; isRefreshing = false; _pendingReload = false; currentPage = 0; hasMorePages = true; allTopics = []; loadedTopicIds.clear(); if (feedScrollEl) feedScrollEl.scrollTop = 0; lastHeadActionAwayState = null; _syncIncomingHeadAction(); _resetAutoLoadState(); lastHeadActionAwayState = null; if (feedListEl) { feedListEl.innerHTML = `
${escapeHtml(t("loading"))}
`; } _syncHeadActionState(); try { await loadCategoryMetadata(); _startTagStyleIndexLoad(); if (requestToken !== activeLoadToken) return; requestQuery = FeedQuery.snapshot(); _resetAutoLoadState(); _syncSiteDerivedControls(); 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 rawTopics = data.topic_list.topics; const topics = _excludeTopReadPinnedTopics(rawTopics, requestQuery, 0); topicPageSize = rawTopics.length; topics.forEach((t) => loadedTopicIds.add(t.id)); allTopics = topics; hasMorePages = !!data.topic_list.more_topics_url; renderTopics(); _removeSidebarIncomingTopicIds(topics.map((topic) => topic.id)); _syncIncomingHeadAction(); } else { if (feedListEl) feedListEl.innerHTML = `
${escapeHtml(t("emptyTopics"))}
`; hasMorePages = false; } } catch (e) { if (requestToken !== activeLoadToken || (requestQuery && !FeedQuery.isCurrent(requestQuery))) return; console.error("[SFP] loadTopics error:", e); if (feedListEl) { feedListEl.innerHTML = `
!
${escapeHtml(t("loadFailed"))}
${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(); _restartAutomaticRefreshTimers(); } } } } 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; isLoadingMore = true; if (isAutoLoad) _recordAutoLoadRequest(); _showLoadMoreSpinner(); try { await loadCategoryMetadata(); if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return; const data = await fetchFeedTopics(requestQuery, currentPage + 1); if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return; _processUsers(data); if (data?.topic_list?.topics) { const topics = data.topic_list.topics; currentPage++; 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 ? t("nextPageNoMatch") : "", }); } 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 ? t("nextPageNoMatch") : "", }); } } 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; _syncHeadActionState(); return; } } feedScrollEl.scrollTop = anchor.scrollTop + (feedScrollEl.scrollHeight - anchor.scrollHeight); _syncHeadActionState(); }; restore(); requestAnimationFrame(restore); } function _residentTopicLimit() { return Math.max(1, topicPageSize) * (Math.max(0, currentPage) + 1); } function _resetLoadedFeedDepth() { currentPage = 0; } function _trimResidentTopicsAfterRefresh() { const keepCount = _residentTopicLimit(); // 异常话题停留在原位置,既不占用保留名额也不参与裁剪;它随刷新 // 逐渐被新内容推到下方,直到全量刷新或超出异常保留上限才清除。 const retainedTopics = []; const trimmedTopics = []; let keptCount = 0; for (const topic of allTopics) { if (topic?.sfpUnavailable) { retainedTopics.push(topic); continue; } if (keptCount < keepCount) { retainedTopics.push(topic); keptCount++; } else { trimmedTopics.push(topic); } } if (trimmedTopics.length === 0) return false; allTopics = retainedTopics; trimmedTopics.forEach((topic) => loadedTopicIds.delete(topic.id)); _rebuildUsersMapForResidentTopics(); return true; } function _rebuildUsersMapForResidentTopics() { const nextUsersMap = {}; allTopics.forEach((topic) => { const userId = topic?.posters?.[0]?.user_id; if (userId !== undefined && usersMap[userId]) { nextUsersMap[userId] = usersMap[userId]; } }); usersMap = nextUsersMap; } function _mergeAndRenderTopics(fetchedTopics, { mode = "prepend", moreTopicsUrl = "", incomingCandidateIds = [], filterTopic = null, clearIncomingForQuery = null, resetFeedDepth = true, sortQuery = null, } = {}) { const shouldFilterTopics = mode !== "replace-head" && typeof filterTopic === "function"; const topics = shouldFilterTopics ? fetchedTopics.filter(filterTopic) : fetchedTopics; if (topics.length === 0) { if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds); _syncIncomingHeadAction(); return false; } const topicMap = new Map(topics.map((topic) => [topic.id, topic])); // 点击话题时会立即在 Resident Topic 上补已读状态,但并发刷新拿到的 // 列表快照可能仍是未读。合并前把这份单调状态带到新对象上,避免同 id // 的服务器快照覆盖刚刚发生的本地点击。 allTopics.forEach((residentTopic) => { const fetchedTopic = topicMap.get(residentTopic.id); if (fetchedTopic && _isTopicRead(residentTopic) && !_isTopicRead(fetchedTopic)) { _applyReadMarker(fetchedTopic); } }); let highlightTopicIds = []; if (resetFeedDepth) _resetLoadedFeedDepth(); if (mode === "replace-head") { highlightTopicIds = topics .filter((topic) => !loadedTopicIds.has(topic.id)) .map((topic) => topic.id); allTopics = topics.concat(allTopics.filter((topic) => !topicMap.has(topic.id))); // 异常话题按排序键插回原位次,避免被整段新鲜 page-0 压出首屏。 if (sortQuery) _interleaveUnavailableTopicsBySortKey(sortQuery, topics.length); hasMorePages = !!moreTopicsUrl; } else { highlightTopicIds = topics.map((topic) => topic.id); allTopics = topics.concat(allTopics.filter((topic) => !topicMap.has(topic.id))); if (resetFeedDepth) hasMorePages = allTopics.length >= Math.max(1, topicPageSize); } const newEntrantCount = topics.reduce( (count, topic) => (loadedTopicIds.has(topic.id) ? count : count + 1), 0 ); topics.forEach((topic) => loadedTopicIds.add(topic.id)); if (newEntrantCount > 0) _expireUnavailableTopicsByPush(newEntrantCount); _trimResidentTopicsAfterRefresh(); const scrollAnchor = _captureFeedScrollAnchor(); renderTopics(highlightTopicIds); if (clearIncomingForQuery) { _removeSidebarIncomingTopicsForQuery(clearIncomingForQuery); } else if (incomingCandidateIds.length) { _removeSidebarIncomingTopicIds(incomingCandidateIds); } _syncIncomingHeadAction(); _restoreFeedScrollAnchor(scrollAnchor); _syncHeadActionState(); return true; } async function _refreshCurrentView({ requireDefaultView = false, logPrefix = "refresh" } = {}) { 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; // 先用未经本地处理的原始 page-0 与旧数据比对,标记凭空消失的话题, // 再走合并渲染;被标记的旧条目会在合并的 retained 尾部保留下来。 _detectVanishedHeadTopics(data.topic_list.topics, requestQuery); const topics = _excludeTopReadPinnedTopics(data.topic_list.topics, requestQuery, 0); return _mergeAndRenderTopics(topics, { mode: "replace-head", moreTopicsUrl: data.topic_list.more_topics_url, clearIncomingForQuery: requestQuery, resetFeedDepth: true, sortQuery: requestQuery, }); } catch (e) { console.warn(`[SFP] ${logPrefix} error:`, e); return false; } finally { if (requestToken === activeRefreshToken) { isRefreshing = false; _resetAutoRefreshCountdown(); _flushQueuedSidebarIncomingApply(); _syncHeadActionState(); } endRefreshBusy(); } } // ========== 静默刷新 ========== // 仅在 latest/latest category 语义可匹配的最新活动视图中按 incoming 事件启用。 // 这条路径不重新拉整页,而是按 message-bus 收集到的 topic id 批量取详情, // 然后插入到当前列表顶部;这样能减少刷新时对阅读位置的扰动。 async function _applySidebarIncomingTopics({ requireDefaultView = false, logPrefix = "incoming", queueIfBusy = true, resetFeedDepth = true } = {}) { 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 incomingCandidateIds = await _refreshSidebarIncomingFilter(); const incomingTopicIds = _getSidebarIncomingLoadTopicIds(); if (incomingTopicIds.length === 0) { 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, filterTopic: (topic) => _topicMatchesIncomingView(topic, requestQuery), resetFeedDepth, }); } catch (e) { console.warn(`[SFP] ${logPrefix} error:`, e); } finally { if (requestToken === activeRefreshToken) { isRefreshing = false; _flushQueuedSidebarIncomingApply(); _syncHeadActionState(); } 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 && !_shouldSkipAutomaticRefresh()) { if (_canUseSidebarIncomingRefresh()) { _applySidebarIncomingTopics({ requireDefaultView: true, logPrefix: "auto silent refresh interval", queueIfBusy: false, }); } else { _refreshCurrentView({ logPrefix: "auto silent refresh interval", }); } } } }, 1000); } function _resetAutoSilentRefreshCountdown() { if (_isAutoSilentRefreshActive() && autoSilentRefreshInterval > 0) { autoSilentRefreshSeconds = autoSilentRefreshInterval; } } function _stopAutoSilentRefresh() { if (autoSilentRefreshTimer) { clearInterval(autoSilentRefreshTimer); autoSilentRefreshTimer = null; } } function _recordPageActivity() { lastPageActivityAt = Date.now(); } function _isPageIdleForAutoRefresh() { return document.visibilityState === "hidden" || Date.now() - lastPageActivityAt > AUTO_REFRESH_IDLE_LIMIT_MS; } function _shouldSkipAutomaticRefresh() { return _isPageIdleForAutoRefresh() || !_isAtFeedHead(); } function _setupPageActivityTracking() { const options = { passive: true }; ["pointerdown", "keydown", "wheel", "touchstart", "scroll"].forEach((eventName) => { window.addEventListener(eventName, _recordPageActivity, options); }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") _recordPageActivity(); }); } // ========== 自动刷新 ========== function _startAutoRefresh() { _stopAutoRefresh(); if (_isLatestActivityView()) return; if (!autoRefreshEnabled) return; if (!_isAtFeedHead()) return; // 非最新活动排序没有可靠 incoming 增量,只能按当前查询重新拉取列表头部。 // refreshCurrentView 会保存滚动锚点,尽量避免自动刷新把正在看的内容挤走。 _resetAutoRefreshCountdown(); autoRefreshTimer = setInterval(() => { autoRefreshSeconds--; if (autoRefreshSeconds <= 0) { _resetAutoRefreshCountdown(); if (feedModeEnabled && !isLoading && !isLoadingMore && !_shouldSkipAutomaticRefresh()) { _refreshCurrentView({ logPrefix: "auto refresh" }); } } }, 1000); } function _resetAutoRefreshCountdown() { if (autoRefreshEnabled) { autoRefreshSeconds = autoRefreshInterval; } } function _stopAutoRefresh() { if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } } 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(`

${escapeHtml(t("hot"))}

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

${escapeHtml(t("pinned"))}

`); } if (_isTopicUnavailable(topic)) { statusBadges.push(`${_svgIcon("far-eye-slash")}

${escapeHtml(t("topicUnavailable"))}

`); } return statusBadges.length ? `${statusBadges.join("")}` : ""; } function _topicTimeHtml(topic) { const timeStr = formatRelativeTime(topic.bumped_at || topic.last_posted_at || topic.created_at); const unreadDotClass = _hasUnreadMarker(topic) ? "sfp-unread-dot" : "sfp-unread-dot sfp-unread-dot--hidden"; const unreadDotHtml = ``; return `${timeStr}${unreadDotHtml}`; } // “话题异常”只由本地消失检测和 message-bus 生命周期事件标记,不读 // 列表 JSON 的 visible/deleted 等字段:staff 的列表会正常返回 // visible:false 的话题,那不是消失,不应打异常标。 function _isTopicUnavailable(topic) { return !!topic?.sfpUnavailable; } function _markTopicUnavailable(topic) { if (!topic || topic.sfpUnavailable) return false; topic.sfpUnavailable = true; topic.sfpUnavailablePushed = 0; return true; } // 异常话题的淘汰以“新返回条数”为基准:每次合并把真正新进入列表的 // 条数累加到每个异常话题上,相当于它被新内容压下去的深度;累计达到 // 当前保留话题数目(_residentTopicLimit,由首页条数和加载深度得出) // 时移除。早标记的话题累计更深、先过期,天然按 FIFO 清出。 function _expireUnavailableTopicsByPush(newEntrantCount) { if (newEntrantCount <= 0) return; const keepCount = _residentTopicLimit(); const expiredIds = new Set(); for (const topic of allTopics) { if (!topic?.sfpUnavailable) continue; topic.sfpUnavailablePushed = (topic.sfpUnavailablePushed || 0) + newEntrantCount; if (topic.sfpUnavailablePushed >= keepCount) expiredIds.add(topic.id); } if (expiredIds.size === 0) return; allTopics = allTopics.filter((topic) => !expiredIds.has(topic.id)); expiredIds.forEach((topicId) => loadedTopicIds.delete(topicId)); _rebuildUsersMapForResidentTopics(); } // 各排序视图在服务端的排序键。凭空消失检测用它比较“本应仍在 // page-0 窗口内的话题是否被更旧的条目顶替”:activity/created 是时间, // 其余是列表 JSON 的计数字段(op_like_count 仅出现在列表序列化中)。 function _feedSortKeyValue(topic, order) { if (!topic) return null; switch (order) { case "created": { const created = Date.parse(topic.created_at || ""); return Number.isFinite(created) ? created : null; } case "views": return Number.isFinite(topic.views) ? topic.views : null; case "posts": return Number.isFinite(topic.posts_count) ? topic.posts_count : null; case "likes": return Number.isFinite(topic.like_count) ? topic.like_count : null; case "op_likes": return Number.isFinite(topic.op_like_count) ? topic.op_like_count : null; case "activity": default: { const bumped = Date.parse(topic.bumped_at || ""); return Number.isFinite(bumped) ? bumped : null; } } } // replace-head 合并会把整段新鲜 page-0 拼在头部,被标记的异常话题若 // 任其留在 retained 尾部,即使两次刷新之间只有少量新内容,也会被压 // 到首屏之外看不见。这里按排序键把异常话题插回其应有位次(夹在老 // 邻居之间),维持“保留在原位置”的可见性。 function _interleaveUnavailableTopicsBySortKey(query, freshLength) { if (allTopics.length <= freshLength) return; const anomalies = []; for (let index = allTopics.length - 1; index >= freshLength; index--) { if (allTopics[index]?.sfpUnavailable) { anomalies.unshift(allTopics.splice(index, 1)[0]); } } if (anomalies.length === 0) return; // activity/default 排序中置顶会浮到头部且排序键可能很旧,插入扫描 // 时跳过它们,避免异常话题被插到置顶块之前。 const pinnedFloats = !query?.order || ["activity", "default"].includes(query.order); for (const anomaly of anomalies) { const keyValue = _feedSortKeyValue(anomaly, query.order); let insertIndex = 0; if (keyValue !== null) { while (insertIndex < allTopics.length) { const resident = allTopics[insertIndex]; if (pinnedFloats && (resident.pinned || resident.pinned_globally)) { insertIndex++; continue; } const residentKey = _feedSortKeyValue(resident, query.order); if (residentKey !== null && residentKey < keyValue) break; insertIndex++; } } allTopics.splice(insertIndex, 0, anomaly); } } // 与上一次的 page-0 数据比较,找出“凭空消失”的话题并标记为异常: // 本应仍在窗口内(排序键比新 page-0 中最旧的非置顶话题还新),却 // 不再被服务端返回。只做本地比对,不发请求确认具体状态。 // - 自然下沉:被更新的话题挤出窗口,排序键不会超过窗口内最旧值; // - 超出保留数被裁:trim 发生在检测之后,候选取自裁剪前的数据; // - 置顶话题:Discourse 只在 activity/default 排序让置顶浮动到顶部 // (lib/topic_query.rb apply_pinning),这些排序里置顶话题可能只是 // 被解除置顶而合法离开窗口,候选和见证都要跳过;其余排序置顶按 // 正常位次参与比较。 function _detectVanishedHeadTopics(rawTopics, query) { if (!Array.isArray(rawTopics) || rawTopics.length === 0) return; if (allTopics.length === 0) return; // 周期榜(top.json?period=...)按“周期内有活动”过滤成员,话题可能 // 只因活动滚出周期而离开窗口,排序键比较会误标,因此只在 period=all // 的纯排序(latest.json?order=...)上检测。 if (_needsPeriodForUrl(query.order) && query.period !== "all") return; const pinnedFloats = !query.order || ["activity", "default"].includes(query.order); const newTopicIds = new Set( rawTopics.map((topic) => Number(topic?.id)).filter(Number.isFinite) ); let windowMinKeyValue = null; for (const topic of rawTopics) { if (pinnedFloats && (topic?.pinned || topic?.pinned_globally)) continue; const keyValue = _feedSortKeyValue(topic, query.order); if (keyValue === null) continue; if (windowMinKeyValue === null || keyValue < windowMinKeyValue) { windowMinKeyValue = keyValue; } } // 窗口内没有可比较的话题时无法判断,宁可漏报也不误标。 if (windowMinKeyValue === null) return; // 只检查上一次 page-0 窗口内的条目;加载更多拉进来的深页条目 // 本来就不在 page-0 语义内,缺失是正常现象。 const candidateWindow = Math.min(rawTopics.length, allTopics.length); for (let index = 0; index < candidateWindow; index++) { const topic = allTopics[index]; if (!topic || newTopicIds.has(Number(topic.id))) continue; if (pinnedFloats && (topic.pinned || topic.pinned_globally)) continue; if (topic.sfpUnavailable) continue; const keyValue = _feedSortKeyValue(topic, query.order); // 排序键相同(同一秒/同一计数)时顺序不确定,只用严格更新的 // 键做判断,避免误标。 if (keyValue === null || keyValue <= windowMinKeyValue) continue; _markTopicUnavailable(topic); } } // ========== 渲染 ========== function renderTopics(newTopicIds = []) { if (!feedListEl) return; feedListEl.innerHTML = ""; if (allTopics.length === 0) { feedListEl.innerHTML = `
${escapeHtml(t("emptyTopics"))}
`; _renderPaginationFooter(); _syncHeadActionState(); return; } // 客户端筛选 let filtered = _applyFilter(allTopics); if (filtered.length === 0) { // 构建更精确的空状态消息 let emptyMsg = t("noMatchingTopics"); if (currentFilter === "unseen") { emptyMsg = currentTab !== "all" ? t("noUnreadInCategory") : t("noUnread"); } else if (currentFilter === "read") { emptyMsg = t("noRead"); } // 有数据但筛选后为空 → 显示当前页提示,分页控件由底部统一渲染 if (hasMorePages && !isLoadingMore) { feedListEl.innerHTML = `
${escapeHtml(t("currentPagePrefix") + emptyMsg)}
`; } else { feedListEl.innerHTML = `
${escapeHtml(emptyMsg)}
`; } } else { filtered.forEach((topic) => { const item = createTopicItem(topic, newTopicIds.includes(topic.id)); feedListEl.appendChild(item); }); } _renderPaginationFooter(); _syncHeadActionState(); } // ========== 话题 URL / 已读状态 / 客户端筛选 ========== function _topicBaseUrl(topic) { const slug = topic.slug || "topic"; return `/t/${slug}/${topic.id}`; } function _isPinnedTopic(topic) { return !!(topic && (topic.pinned || topic.pinned_globally)); } 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}`; } // 以脚本实际跳转链接为准:不带楼层号是未读,带楼层号说明有阅读进度。 // `unseen: false` 只表示这个话题不再是“新出现”,不能证明用户打开读过。 function _isTopicRead(topic) { if (!topic || !topic.id) return false; const baseUrl = _topicBaseUrl(topic); const url = _topicListUrl(topic); return url !== baseUrl && url.startsWith(`${baseUrl}/`); } 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.classList.add("sfp-unread-dot--hidden"); } // 未读/已读筛选复用 _isTopicRead,避免和渲染、点击后本地 patch 的语义分叉。 function _applyFilter(topics) { if (currentFilter === "all") return topics; let result = topics; 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("a"); const targetUrl = _topicListUrl(topic); item.className = "sfp-topic-item"; item.href = toAbsoluteSiteUrl(targetUrl); 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 (_isTopicUnavailable(topic)) { item.classList.add("sfp-topic-unavailable"); } if (isNew) { _triggerTopicHighlight(item); } // 获取用户信息 let avatarUrl = ""; let name = ""; let username = ""; let userProfileUrl = ""; 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 || ""; userProfileUrl = getUserProfileUrl(username); if (user.avatar_template) { avatarUrl = getAvatarUrl(user.avatar_template, 45); } } } // 头像 HTML const avatarHtml = userProfileUrl && avatarUrl ? `${escapeHtml(username)}` : (avatarUrl ? `${escapeHtml(username)}` : ""); // 显示名称 const displayName = name && name !== username ? (userProfileUrl ? `${escapeHtml(name)}` : `${escapeHtml(name)}`) : ""; const usernameHtml = userProfileUrl ? `${escapeHtml(username)}` : `${escapeHtml(username)}`; 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) => { const profileLink = e.target.closest?.("[data-user-profile-link='true']"); if (profileLink) { const profileUrl = profileLink.getAttribute("data-user-profile-url") || ""; handlePointerNavigation(e, profileUrl); return; } handlePointerNavigation(e, targetUrl, { onPrimaryActivate: () => markTopicAsRead(topic, item) }); }); // 中键使用原生链接打开新标签页,同时保持本地已读状态同步。 item.addEventListener("auxclick", (e) => { const profileLink = e.target.closest?.("[data-user-profile-link='true']"); if (profileLink) { const profileUrl = profileLink.getAttribute("data-user-profile-url") || ""; handlePointerNavigation(e, profileUrl); return; } handlePointerNavigation(e, targetUrl, { onMiddleActivate: () => markTopicAsRead(topic, item) }); }); return item; } // ========== 无限滚动加载 ========== function _setupScrollLoadMore() { if (!feedScrollEl) return; if (feedScrollAbortController) feedScrollAbortController.abort(); feedScrollAbortController = typeof AbortController === "function" ? new AbortController() : null; const listenerOptions = feedScrollAbortController ? { passive: true, signal: feedScrollAbortController.signal } : { passive: true }; // 边界隔离交给 .sfp-feed-scroll 的 overscroll-behavior / touch-action。 // 非 passive 的 wheel/touchmove 会让原生滚动等待主线程,放大站点长任务的影响。 feedScrollEl.addEventListener("scroll", _scheduleHeadActionStateSync, listenerOptions); feedScrollEl.addEventListener("scroll", debounce(() => { if (!feedScrollEl || !hasMorePages || isLoadingMore) return; const { scrollTop, scrollHeight, clientHeight } = feedScrollEl; if (scrollHeight - scrollTop - clientHeight < 200) { loadMoreTopics({ source: "auto" }); } }, 300), listenerOptions); } // ========== 加载更多辅助 ========== 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 = t("loadMore"); 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 = `${escapeHtml(t("loading"))}`; 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 = t("noMore"); 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 = ` ${escapeHtml(t("requestFailed"))} `; 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(); } } }); // 这里必须盯住 body:Discourse 关闭/打开原生侧边栏时,sidebar 节点可能在 // main-outlet / d-header 之外被重建;如果只观察局部容器,feed 恢复就会漏掉。 observer.observe(document.body, { childList: true, subtree: true }); } function _checkUrlChange() { const newUrl = location.href; if (newUrl !== lastUrl) { lastUrl = newUrl; if (feedModeEnabled) { _syncIncomingHeadAction(); } } } function stop() { if (observer) observer.disconnect(); } return { start, stop }; })(); function _setupCacheControlEntrypoints() { if (typeof GM_registerMenuCommand === "function") { GM_registerMenuCommand("SFP: 清空分类和标签缓存", () => clearDataCaches({ reload: true })); } try { const targetWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const api = targetWindow.SFPFeedPanel || {}; api.clearCaches = () => clearDataCaches({ reload: true }); targetWindow.SFPFeedPanel = api; } catch (e) { console.warn("[SFP] setup cache controls failed:", e); } } // ========== 初始化 ========== let globalHelpTooltip = null; function init() { injectStyles(); _setupCacheControlEntrypoints(); _setupPageActivityTracking(); globalHelpTooltip = document.createElement("div"); globalHelpTooltip.className = "sfp-help-tooltip"; globalHelpTooltip.setAttribute("role", "tooltip"); globalHelpTooltip.style.display = "none"; document.body.appendChild(globalHelpTooltip); waitForStableHeaderMount(() => { createToggle(); RouteWatcher.start(); if (feedModeEnabled) { setTimeout(() => activateFeed(), 300); } else { removeResizer(); restoreSidebarWidth(); } }); } init(); })();