// ==UserScript==
// @name Discourse Sidebar Feed Panel
// @namespace https://github.com/YsLtr
// @version 3.0.1
// @author YsLtr
// @description 将 Discourse 原生侧边栏改造为信息流面板,支持分类筛选、已读/未读过滤、拖拽调整宽度
// @license MIT
// @icon https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/discourse/24/148734_2.png
// @match https://linux.do/*
// @match https://www.nodeloc.com/*
// @match https://forum.chrultrabook.com/*
// @match https://community.openai.com/*
// @grant GM_addStyle
// @grant GM_deleteValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant unsafeWindow
// @run-at document-idle
// ==/UserScript==
(function() {
"use strict";
var __defProp = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
for (var name in all) __defProp(target, name, {
get: all[name],
enumerable: true
});
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
return target;
};
var 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 createI18n(getDiscourse) {
function getUiLocale() {
const language = [
document.documentElement?.getAttribute("lang"),
document.documentElement?.getAttribute("xml:lang"),
getDiscourse()?.SiteSettings?.default_locale,
navigator.language
].find((value) => String(value || "").trim()) || "en";
return /^zh/i.test(language) ? "zh-CN" : "en";
}
function t(key, params = {}) {
const template = (I18N[getUiLocale()] || I18N.en)[key] || I18N.en[key] || key;
return String(template).replace(/\{(\w+)\}/g, (_, name) => {
return params[name] === void 0 ? `{${name}}` : String(params[name]);
});
}
function formatRelativeTime(dateStr) {
const date = new Date(dateStr || "");
if (Number.isNaN(date.getTime())) return "";
const diff = Math.max(0, new Date().getTime() - date.getTime());
const seconds = Math.floor(diff / 1e3);
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)}年前`;
}
return {
getUiLocale,
t,
formatRelativeTime
};
}
var Lifetime = class {
cleanups = new Set();
timeouts = new Set();
frames = new Set();
ended = false;
aborter = new AbortController();
get disposed() {
return this.ended;
}
get signal() {
return this.aborter.signal;
}
defer(cleanup) {
if (this.ended) {
cleanup();
return () => {};
}
this.cleanups.add(cleanup);
return () => {
this.cleanups.delete(cleanup);
};
}
listen(target, type, fn, options) {
if (this.ended) return () => {};
target.addEventListener(type, fn, options);
const cleanup = () => target.removeEventListener(type, fn, options);
const forget = this.defer(cleanup);
return () => {
cleanup();
forget();
};
}
timeout(fn, ms) {
if (this.ended) return 0;
const id = window.setTimeout(() => {
this.timeouts.delete(id);
if (!this.ended) fn();
}, ms);
this.timeouts.add(id);
return id;
}
clearTimeout(id) {
window.clearTimeout(id);
this.timeouts.delete(id);
}
frame(fn) {
if (this.ended) return 0;
const id = window.requestAnimationFrame((time) => {
this.frames.delete(id);
if (!this.ended) fn(time);
});
this.frames.add(id);
return id;
}
cancelFrame(id) {
window.cancelAnimationFrame(id);
this.frames.delete(id);
}
dispose() {
if (this.ended) return;
this.ended = true;
this.aborter.abort();
for (const cleanup of [...this.cleanups].reverse()) cleanup();
this.cleanups.clear();
for (const id of this.timeouts) window.clearTimeout(id);
for (const id of this.frames) window.cancelAnimationFrame(id);
this.timeouts.clear();
this.frames.clear();
}
};
function createRouteWatcher(callbacks) {
let stopCurrent;
function stop() {
stopCurrent?.();
stopCurrent = void 0;
}
function start() {
if (stopCurrent) return;
let lastUrl = location.href;
let active = true;
const checkUrl = () => {
if (!active || location.href === lastUrl) return;
lastUrl = location.href;
callbacks.onRouteChange();
};
const originalPush = history.pushState;
const originalReplace = history.replaceState;
const push = function(...args) {
originalPush.apply(this, args);
checkUrl();
};
const replace = function(...args) {
originalReplace.apply(this, args);
checkUrl();
};
history.pushState = push;
history.replaceState = replace;
window.addEventListener("popstate", checkUrl);
const observer = new MutationObserver(() => {
if (!active) return;
checkUrl();
callbacks.onMutation();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
stopCurrent = () => {
active = false;
observer.disconnect();
window.removeEventListener("popstate", checkUrl);
if (history.pushState === push) history.pushState = originalPush;
if (history.replaceState === replace) history.replaceState = originalReplace;
};
}
return {
start,
stop
};
}
var storage_keys_exports = __exportAll({
AUTO_REFRESH_ENABLED_KEY: () => AUTO_REFRESH_ENABLED_KEY,
AUTO_REFRESH_INTERVAL_KEY: () => AUTO_REFRESH_INTERVAL_KEY,
AUTO_SILENT_REFRESH_INTERVAL_KEY: () => AUTO_SILENT_REFRESH_INTERVAL_KEY,
AUTO_SILENT_REFRESH_KEY: () => AUTO_SILENT_REFRESH_KEY,
CATEGORY_DATA_CACHE_KEY: () => CATEGORY_DATA_CACHE_KEY,
FILTER_KEY: () => FILTER_KEY,
HIDE_PINNED_KEY: () => HIDE_PINNED_KEY,
LEGACY_LINUXDO_ORIGIN: () => LEGACY_LINUXDO_ORIGIN,
ORDER_KEY: () => ORDER_KEY,
PERIOD_KEY: () => PERIOD_KEY,
SHOW_INCOMING_HINT_KEY: () => SHOW_INCOMING_HINT_KEY,
SITE_SCOPED_STORAGE_KEYS: () => SITE_SCOPED_STORAGE_KEYS,
SITE_STORAGE_PREFIX: () => SITE_STORAGE_PREFIX,
STATE_KEY: () => STATE_KEY,
STORAGE_MISSING: () => STORAGE_MISSING,
TAB_KEY: () => TAB_KEY,
TAB_ORDER_KEY: () => TAB_ORDER_KEY,
TAG_STYLE_CACHE_KEY: () => TAG_STYLE_CACHE_KEY,
WIDTH_KEY: () => WIDTH_KEY
});
var STATE_KEY = "sfp_feed_mode_enabled";
var ORDER_KEY = "sfp_current_order";
var PERIOD_KEY = "sfp_current_period";
var WIDTH_KEY = "sfp_sidebar_width";
var TAB_KEY = "sfp_current_tab";
var TAB_ORDER_KEY = "sfp_tab_order";
var FILTER_KEY = "sfp_current_filter";
var HIDE_PINNED_KEY = "sfp_activity_hide_pinned";
var SHOW_INCOMING_HINT_KEY = "sfp_show_incoming_hint";
var AUTO_SILENT_REFRESH_KEY = "sfp_auto_silent_refresh";
var AUTO_SILENT_REFRESH_INTERVAL_KEY = "sfp_auto_silent_refresh_interval";
var AUTO_REFRESH_ENABLED_KEY = "sfp_auto_refresh_enabled";
var AUTO_REFRESH_INTERVAL_KEY = "sfp_auto_refresh_interval";
var CATEGORY_DATA_CACHE_KEY = "sfp_category_data_cache_v1";
var TAG_STYLE_CACHE_KEY = "sfp_tag_style_cache_v1";
var SITE_STORAGE_PREFIX = "sfp_site";
var LEGACY_LINUXDO_ORIGIN = "https://linux.do";
var STORAGE_MISSING = "__SFP_STORAGE_MISSING__";
var 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
];
var AUTO_LOAD_RATE_WINDOW_MS = 5e3;
function buildCategoryPath(categoryId, getCategoryMeta) {
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((slug) => !!slug);
if (slugs.length === 0) slugs.push("category");
return `/c/${slugs.map((slug) => encodeURIComponent(slug)).join("/")}/${targetCategoryId}`;
}
function _needsPeriodForUrl(order) {
return [
"views",
"posts",
"likes",
"op_likes"
].includes(order);
}
function _usesPeriodScopedTopList(order, period) {
return period !== "all" && _needsPeriodForUrl(order);
}
function feedQueryKey(query) {
return [
query.tab,
query.categoryId || "",
query.order,
query.period,
query.filter
].join("|");
}
function buildFeedUrl(query, page, getCategoryMeta) {
const useTopList = _usesPeriodScopedTopList(query.order, query.period);
if (query.tab !== "all" && query.categoryId) {
const categoryPath = buildCategoryPath(query.categoryId, getCategoryMeta);
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) return `/top.json?${[
`period=${encodeURIComponent(query.period)}`,
`order=${encodeURIComponent(query.order)}`,
`page=${page}`
].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("&")}`;
}
function objectPayload(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
var SAFE_ICON_RE = /^[A-Za-z0-9_-]+$/;
var 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";
}
var 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"
}
];
var 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"
}
];
var FILTER_OPTION_DEFS = [
{
labelKey: "filterAll",
value: "all"
},
{
labelKey: "filterUnseen",
value: "unseen"
},
{
labelKey: "filterRead",
value: "read"
}
];
function createSiteData({ storage, getCsrfToken, onChange }) {
let scope = new Lifetime();
const _getSiteValue = storage.get;
const _setSiteValue = storage.set;
const categoryMetaById = new Map();
let tabCategories = [];
let siteCapabilities = _createDefaultSiteCapabilities();
let categoryMetaPromise = null;
let categoryMetaLoaded = false;
let siteDataPromise = null;
let siteDataLoaded = false;
let siteDataCache = null;
let categoriesAndLatestPromise = null;
let categoriesAndLatestLoaded = false;
let categoriesAndLatestCache = null;
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 _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);
if ((String(record.type || record.section_type || "").toLowerCase() === "category" || record.topic_count !== void 0 || record.parent_category_id !== void 0 || record.read_restricted !== void 0) && record.id !== void 0) 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 _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);
onChange();
if (primeSiteData && !siteDataLoaded) {
siteDataCache = site;
siteDataLoaded = true;
}
categoryMetaLoaded = true;
}
function _loadCategoryDataCache() {
try {
const cache = _getSiteValue(CATEGORY_DATA_CACHE_KEY, null);
if (!cache || cache.version !== 1 || !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: 1,
savedAt: Date.now(),
source
});
} catch (e) {
console.warn("[SFP] save category data cache failed:", e);
}
}
function _resetRuntimeCategoryData() {
scope.dispose();
scope = new Lifetime();
categoryMetaPromise = null;
categoryMetaLoaded = false;
categoryMetaById.clear();
tabCategories = [];
siteDataPromise = null;
siteDataLoaded = false;
siteDataCache = null;
categoriesAndLatestPromise = null;
categoriesAndLatestLoaded = false;
categoriesAndLatestCache = null;
}
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 _parsePreloadedPayload(raw) {
if (!raw) return null;
try {
const decoded = raw.startsWith("%") ? decodeURIComponent(raw) : raw;
return objectPayload(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() {
scope.signal.throwIfAborted();
if (siteDataLoaded) return siteDataCache;
if (siteDataPromise) return siteDataPromise;
const loadScope = scope;
siteDataPromise = (async () => {
const preloaded = _extractPreloadedSiteData();
if (preloaded) {
siteDataCache = preloaded;
siteDataLoaded = true;
return siteDataCache;
}
const resp = await fetch("/site.json", {
signal: loadScope.signal,
headers: { "X-CSRF-Token": getCsrfToken() }
});
if (!resp.ok) throw new Error(`site.json ${resp.status}`);
const data = await resp.json();
loadScope.signal.throwIfAborted();
siteDataCache = objectPayload(data);
siteDataLoaded = true;
return siteDataCache;
})().finally(() => {
if (loadScope === scope) siteDataPromise = null;
});
return siteDataPromise;
}
async function loadCategoriesAndLatestData() {
scope.signal.throwIfAborted();
if (categoriesAndLatestLoaded) return categoriesAndLatestCache;
if (categoriesAndLatestPromise) return categoriesAndLatestPromise;
const loadScope = scope;
categoriesAndLatestPromise = (async () => {
const resp = await fetch("/categories_and_latest.json", {
signal: loadScope.signal,
headers: { "X-CSRF-Token": getCsrfToken() }
});
if (!resp.ok) throw new Error(`categories_and_latest.json ${resp.status}`);
const data = await resp.json();
loadScope.signal.throwIfAborted();
categoriesAndLatestCache = objectPayload(data);
categoriesAndLatestLoaded = true;
return categoriesAndLatestCache;
})().finally(() => {
if (loadScope === scope) categoriesAndLatestPromise = null;
});
return categoriesAndLatestPromise;
}
async function loadCategoryMetadata() {
scope.signal.throwIfAborted();
if (categoryMetaLoaded) return;
if (categoryMetaPromise) return categoryMetaPromise;
const loadScope = scope;
categoryMetaPromise = (async () => {
try {
const cachedSource = _loadCategoryDataCache();
if (cachedSource) {
_applyCategoryDataSource(cachedSource, { primeSiteData: true });
return;
}
const [siteResult, categoriesAndLatestResult] = await Promise.allSettled([loadSiteData(), loadCategoriesAndLatestData()]);
loadScope.signal.throwIfAborted();
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) {
if (loadScope.disposed) return;
console.warn("[SFP] load category metadata failed:", e);
tabCategories = [];
onChange();
categoryMetaLoaded = true;
} finally {
if (loadScope === scope) categoryMetaPromise = null;
}
})();
return categoryMetaPromise;
}
return {
get loaded() {
return categoryMetaLoaded;
},
get tabCategories() {
return tabCategories;
},
get capabilities() {
return siteCapabilities;
},
_getCategoryMeta,
_findTabCategoryByTabId,
getCategoryTabMeta,
_getOrderedTabCategories,
_extractPreloadedSiteData,
loadSiteData,
loadCategoryMetadata,
reset: _resetRuntimeCategoryData,
dispose: () => scope.dispose()
};
}
function closestTarget(event, selector) {
return event.target instanceof Element ? event.target.closest(selector) : null;
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = String(text);
return div.innerHTML;
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'");
}
function _svgIcon(icon, extraClass = "") {
const safeIcon = _safeIconName(icon);
if (!safeIcon) return "";
return ``;
}
function _categoryColorMarkerHtml(color) {
return ``;
}
function createControls({ t, prefs, site, host, getScope, updatePreference, saveTabOrder, getCategoryId, setCategoryId, _resetAutoLoadState, loadTopics, _beginSidebarIncomingViewSettling, _syncDefaultViewControls, _syncRefreshButtonBusy, _handleHeadActionClick, _syncSidebarIncomingTracking, _syncHeadActionState, _isAutoSilentRefreshActive, _queueSidebarIncomingApply, _startAutoSilentRefresh, _startAutoRefresh, _isLatestActivityView, renderTopics, _finishSidebarIncomingViewSettling }) {
const { getCategoryTabMeta, _getOrderedTabCategories } = site;
let feedRefreshBtn = null;
let globalHelpTooltip = null;
let pendingTabBarScrollTab = null;
const requestAnimationFrame = (fn) => getScope().frame(fn);
const setTimeout = (fn, ms) => getScope().timeout(fn, ms);
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 _getOrderOptions() {
return ORDER_OPTION_DEFS.filter((option) => site.capabilities.orderValues.has(option.value)).map((option) => ({
label: t(option.labelKey),
value: option.value
}));
}
function _getPeriodOptions() {
return PERIOD_OPTION_DEFS.filter((option) => site.capabilities.periodValues.has(option.value)).map((option) => ({
label: t(option.labelKey),
value: option.value
}));
}
function _getFilterOptions() {
return FILTER_OPTION_DEFS.filter((option) => site.capabilities.filterValues.has(option.value)).map((option) => ({
label: t(option.labelKey),
value: option.value
}));
}
function _saveTabOrderFromGrid(grid) {
saveTabOrder(Array.from(grid.querySelectorAll(".sfp-tab-grid-item[data-category-id]")).map((item) => Number(item.dataset.categoryId)).filter((id) => Number.isFinite(id)));
}
function _buildCategoryTabContent(cat) {
const tabMeta = getCategoryTabMeta(cat);
return `${tabMeta.icon ? _svgIcon(tabMeta.icon) : _categoryColorMarkerHtml(tabMeta.color)}${escapeHtml(tabMeta.name)}`;
}
function _cssEscape(value) {
return typeof window.CSS?.escape === "function" ? 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 = prefs.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 _buildHeaderControls(header) {
const orderOptions = _getOrderOptions();
const periodOptions = _getPeriodOptions();
const periodSelect = _buildCustomSelect(periodOptions, prefs.currentPeriod, (value) => {
updatePreference("currentPeriod", value);
_resetAutoLoadState();
loadTopics();
});
periodSelect.classList.add("sfp-period-select");
_updatePeriodVisibility(periodSelect);
const orderSelect = _buildCustomSelect(orderOptions, prefs.currentOrder, (value) => {
updatePreference("currentOrder", value);
_updatePeriodVisibility(periodSelect);
_beginSidebarIncomingViewSettling();
_syncDefaultViewControls();
_resetAutoLoadState();
loadTopics();
});
orderSelect.classList.add("sfp-order-select");
function _updatePeriodVisibility(ps) {
ps.style.display = _needsPeriodForUrl(prefs.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";
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) => {
updatePreference("showIncomingHint", checked);
_syncSettingsPanelState(wrapper);
_syncSidebarIncomingTracking();
_syncHeadActionState();
if (_isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval === 0) _queueSidebarIncomingApply();
});
const hidePinnedInput = panel.querySelector(".sfp-hide-pinned-input");
if (hidePinnedInput) _bindCheckboxSetting(hidePinnedInput, (checked) => {
updatePreference("hidePinned", checked);
_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) => {
updatePreference("autoSilentRefreshEnabled", checked);
_syncSettingsPanelState(wrapper);
_syncSidebarIncomingTracking();
_startAutoSilentRefresh();
_syncHeadActionState();
if (_isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval === 0) _queueSidebarIncomingApply();
});
_bindNumberSetting(silentIntervalInput, 0, 0, (seconds) => {
updatePreference("autoSilentRefreshInterval", seconds);
_startAutoSilentRefresh();
_syncHeadActionState();
if (_isAutoSilentRefreshActive() && prefs.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) => {
updatePreference("autoRefreshEnabled", checked);
intervalRow?.classList.toggle("visible", prefs.autoRefreshEnabled);
_syncSettingsPanelHeight(wrapper);
_startAutoRefresh();
_syncHeadActionState();
});
_bindNumberSetting(intervalInput, 1, 10, (seconds) => {
updatePreference("autoRefreshInterval", seconds);
_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 = prefs.showIncomingHint;
if (hidePinnedInput) hidePinnedInput.checked = prefs.hidePinned;
if (autoSilentInput) autoSilentInput.checked = prefs.autoSilentRefreshEnabled;
if (autoRefreshInput) autoRefreshInput.checked = prefs.autoRefreshEnabled;
if (autoSilentRow) autoSilentRow.classList.remove("hidden");
if (autoSilentIntervalRow) {
autoSilentIntervalRow.classList.remove("hidden");
autoSilentIntervalRow.classList.toggle("visible", prefs.autoSilentRefreshEnabled);
}
if (autoRefreshIntervalRow) autoRefreshIntervalRow.classList.toggle("visible", prefs.autoRefreshEnabled);
_syncSettingsPanelHeight(wrapper);
}
function _syncSettingsPanelHeight(wrapper) {
const shell = wrapper?.querySelector(".sfp-settings-shell");
const panel = wrapper?.querySelector(".sfp-settings-panel");
if (!shell || !panel) return;
requestAnimationFrame(() => {
const contentBottom = Array.from(panel.children).filter((child) => {
return child instanceof HTMLElement && getComputedStyle(child).display !== "none";
}).reduce((bottom, row) => {
return Math.max(bottom, row.offsetTop + row.offsetHeight);
}, 28);
const panelStyle = getComputedStyle(panel);
const paddingBottom = Number.parseFloat(panelStyle.paddingBottom) || 0;
const height = Math.max(28, 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 = String(nextValue);
onChange(nextValue);
});
}
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";
btn.textContent = (safeOptions.find((o) => o.value === selectedValue) || safeOptions[0]).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;
}
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 = `
`;
const grid = panel.querySelector(".sfp-tab-grid");
const allTab = document.createElement("span");
allTab.className = "sfp-tab-item" + (prefs.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" + (prefs.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" + (prefs.currentTab === cat.tabId ? " active" : "");
tab.dataset.tab = cat.tabId;
tab.dataset.categoryId = String(cat.id);
tab.innerHTML = _buildCategoryTabContent(cat);
bar.appendChild(tab);
const gridItem = document.createElement("span");
gridItem.className = "sfp-tab-grid-item" + (prefs.currentTab === cat.tabId ? " active" : "");
gridItem.draggable = true;
gridItem.dataset.tab = cat.tabId;
gridItem.dataset.categoryId = String(cat.id);
gridItem.title = getCategoryTabMeta(cat).name;
gridItem.innerHTML = _buildCategoryTabContent(cat);
grid.appendChild(gridItem);
});
shell.addEventListener("click", (e) => {
e.stopPropagation();
_closeFloatingPanels(shell);
if (closestTarget(e, ".sfp-tab-panel-close")) {
e.stopPropagation();
shell.classList.remove("open");
return;
}
if (closestTarget(e, ".sfp-tab-more-btn")) {
e.stopPropagation();
shell.classList.toggle("open", !shell.classList.contains("open"));
return;
}
const tab = closestTarget(e, ".sfp-tab-item, .sfp-tab-grid-item");
if (!tab) return;
const tabId = tab.dataset.tab || "all";
const catId = tab.dataset.categoryId ? Number(tab.dataset.categoryId) : null;
const fromGrid = tab.classList.contains("sfp-tab-grid-item");
if (tabId === prefs.currentTab && catId === getCategoryId()) {
if (fromGrid) {
shell.classList.remove("open");
_scrollTabIntoView(shell, tabId, "smooth");
}
return;
}
updatePreference("currentTab", tabId);
setCategoryId(catId);
_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 = closestTarget(e, ".sfp-tab-grid-item[data-category-id]");
if (!item) return;
dragItem = item;
tabOrderChanged = false;
item.classList.add("dragging");
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
e.dataTransfer?.setData("text/plain", item.dataset.categoryId || "");
});
grid.addEventListener("dragover", (e) => {
if (!dragItem) return;
const target = closestTarget(e, ".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 nextNode = e.clientY < rect.top + rect.height / 2 ? 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 scrollLeft = oldShell.querySelector(".sfp-tab-bar")?.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 || void 0, options.scrollBehavior || "auto");
});
else if (newBar) newBar.scrollLeft = scrollLeft;
return newShell;
}
function _buildFilterBar() {
const bar = document.createElement("div");
bar.className = "sfp-filter-bar";
_getFilterOptions().forEach((f) => {
const item = document.createElement("span");
item.className = "sfp-filter-item" + (prefs.currentFilter === f.value ? " active" : "");
item.dataset.filter = f.value;
item.textContent = f.label;
bar.appendChild(item);
});
bar.addEventListener("click", (e) => {
const item = closestTarget(e, ".sfp-filter-item");
if (!item) return;
const filterVal = item.dataset.filter || "all";
if (filterVal === prefs.currentFilter) return;
updatePreference("currentFilter", filterVal);
_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 _refreshCategoryTabs() {
const shell = host.feedContainer?.querySelector(".sfp-tab-shell");
if (!shell) return;
const scrollTabId = pendingTabBarScrollTab;
pendingTabBarScrollTab = null;
_rerenderTabBar(shell, {
scrollTabId,
scrollBehavior: scrollTabId ? "smooth" : "auto"
});
}
function _updateSettingsControl() {
if (!host.feedHeaderEl) return;
const oldSettings = host.feedHeaderEl.querySelector(".sfp-settings-wrap");
if (!oldSettings) return;
if (!!oldSettings.querySelector(".sfp-incoming-hint-row") === _isLatestActivityView()) {
_syncSettingsPanelState(oldSettings);
return;
}
const isOpen = oldSettings.classList.contains("open");
const nextSettings = _buildSettingsControl();
if (isOpen) nextSettings.classList.add("open");
oldSettings.replaceWith(nextSettings);
}
function _formatIncomingCount(count) {
return count > 999 ? "999" : String(count);
}
function _refreshIconHtml() {
return ``;
}
function _backTopIconHtml() {
return ``;
}
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 start() {
if (globalHelpTooltip) return;
globalHelpTooltip = document.createElement("div");
globalHelpTooltip.className = "sfp-help-tooltip";
globalHelpTooltip.setAttribute("role", "tooltip");
globalHelpTooltip.style.display = "none";
document.body.append(globalHelpTooltip);
}
function reset() {
_closeFloatingPanels();
feedRefreshBtn = null;
pendingTabBarScrollTab = null;
}
function dispose() {
reset();
globalHelpTooltip?.remove();
globalHelpTooltip = null;
}
function renderHeadAction(isAway, isBusy, incomingCount) {
if (!feedRefreshBtn) return;
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");
}
function setBusy(isBusy) {
if (!feedRefreshBtn) return false;
const wasBusy = feedRefreshBtn.classList.contains("spinning");
feedRefreshBtn.classList.toggle("spinning", isBusy);
feedRefreshBtn.setAttribute("aria-busy", String(isBusy));
return wasBusy && !isBusy;
}
return {
start,
reset,
dispose,
renderHeadAction,
setBusy,
get feedRefreshBtn() {
return feedRefreshBtn;
},
_getOrderOptions,
_getPeriodOptions,
_getFilterOptions,
_closeFloatingPanels,
_buildHeaderControls,
_buildTabBar,
_rerenderTabBar,
_buildFilterBar,
_refreshCategoryTabs,
_updateSettingsControl
};
}
function createFeedHost({ t, getEnabled, getWidth, onToggle, onWidth, getScope }) {
let toggleBtn = null;
let resizerEl = null;
let isResizing = false;
let originalSidebarWidthBeforeFeed = null;
let widthAnimationTimer = null;
let feedContainer = null;
let feedHeaderEl = null;
let feedScrollEl = null;
let feedListEl = null;
const requestAnimationFrame = (fn) => getScope().frame(fn);
const setTimeout = (fn, ms) => getScope().timeout(fn, ms);
function createToggle() {
if (toggleBtn?.isConnected) return toggleBtn;
toggleBtn?.remove();
const homeLogo = document.querySelector(".home-logo-wrapper-outlet");
if (!homeLogo) return null;
toggleBtn = document.createElement("button");
toggleBtn.className = "sfp-toggle-btn" + (getEnabled() ? " active" : "");
toggleBtn.title = t("toggleTitle");
toggleBtn.innerHTML = ``;
toggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
e.preventDefault();
onToggle();
toggleBtn?.classList.toggle("active", getEnabled());
});
const titleEl = homeLogo.querySelector(".title");
if (titleEl) titleEl.appendChild(toggleBtn);
else homeLogo.appendChild(toggleBtn);
return toggleBtn;
}
function getMinSidebarWidth() {
return 272;
}
function getSidebarElement() {
return document.querySelector("#d-sidebar") || document.querySelector(".sidebar-container");
}
function applySidebarWidth(width) {
const clampedWidth = Math.min(500, Math.max(getMinSidebarWidth(), width));
const sidebar = getSidebarElement();
if (sidebar) sidebar.style.setProperty("width", clampedWidth + "px", "important");
document.documentElement.style.setProperty("--d-sidebar-width", clampedWidth + "px");
}
function getSidebarWidthTransitionElements(sidebar) {
return [sidebar, sidebar?.classList?.contains("sidebar-wrapper") ? sidebar : sidebar?.closest(".sidebar-wrapper")].filter((el) => !!el);
}
function setSidebarWidthForAnimation(sidebar, width, { enforceMin = true } = {}) {
const minWidth = enforceMin ? getMinSidebarWidth() : 0;
const clampedWidth = Math.min(500, 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) {
getScope().clearTimeout(widthAnimationTimer);
widthAnimationTimer = null;
}
const startWidth = sidebar.getBoundingClientRect().width || 272;
const minWidth = enforceMin ? getMinSidebarWidth() : 0;
const clampedTarget = Math.min(500, Math.max(minWidth, targetWidth));
const transitionEls = getSidebarWidthTransitionElements(sidebar);
setSidebarWidthForAnimation(sidebar, startWidth, { enforceMin: false });
transitionEls.forEach((el) => el.classList.add("sfp-width-animating"));
requestAnimationFrame(() => {
setSidebarWidthForAnimation(sidebar, clampedTarget, { enforceMin });
widthAnimationTimer = 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");
getSidebarWidthTransitionElements(sidebar).forEach((el) => el.classList.remove("sfp-width-animating"));
}
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) => {
if (isResizing) return;
e.preventDefault();
e.stopPropagation();
isResizing = true;
const startX = e.clientX;
const startWidth = sidebar.offsetWidth;
let finalWidth = getWidth();
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(500, Math.max(getMinSidebarWidth(), startWidth + delta));
applySidebarWidth(newWidth);
finalWidth = newWidth;
};
const onMouseUp = () => {
onWidth(finalWidth);
cleanup();
forget();
};
const removeMove = getScope().listen(document, "mousemove", onMouseMove);
const removeUp = getScope().listen(document, "mouseup", onMouseUp);
const cleanup = () => {
isResizing = false;
resizerEl?.classList.remove("sfp-resizing");
document.body.style.cursor = "";
document.body.style.userSelect = "";
removeMove();
removeUp();
};
const forget = getScope().defer(cleanup);
});
}
function removeResizer() {
if (resizerEl) {
resizerEl.remove();
resizerEl = null;
}
getSidebarElement()?.querySelectorAll(":scope > .sfp-resizer").forEach((el) => el.remove());
}
function ensure() {
const sidebar = getSidebarElement();
if (!sidebar) return;
if (!sidebar.classList.contains("sfp-feed-mode") || originalSidebarWidthBeforeFeed === null) originalSidebarWidthBeforeFeed = sidebar.getBoundingClientRect().width || 272;
sidebar.classList.add("sfp-feed-mode");
animateSidebarWidth(getWidth());
setupResizer();
}
function mount(buildHeader, buildTabs, buildFilter) {
const sidebar = getSidebarElement();
if (!sidebar) return false;
feedContainer = document.createElement("div");
feedContainer.className = "sfp-feed-container";
feedHeaderEl = document.createElement("div");
feedHeaderEl.className = "sfp-feed-header";
buildHeader(feedHeaderEl);
feedContainer.append(feedHeaderEl, buildTabs(), buildFilter());
feedScrollEl = document.createElement("div");
feedScrollEl.className = "sfp-feed-scroll";
const wrapper = document.createElement("div");
wrapper.className = "sfp-content-wrapper";
feedListEl = document.createElement("div");
feedListEl.className = "sfp-topic-list";
wrapper.append(feedListEl);
feedScrollEl.append(wrapper);
feedContainer.append(feedScrollEl);
sidebar.append(feedContainer);
ensure();
return true;
}
function unmount() {
feedContainer?.remove();
feedContainer = feedHeaderEl = feedScrollEl = feedListEl = null;
getSidebarElement()?.classList.remove("sfp-feed-mode");
removeResizer();
animateSidebarWidth(originalSidebarWidthBeforeFeed || 272, {
cleanupAfter: true,
enforceMin: false
});
originalSidebarWidthBeforeFeed = null;
}
function dispose() {
toggleBtn?.remove();
toggleBtn = null;
unmount();
restoreSidebarWidth();
}
return {
createToggle,
getSidebarElement,
removeResizer,
restoreSidebarWidth,
ensure,
mount,
unmount,
dispose,
get feedContainer() {
return feedContainer;
},
get feedHeaderEl() {
return feedHeaderEl;
},
get feedScrollEl() {
return feedScrollEl;
},
get feedListEl() {
return feedListEl;
},
get resizerEl() {
return resizerEl;
}
};
}
function createFeedScroll(callbacks) {
let release;
function dispose() {
release?.();
release = void 0;
}
function bind(element) {
dispose();
if (!element) return;
let timer;
const onLoadScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
timer = void 0;
if (!callbacks.canLoadMore()) return;
const { scrollTop, scrollHeight, clientHeight } = element;
if (scrollHeight - scrollTop - clientHeight < 200) callbacks.onLoadMore();
}, 300);
};
element.addEventListener("scroll", callbacks.onReadingState, { passive: true });
element.addEventListener("scroll", onLoadScroll, { passive: true });
release = () => {
clearTimeout(timer);
element.removeEventListener("scroll", callbacks.onReadingState);
element.removeEventListener("scroll", onLoadScroll);
};
}
return {
bind,
dispose
};
}
function _topicBaseUrl(topic) {
return `/t/${topic.slug || "topic"}/${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 !== void 0 && 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}`;
}
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 createTopicRenderer({ t, formatRelativeTime, site, tags, discourse, getUser, markTopicAsRead, getScope }) {
const { _getCategoryMeta } = site;
const { _tagDisplayName, _getTagStyle } = tags;
const { toAbsoluteSiteUrl, getAvatarUrl, getUserProfileUrl, handlePointerNavigation } = discourse;
const topicHighlightTimers = new WeakMap();
const setTimeout = (fn, delay) => getScope().timeout(fn, delay);
function _triggerTopicHighlight(item) {
if (!item) return;
const oldTimer = topicHighlightTimers.get(item);
if (oldTimer) getScope().clearTimeout(oldTimer);
item.classList.remove("sfp-new-highlight");
item.offsetWidth;
item.classList.add("sfp-new-highlight");
const timer = setTimeout(() => {
item.classList.remove("sfp-new-highlight");
topicHighlightTimers.delete(item);
}, 1e4);
topicHighlightTimers.set(item, timer);
}
function _topicStatsHtml(topic) {
const replies = Math.max(0, (topic.posts_count || 1) - 1);
const views = (topic.views || 0) >= 1e3 ? ((topic.views || 0) / 1e3).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) {
return `${formatRelativeTime(topic.bumped_at || topic.last_posted_at || topic.created_at)}${``}`;
}
function _isTopicUnavailable(topic) {
return !!topic?.sfpUnavailable;
}
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 = String(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 = getUser(userId);
if (user) {
name = user.name || "";
username = user.username || "";
userProfileUrl = getUserProfileUrl(username);
if (user.avatar_template) avatarUrl = getAvatarUrl(user.avatar_template, 45);
}
}
const avatarHtml = userProfileUrl && avatarUrl ? `
` : avatarUrl ? `
` : "";
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) tagsHtml = `${topic.tags.slice(0, 3).map((tag) => {
return _buildTagBadge(tag);
}).join("")}`;
item.innerHTML = `
${closedHtml}${escapeHtml(topic.unicode_title?.trim() || topic.title)}
${categoryHtml}
${tagsHtml}
${_topicStatsHtml(topic)}
`;
item.addEventListener("click", (e) => {
const profileLink = closestTarget(e, "[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 = closestTarget(e, "[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;
}
return {
createTopicItem,
markRead(item) {
item.classList.add("sfp-read");
item.querySelector(".sfp-unread-dot")?.classList.add("sfp-unread-dot--hidden");
}
};
}
function createTopicList({ getScroll, getList, getScope, syncHead, atHead, t, hasMore, loadMore, renderItem }) {
const requestAnimationFrame = (fn) => getScope().frame(fn);
function _captureFeedScrollAnchor() {
const feedScrollEl = getScroll();
const feedListEl = getList();
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 || null,
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) {
const feedScrollEl = getScroll();
const feedListEl = getList();
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;
syncHead();
return;
}
}
feedScrollEl.scrollTop = anchor.scrollTop + (feedScrollEl.scrollHeight - anchor.scrollHeight);
syncHead();
};
restore();
requestAnimationFrame(restore);
}
function _waitForFeedScrollHead(timeoutMs = 1200) {
const feedScrollEl = getScroll();
const viewScope = getScope();
if (!feedScrollEl) return Promise.resolve("interrupted");
if (atHead()) return Promise.resolve("reached");
const scrollElement = feedScrollEl;
return new Promise((resolve) => {
let forget = () => {};
const startedAt = Date.now();
let settledFrames = 0;
let done = false;
const cleanup = () => {
if (done) return;
done = true;
scrollElement.removeEventListener("scroll", onScroll);
scrollElement.removeEventListener("wheel", onInterrupt);
scrollElement.removeEventListener("pointerdown", onInterrupt);
scrollElement.removeEventListener("touchstart", onInterrupt);
window.removeEventListener("keydown", onInterrupt, true);
};
const finish = (result) => {
if (done) return;
forget();
cleanup();
resolve(result);
};
const onInterrupt = () => {
if (feedScrollEl) feedScrollEl.scrollTo({
top: feedScrollEl.scrollTop,
behavior: "auto"
});
finish(atHead() ? "reached" : "interrupted");
};
const onScroll = () => {
if (!feedScrollEl) {
finish("interrupted");
return;
}
if (atHead()) {
settledFrames++;
if (settledFrames >= 2) finish("reached");
} else settledFrames = 0;
};
const tick = () => {
if (done) return;
onScroll();
if (done) return;
if (Date.now() - startedAt >= timeoutMs) {
finish(atHead() ? "reached" : "timeout");
return;
}
requestAnimationFrame(tick);
};
forget = viewScope.defer(() => finish("interrupted"));
scrollElement.addEventListener("scroll", onScroll, { passive: true });
scrollElement.addEventListener("wheel", onInterrupt, { passive: true });
scrollElement.addEventListener("pointerdown", onInterrupt, { passive: true });
scrollElement.addEventListener("touchstart", onInterrupt, { passive: true });
window.addEventListener("keydown", onInterrupt, true);
requestAnimationFrame(tick);
});
}
function _renderPaginationFooter({ note = "" } = {}) {
const list = getList();
if (!list) return;
_removePaginationFooter();
if (note) {
const noteEl = document.createElement("div");
noteEl.className = "sfp-load-more-note";
noteEl.textContent = note;
list.appendChild(noteEl);
}
if (hasMore()) {
const loadMoreEl = document.createElement("div");
loadMoreEl.className = "sfp-load-more";
loadMoreEl.textContent = t("loadMore");
loadMoreEl.addEventListener("click", () => {
loadMoreEl.remove();
loadMore();
});
list.appendChild(loadMoreEl);
return;
}
_appendNoMore();
}
function _showLoadMoreSpinner() {
const list = getList();
_removePaginationFooter();
const el = document.createElement("div");
el.className = "sfp-load-more";
el.innerHTML = `${escapeHtml(t("loading"))}`;
if (list) list.appendChild(el);
}
function _removePaginationFooter() {
getList()?.querySelectorAll(".sfp-load-more, .sfp-no-more, .sfp-load-more-note").forEach((el) => el.remove());
}
function _showNoMore() {
_removePaginationFooter();
_appendNoMore();
}
function _appendNoMore() {
const list = getList();
const el = document.createElement("div");
el.className = "sfp-no-more";
el.textContent = t("noMore");
if (list) list.appendChild(el);
}
function _showLoadMoreError(error) {
const list = getList();
_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", () => {
loadMore();
});
if (list) list.appendChild(el);
console.warn("[SFP] load more failed:", error);
}
function showMessage(message) {
const list = getList();
if (list) list.innerHTML = `${escapeHtml(message)}
`;
}
function showLoading() {
const list = getList();
if (list) list.innerHTML = `${escapeHtml(t("loading"))}
`;
}
function showError(error, retry) {
const list = getList();
if (!list) return;
list.innerHTML = `!
${escapeHtml(t("loadFailed"))}
${escapeHtml(errorMessage(error))}
`;
list.querySelector(".sfp-retry-btn")?.addEventListener("click", retry);
}
function append(topics, highlights = []) {
const list = getList();
if (list) for (const topic of topics) list.append(renderItem(topic, highlights.includes(topic.id)));
}
function render(topics, emptyMessage, highlights) {
const list = getList();
if (!list) return;
list.replaceChildren();
if (!topics.length) showMessage(emptyMessage);
else append(topics, highlights);
_renderPaginationFooter();
}
return {
showMessage,
showLoading,
showError,
append,
render,
_renderPaginationFooter,
_showLoadMoreSpinner,
_showNoMore,
_showLoadMoreError,
captureAnchor: _captureFeedScrollAnchor,
restoreAnchor: _restoreFeedScrollAnchor,
waitForHead: _waitForFeedScrollHead
};
}
function topicResponse(value) {
const data = objectPayload(value) || {};
const list = objectPayload(data.topic_list);
return {
users: Array.isArray(data.users) ? data.users.filter((user) => !!user && Number.isFinite(user.id)) : [],
topic_list: list ? {
topics: Array.isArray(list.topics) ? list.topics.filter((topic) => !!topic && Number.isFinite(topic.id)) : void 0,
more_topics_url: typeof list.more_topics_url === "string" ? list.more_topics_url : null
} : void 0
};
}
function createFeedApi({ buildUrl, getCsrfToken, getSignal }) {
async function request(url) {
const response = await fetch(url, {
headers: { "X-CSRF-Token": getCsrfToken() },
signal: getSignal()
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return topicResponse(await response.json());
}
return {
page: (query, page) => request(buildUrl(query, page)),
byIds(ids) {
const unique = [...new Set(ids.map(Number).filter(Number.isFinite))];
return unique.length ? request(`/latest.json?topic_ids=${unique.join(",")}`) : Promise.resolve(null);
}
};
}
var AutoLoadGate = class {
now;
key = "";
timestamps = [];
emptyCount = 0;
stopped = false;
constructor(now = Date.now) {
this.now = now;
}
reset(key) {
this.key = key;
this.timestamps = [];
this.emptyCount = 0;
this.stopped = false;
}
ensure(key) {
if (this.key !== key) this.reset(key);
}
canRun(key) {
this.ensure(key);
if (this.stopped) return false;
this.timestamps = this.timestamps.filter((time) => this.now() - time < AUTO_LOAD_RATE_WINDOW_MS);
return this.timestamps.length < 3;
}
recordRequest(key) {
this.ensure(key);
this.timestamps.push(this.now());
}
recordResult(key, count) {
this.ensure(key);
this.emptyCount = count > 0 ? 0 : this.emptyCount + 1;
if (this.emptyCount >= 3) this.stopped = true;
}
};
var IncomingTopics = class {
candidates = [];
candidateIds = new Set();
cache = new Map();
filtered = [];
get ids() {
return this.candidates;
}
get matchingIds() {
return this.filtered;
}
get(id) {
return this.cache.get(id);
}
clear() {
this.candidates = [];
this.candidateIds.clear();
this.cache.clear();
this.filtered = [];
}
touch(id, payload) {
const numeric = Number(id);
if (!Number.isFinite(numeric)) return false;
if (this.candidateIds.has(numeric)) {
const index = this.candidates.indexOf(numeric);
if (index !== -1) this.candidates.splice(index, 1);
} else this.candidateIds.add(numeric);
this.candidates.push(numeric);
if (payload) this.cache.set(numeric, {
...this.cache.get(numeric),
...payload,
id: numeric
});
return true;
}
recompute(enabled, matches) {
this.filtered = enabled ? this.candidates.filter((id) => {
const topic = this.cache.get(id);
return !topic || matches(topic);
}) : [];
return this.filtered;
}
loadIds(pageSize) {
return this.filtered.slice(-Math.max(1, pageSize));
}
remove(ids) {
const removed = new Set(ids.map(Number).filter(Number.isFinite));
this.candidates = this.candidates.filter((id) => !removed.has(id));
this.filtered = this.filtered.filter((id) => !removed.has(id));
removed.forEach((id) => {
this.candidateIds.delete(id);
this.cache.delete(id);
});
}
};
var ReadingState = class {
away = null;
atHead(scrollTop) {
return scrollTop <= 1;
}
isAway(scrollTop, viewport) {
return !this.atHead(scrollTop) && (this.away === true || scrollTop > viewport);
}
reset() {
this.away = null;
}
};
var RefreshCountdown = class {
timer = null;
remaining = 0;
get running() {
return this.timer !== null;
}
reset(seconds) {
this.remaining = seconds;
}
start(seconds, tick) {
this.stop();
this.reset(seconds);
this.timer = window.setInterval(() => {
if (--this.remaining <= 0) {
this.reset(seconds);
tick();
}
}, 1e3);
}
stop() {
if (this.timer !== null) window.clearInterval(this.timer);
this.timer = null;
}
};
var PageActivity = class {
now;
lastActivity;
constructor(now = Date.now) {
this.now = now;
this.lastActivity = now();
}
record = () => {
this.lastActivity = this.now();
};
idle(hidden) {
return hidden || this.now() - this.lastActivity > 6e5;
}
start(scope) {
for (const event of [
"pointerdown",
"keydown",
"wheel",
"touchstart",
"scroll"
]) scope.listen(window, event, this.record, { passive: true });
scope.listen(document, "visibilitychange", () => {
if (document.visibilityState === "visible") this.record();
});
}
};
function markUnavailable(topic) {
if (!topic || topic.sfpUnavailable) return false;
topic.sfpUnavailable = true;
topic.sfpUnavailablePushed = 0;
return true;
}
function sortKey(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;
default: {
const bumped = Date.parse(topic.bumped_at || "");
return Number.isFinite(bumped) ? bumped : null;
}
}
}
function interleaveUnavailableTopics(allTopics, 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;
const pinnedFloats = !query?.order || ["activity", "default"].includes(query.order);
for (const anomaly of anomalies) {
const keyValue = sortKey(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 = sortKey(resident, query.order);
if (residentKey !== null && residentKey < keyValue) break;
insertIndex++;
}
allTopics.splice(insertIndex, 0, anomaly);
}
}
function detectVanishedTopics(allTopics, rawTopics, query) {
if (!Array.isArray(rawTopics) || rawTopics.length === 0) return;
if (allTopics.length === 0) return;
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 = sortKey(topic, query.order);
if (keyValue === null) continue;
if (windowMinKeyValue === null || keyValue < windowMinKeyValue) windowMinKeyValue = keyValue;
}
if (windowMinKeyValue === null) return;
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 = sortKey(topic, query.order);
if (keyValue === null || keyValue <= windowMinKeyValue) continue;
markUnavailable(topic);
}
}
var ResidentTopics = class {
retained = [];
displayUsers = {};
loaded = new Set();
depth = 0;
size = 30;
more = true;
get topics() {
return this.retained;
}
get users() {
return this.displayUsers;
}
get page() {
return this.depth;
}
get pageSize() {
return this.size;
}
get hasMore() {
return this.more;
}
get limit() {
return Math.max(1, this.size) * (Math.max(0, this.depth) + 1);
}
has(id) {
return this.loaded.has(id);
}
reset({ clearUsers = true } = {}) {
this.retained = [];
if (clearUsers) this.displayUsers = {};
this.loaded.clear();
this.depth = 0;
this.more = true;
}
addUsers(users = []) {
for (const user of users) this.displayUsers[user.id] = user;
}
loadHead(topics, rawPageSize, moreTopicsUrl) {
this.retained = topics;
this.loaded = new Set(topics.map((topic) => topic.id));
this.size = rawPageSize;
this.depth = 0;
this.more = !!moreTopicsUrl;
}
endPages() {
this.more = false;
}
append(topics, moreTopicsUrl) {
this.depth++;
this.more = !!moreTopicsUrl;
const added = topics.filter((topic) => {
if (this.loaded.has(topic.id)) return false;
this.loaded.add(topic.id);
return true;
});
this.retained = this.retained.concat(added);
return added;
}
markRead(topic) {
if (_isTopicRead(topic)) return false;
_applyReadMarker(topic);
const resident = this.retained.find((candidate) => candidate.id === topic.id);
if (resident && resident !== topic) _applyReadMarker(resident);
return true;
}
lifecycle(message) {
if (!message?.topic_id) return false;
const topicId = Number(message.topic_id);
if (!Number.isFinite(topicId)) return false;
const topic = this.retained.find((candidate) => candidate.id === topicId);
if (!topic) return false;
if (message.message_type === "recover") {
if (!topic.sfpUnavailable) return false;
topic.sfpUnavailable = false;
delete topic.sfpUnavailablePushed;
return true;
}
return ["delete", "destroy"].includes(message.message_type || "") && markUnavailable(topic);
}
detectVanished(rawTopics, query) {
detectVanishedTopics(this.retained, rawTopics, query);
}
rebuildUsers() {
const next = {};
for (const topic of this.retained) {
const id = topic.posters?.[0]?.user_id;
if (id !== void 0 && this.displayUsers[id]) next[id] = this.displayUsers[id];
}
this.displayUsers = next;
}
expireUnavailable(newEntrants) {
if (!newEntrants) return;
const expired = new Set();
for (const topic of this.retained) {
if (!topic.sfpUnavailable) continue;
topic.sfpUnavailablePushed = (topic.sfpUnavailablePushed || 0) + newEntrants;
if (topic.sfpUnavailablePushed >= this.limit) expired.add(topic.id);
}
if (!expired.size) return;
this.retained = this.retained.filter((topic) => !expired.has(topic.id));
expired.forEach((id) => this.loaded.delete(id));
this.rebuildUsers();
}
trim() {
let kept = 0;
const trimmed = [];
this.retained = this.retained.filter((topic) => {
if (topic.sfpUnavailable) return true;
if (kept++ < this.limit) return true;
trimmed.push(topic.id);
return false;
});
if (!trimmed.length) return;
trimmed.forEach((id) => this.loaded.delete(id));
this.rebuildUsers();
}
merge(topics, options = {}) {
if (!topics.length) return null;
const { mode = "prepend", resetFeedDepth = true, moreTopicsUrl, sortQuery } = options;
const fetched = new Map(topics.map((topic) => [topic.id, topic]));
for (const resident of this.retained) {
const replacement = fetched.get(resident.id);
if (replacement && _isTopicRead(resident) && !_isTopicRead(replacement)) _applyReadMarker(replacement);
}
if (resetFeedDepth) this.depth = 0;
const highlights = topics.filter((topic) => mode === "prepend" || !this.loaded.has(topic.id)).map((topic) => topic.id);
this.retained = [...topics, ...this.retained.filter((topic) => !fetched.has(topic.id))];
if (mode === "replace-head") {
if (sortQuery) interleaveUnavailableTopics(this.retained, sortQuery, topics.length);
this.more = !!moreTopicsUrl;
} else if (resetFeedDepth) this.more = this.retained.length >= Math.max(1, this.size);
const entrants = topics.filter((topic) => !this.loaded.has(topic.id)).length;
topics.forEach((topic) => this.loaded.add(topic.id));
this.expireUnavailable(entrants);
this.trim();
return highlights;
}
};
function createFeedController({ storage, discourse, prefs, site, tags }) {
const { CATEGORY_DATA_CACHE_KEY, TAG_STYLE_CACHE_KEY } = storage_keys_exports;
const { delete: _deleteSiteValue } = storage;
const { getCsrfToken, getDiscourse, getMessageBus, waitForStableHeaderMount } = discourse;
const { t, getUiLocale, formatRelativeTime } = createI18n(getDiscourse);
const { _getCategoryMeta, _findTabCategoryByTabId, loadCategoryMetadata } = site;
const { loadTagStyleIndex } = tags;
const scroll = createFeedScroll({
canLoadMore: () => resident.hasMore && !isLoadingMore,
onLoadMore: () => loadMoreTopics({ source: "auto" }),
onReadingState: () => _scheduleHeadActionStateSync()
});
const FeedQuery = {
snapshot: () => ({
tab: prefs.currentTab,
categoryId: currentCategoryId,
order: prefs.currentOrder,
period: prefs.currentPeriod,
filter: prefs.currentFilter
}),
key: feedQueryKey,
isCurrent: (query) => feedQueryKey(query) === feedQueryKey(FeedQuery.snapshot()),
buildUrl: (query, page) => buildFeedUrl(query, page, _getCategoryMeta)
};
const api = createFeedApi({
buildUrl: FeedQuery.buildUrl,
getCsrfToken,
getSignal: () => requests.signal
});
const fetchFeedTopics = api.page;
const fetchFeedTopicsByIds = api.byIds;
const RouteWatcher = createRouteWatcher({
onRouteChange: () => {
if (prefs.feedModeEnabled) _syncIncomingHeadAction();
},
onMutation: () => {
if (!prefs.feedModeEnabled) return;
const sidebar = getSidebarElement();
if (!sidebar) {
if (host.feedContainer) deactivateFeed();
return;
}
const resizerMissing = !host.resizerEl || !sidebar?.contains(host.resizerEl);
if (sidebar && (!host.feedContainer || !sidebar.contains(host.feedContainer) || !sidebar.classList.contains("sfp-feed-mode") || resizerMissing)) activateFeed();
}
});
let currentCategoryId = null;
const resident = new ResidentTopics();
const autoLoad = new AutoLoadGate();
let isLoading = false;
let loadCycle = null;
let isLoadingMore = false;
let isRefreshing = false;
const silentRefresh = new RefreshCountdown();
const automaticRefresh = new RefreshCountdown();
const activity = new PageActivity();
const appScope = new Lifetime();
let viewScope = new Lifetime();
let headActionScheduled = false;
let started = false;
let disposed = false;
let active = false;
let viewEpoch = 0;
let requests = new AbortController();
const setTimeout = (callback, delay) => viewScope.timeout(callback, delay);
const clearTimeout = (id) => viewScope.clearTimeout(id);
const requestAnimationFrame = (callback) => viewScope.frame(callback);
function invalidateRequests() {
viewEpoch++;
sidebarIncomingState.applyQueued = false;
refreshBusyCount = 0;
requests.abort();
requests = new AbortController();
activeLoadToken++;
activeLoadMoreToken++;
activeRefreshToken++;
sidebarIncomingState.filterRefreshToken++;
}
const incoming = new IncomingTopics();
const reading = new ReadingState();
const sidebarIncomingState = {
filterRefreshTimer: null,
filterRefreshToken: 0,
viewSettling: false,
filterStable: false,
applyQueued: false
};
let sidebarMessageBus = null;
let sidebarLatestMessageBusCallback = null;
let sidebarNewMessageBusCallback = null;
let sidebarLifecycleMessageBus = null;
let sidebarLifecycleMessageBusCallback = null;
let activeLoadToken = 0;
let activeLoadMoreToken = 0;
let activeRefreshToken = 0;
let refreshBusyCount = 0;
const topicRenderer = createTopicRenderer({
t,
formatRelativeTime,
site,
tags,
discourse,
getUser: (id) => resident.users[id],
markTopicAsRead,
getScope: () => viewScope
});
const host = createFeedHost({
t,
getEnabled: () => prefs.feedModeEnabled,
getWidth: () => prefs.sfpSidebarWidth,
onToggle: () => {
prefs.feedModeEnabled = !prefs.feedModeEnabled;
if (prefs.feedModeEnabled) activateFeed();
else deactivateFeed();
},
onWidth: (width) => {
prefs.sfpSidebarWidth = width;
},
getScope: () => viewScope
});
const { createToggle, getSidebarElement, removeResizer, restoreSidebarWidth } = host;
const { createTopicItem } = topicRenderer;
const controls = createControls({
renderTopics,
_finishSidebarIncomingViewSettling,
t,
prefs,
site,
host,
getScope: () => viewScope,
updatePreference: (key, value) => {
const changed = prefs[key] !== value;
Object.assign(prefs, { [key]: value });
if (key === "currentFilter" && changed) {
if (isLoading) loadTopics();
else {
invalidateRequests();
isLoadingMore = isRefreshing = false;
}
}
},
saveTabOrder: (ids) => prefs.saveTabOrder(ids),
getCategoryId: () => currentCategoryId,
setCategoryId: (id) => {
currentCategoryId = id;
},
_resetAutoLoadState,
loadTopics,
_beginSidebarIncomingViewSettling,
_syncDefaultViewControls,
_syncRefreshButtonBusy,
_handleHeadActionClick,
_syncSidebarIncomingTracking,
_syncHeadActionState,
_isAutoSilentRefreshActive,
_queueSidebarIncomingApply,
_startAutoSilentRefresh,
_startAutoRefresh,
_isLatestActivityView
});
const { _getOrderOptions, _getPeriodOptions, _getFilterOptions, _closeFloatingPanels, _buildHeaderControls, _buildTabBar, _rerenderTabBar, _buildFilterBar, _refreshCategoryTabs, _updateSettingsControl } = controls;
const topicList = createTopicList({
t,
hasMore: () => resident.hasMore,
loadMore: () => loadMoreTopics(),
renderItem: createTopicItem,
getScroll: () => host.feedScrollEl,
getList: () => host.feedListEl,
getScope: () => viewScope,
syncHead: _syncHeadActionState,
atHead: _isAtFeedHead
});
const { _renderPaginationFooter, _showLoadMoreSpinner, _showNoMore, _showLoadMoreError } = topicList;
let siteControlsSignature = "";
function _normalizeCurrentSiteState() {
let changed = false;
const orderOptions = _getOrderOptions();
const periodOptions = _getPeriodOptions();
const filterOptions = _getFilterOptions();
if (!site.capabilities.orderValues.has(prefs.currentOrder)) {
prefs.currentOrder = orderOptions[0]?.value || "activity";
changed = true;
}
if (!site.capabilities.periodValues.has(prefs.currentPeriod)) {
prefs.currentPeriod = site.capabilities.periodValues.has("all") ? "all" : periodOptions[0]?.value || "all";
changed = true;
}
if (!site.capabilities.filterValues.has(prefs.currentFilter)) {
prefs.currentFilter = filterOptions[0]?.value || "all";
changed = true;
}
if (prefs.currentTab === "all") currentCategoryId = null;
else {
const tab = _findTabCategoryByTabId(prefs.currentTab);
if (tab) {
currentCategoryId = tab.id;
if (prefs.currentTab !== tab.tabId) {
prefs.currentTab = tab.tabId;
changed = true;
}
} else {
prefs.currentTab = "all";
currentCategoryId = null;
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: site.tabCategories.map((cat) => `${cat.id}:${cat.name}:${cat.icon}:${cat.color}`),
currentTab: prefs.currentTab,
currentOrder: prefs.currentOrder,
currentPeriod: prefs.currentPeriod,
currentFilter: prefs.currentFilter
});
}
function _syncSiteDerivedControls({ force = false } = {}) {
const nextSignature = _siteControlsStateSignature();
if (!force && nextSignature === siteControlsSignature) return;
siteControlsSignature = nextSignature;
if (host.feedHeaderEl) {
host.feedHeaderEl.replaceChildren();
_buildHeaderControls(host.feedHeaderEl);
}
const tabShell = host.feedContainer?.querySelector(".sfp-tab-shell");
if (tabShell) _rerenderTabBar(tabShell);
const filterBar = host.feedContainer?.querySelector(".sfp-filter-bar");
if (filterBar) filterBar.replaceWith(_buildFilterBar());
}
function clearDataCaches({ reload = false } = {}) {
_deleteSiteValue(CATEGORY_DATA_CACHE_KEY);
_deleteSiteValue(TAG_STYLE_CACHE_KEY);
site.reset();
tags.reset();
console.info("[SFP] category data and tag style caches cleared");
if (reload && prefs.feedModeEnabled) {
_resetAutoLoadState();
loadTopics();
}
}
function activateFeed() {
if (disposed || !prefs.feedModeEnabled) return;
const sidebar = getSidebarElement();
if (!sidebar) return;
active = true;
if (host.feedContainer && sidebar.contains(host.feedContainer)) {
host.ensure();
_startSidebarTopicLifecycleTracking();
_syncDefaultViewControls();
_syncIncomingHeadAction();
_syncHeadActionState();
return;
}
if (host.feedContainer) deactivateFeed();
active = true;
if (!host.mount(_buildHeaderControls, _buildTabBar, _buildFilterBar)) return;
_restoreTabState();
_syncDefaultViewControls();
loadTopics();
scroll.bind(host.feedScrollEl);
_startSidebarTopicLifecycleTracking();
}
function deactivateFeed() {
active = false;
invalidateRequests();
viewScope.dispose();
viewScope = new Lifetime();
headActionScheduled = false;
isLoading = false;
loadCycle = null;
isLoadingMore = false;
isRefreshing = false;
sidebarIncomingState.applyQueued = false;
scroll.dispose();
_stopAutoRefresh();
_stopAutoSilentRefresh();
_stopSidebarIncomingTracking();
_stopSidebarTopicLifecycleTracking();
_resetRefreshButtonBusy();
controls.feedRefreshBtn?.classList.remove("sfp-back-top-enter");
host.unmount();
controls.reset();
resident.reset();
_resetAutoLoadState();
}
function _beginRefreshButtonBusy() {
refreshBusyCount++;
_syncRefreshButtonBusy();
let ended = false;
const busyEpoch = viewEpoch;
return () => {
if (ended || busyEpoch !== viewEpoch) return;
ended = true;
refreshBusyCount = Math.max(0, refreshBusyCount - 1);
_syncRefreshButtonBusy();
};
}
function _syncRefreshButtonBusy() {
if (controls.setBusy(refreshBusyCount > 0)) _syncHeadActionState();
}
function _resetRefreshButtonBusy() {
refreshBusyCount = 0;
_syncRefreshButtonBusy();
}
function _restoreTabState() {
if (prefs.currentTab === "all") {
currentCategoryId = null;
return;
}
const cat = _findTabCategoryByTabId(prefs.currentTab);
if (cat) {
currentCategoryId = cat.id;
prefs.currentTab = cat.tabId;
} else if (site.loaded) {
prefs.currentTab = "all";
currentCategoryId = null;
} else currentCategoryId = null;
}
function _isAtFeedHead() {
return reading.atHead(host.feedScrollEl?.scrollTop || 0);
}
function _getIncomingCountDisplayValue(query = FeedQuery.snapshot()) {
if (!prefs.showIncomingHint || !_canShowSidebarIncomingHint(query)) return 0;
if (!reading.isAway(host.feedScrollEl?.scrollTop || 0, host.feedScrollEl?.clientHeight || 0) && _isAutoSilentRefreshActive(query)) return 0;
return sidebarIncomingState.filterStable ? incoming.matchingIds.length : 0;
}
function _restartAutomaticRefreshTimers() {
if (isLoading || isLoadingMore || isRefreshing) return;
_startAutoSilentRefresh();
_startAutoRefresh();
if (_isAtFeedHead() && _isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval === 0) _queueSidebarIncomingApply();
}
function _isAutoSilentRefreshTimerExpected() {
return _isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval > 0;
}
function _isAutoRefreshTimerExpected() {
return prefs.autoRefreshEnabled && !_isLatestActivityView() && _isAtFeedHead();
}
function _restoreMissingAutomaticRefreshTimers() {
if (isLoading || isLoadingMore || isRefreshing) return;
if (_isAutoSilentRefreshTimerExpected() && !silentRefresh.running) _startAutoSilentRefresh();
if (_isAutoRefreshTimerExpected() && !automaticRefresh.running) _startAutoRefresh();
}
function _syncHeadActionState({ restartAuto = true } = {}) {
if (!controls.feedRefreshBtn) return;
const isAtHead = _isAtFeedHead();
const isAway = reading.isAway(host.feedScrollEl?.scrollTop || 0, host.feedScrollEl?.clientHeight || 0);
const isBusy = refreshBusyCount > 0;
const incomingCount = _getIncomingCountDisplayValue();
controls.renderHeadAction(isAway, isBusy, incomingCount);
_syncRefreshButtonBusy();
if (reading.away !== isAway) {
reading.away = isAway;
if (isAway) {
_stopAutoRefresh();
_stopAutoSilentRefresh();
} else if (restartAuto && isAtHead) _restartAutomaticRefreshTimers();
} else if (!isAway && restartAuto && isAtHead) _restoreMissingAutomaticRefreshTimers();
}
function _scheduleHeadActionStateSync() {
if (headActionScheduled) return;
headActionScheduled = true;
requestAnimationFrame(() => {
headActionScheduled = false;
_syncHeadActionState();
});
}
async function _returnToHead({ animated = true, restartAuto = true } = {}) {
if (!host.feedScrollEl) return false;
host.feedScrollEl.scrollTo({
top: 0,
behavior: animated ? "smooth" : "auto"
});
if (!animated) {
_syncHeadActionState({ restartAuto });
return _isAtFeedHead();
}
const scrollResult = await topicList.waitForHead();
if (scrollResult === "timeout" && host.feedScrollEl && !_isAtFeedHead()) host.feedScrollEl.scrollTop = 0;
_syncHeadActionState({ restartAuto });
return scrollResult !== "interrupted" && _isAtFeedHead();
}
async function _handleHeadActionClick(event) {
if (!controls.feedRefreshBtn || controls.feedRefreshBtn.getAttribute("aria-busy") === "true") return;
const action = controls.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()) {
if (!await _returnToHead({
animated: true,
restartAuto: false
})) {
_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;
incoming.recompute(false, () => false);
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() && prefs.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) && (prefs.showIncomingHint || prefs.autoSilentRefreshEnabled);
}
function _canShowSidebarIncomingHint(query = FeedQuery.snapshot()) {
return prefs.showIncomingHint && _canUseSidebarIncomingRefresh(query) && query.filter === "all";
}
function _isAutoSilentRefreshActive(query = FeedQuery.snapshot()) {
return active && !disposed && prefs.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()) {
const enabled = _canUseSidebarIncomingRefresh(query);
if (!enabled) sidebarIncomingState.filterStable = false;
return incoming.recompute(enabled, (topic) => _topicMatchesIncomingCandidate(topic, query));
}
function _scheduleSidebarIncomingFilterRefresh() {
if (sidebarIncomingState.viewSettling || isLoading || isRefreshing) return;
if (!_canUseSidebarIncomingRefresh() || incoming.ids.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 incoming.matchingIds;
if (!_canUseSidebarIncomingRefresh(requestQuery)) {
_recomputeSidebarIncomingFilteredTopicIds(requestQuery);
_syncIncomingHeadAction({ skipIncomingFilterRefresh: true });
return [];
}
const incomingTopicIds = incoming.ids.slice();
const token = ++sidebarIncomingState.filterRefreshToken;
if (incomingTopicIds.length === 0) {
incoming.recompute(false, () => false);
sidebarIncomingState.filterStable = true;
_syncIncomingHeadAction({ skipIncomingFilterRefresh: true });
return [];
}
await loadCategoryMetadata();
if (token !== sidebarIncomingState.filterRefreshToken || !FeedQuery.isCurrent(requestQuery)) return incoming.matchingIds;
_recomputeSidebarIncomingFilteredTopicIds(requestQuery);
sidebarIncomingState.filterStable = true;
_syncIncomingHeadAction({ skipIncomingFilterRefresh: true });
return incoming.matchingIds;
}
function _syncDefaultViewControls() {
_syncSidebarIncomingTracking();
_recomputeSidebarIncomingFilteredTopicIds();
_updateSettingsControl();
_syncIncomingHeadAction();
_syncHeadActionState();
_scheduleSidebarIncomingFilterRefresh();
if (_isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval === 0) _queueSidebarIncomingApply();
}
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);
messageBus.subscribe("/latest", sidebarLatestMessageBusCallback, _getMessageBusLastId(messageBus, "/latest"));
messageBus.subscribe("/new", sidebarNewMessageBusCallback, _getMessageBusLastId(messageBus, "/new"));
}
function _syncSidebarIncomingTracking() {
if (prefs.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();
}
function _startSidebarTopicLifecycleTracking() {
if (!prefs.feedModeEnabled) return;
if (sidebarLifecycleMessageBusCallback) return;
const messageBus = getMessageBus();
if (!messageBus?.subscribe) 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 (resident.lifecycle(data) && host.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;
incoming.touch(data.topic_id, data.payload);
sidebarIncomingState.filterStable = false;
if (_isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval === 0) _queueSidebarIncomingApply();
else if (_canUseSidebarIncomingRefresh()) {
_recomputeSidebarIncomingFilteredTopicIds();
sidebarIncomingState.filterStable = true;
_syncIncomingHeadAction({ skipIncomingFilterRefresh: true });
}
}
function _clearSidebarIncomingCandidates() {
incoming.clear();
sidebarIncomingState.filterStable = false;
sidebarIncomingState.applyQueued = false;
if (sidebarIncomingState.filterRefreshTimer) {
clearTimeout(sidebarIncomingState.filterRefreshTimer);
sidebarIncomingState.filterRefreshTimer = null;
}
sidebarIncomingState.filterRefreshToken++;
_syncHeadActionState();
}
function _resetSidebarIncomingCandidatesForQueryChange() {
incoming.clear();
sidebarIncomingState.filterStable = false;
sidebarIncomingState.applyQueued = false;
if (sidebarIncomingState.filterRefreshTimer) {
clearTimeout(sidebarIncomingState.filterRefreshTimer);
sidebarIncomingState.filterRefreshTimer = null;
}
sidebarIncomingState.filterRefreshToken++;
_syncHeadActionState();
}
function _getSidebarIncomingLoadTopicIds() {
return incoming.loadIds(resident.pageSize);
}
function _removeSidebarIncomingTopicIds(topicIds) {
incoming.remove(topicIds);
_recomputeSidebarIncomingFilteredTopicIds();
}
function _removeSidebarIncomingTopicsForQuery(query = FeedQuery.snapshot()) {
if (!incoming.ids.length) return;
_removeSidebarIncomingTopicIds(incoming.ids.filter((id) => {
const topic = incoming.get(Number(id));
return topic && _topicMatchesIncomingCandidate(topic, query);
}));
}
function _queueSidebarIncomingApply() {
if (sidebarIncomingState.viewSettling) return;
if (!_canUseSidebarIncomingRefresh()) return;
if (!_isAutoSilentRefreshActive() || prefs.autoSilentRefreshInterval > 0) return;
if (_shouldSkipAutomaticRefresh()) return;
if (incoming.ids.length === 0) return;
if (sidebarIncomingState.applyQueued) return;
sidebarIncomingState.applyQueued = true;
const queuedEpoch = viewEpoch;
Promise.resolve().then(async () => {
if (disposed || queuedEpoch !== viewEpoch) return;
sidebarIncomingState.applyQueued = false;
if (!_isAutoSilentRefreshActive() || prefs.autoSilentRefreshInterval > 0) return;
await _applySidebarIncomingTopics({
requireDefaultView: true,
logPrefix: "auto silent refresh"
});
});
}
function _flushQueuedSidebarIncomingApply() {
if (!sidebarIncomingState.applyQueued) return;
if (!_isAutoSilentRefreshActive() || prefs.autoSilentRefreshInterval > 0) {
sidebarIncomingState.applyQueued = false;
return;
}
sidebarIncomingState.applyQueued = false;
_queueSidebarIncomingApply();
}
function _getAutoLoadSessionKey() {
return FeedQuery.key(FeedQuery.snapshot());
}
function _resetAutoLoadState() {
autoLoad.reset(_getAutoLoadSessionKey());
}
function _canRunAutoLoad() {
return autoLoad.canRun(_getAutoLoadSessionKey());
}
function _recordAutoLoadRequest() {
autoLoad.recordRequest(_getAutoLoadSessionKey());
}
function _recordAutoLoadFilterResult(count) {
autoLoad.recordResult(_getAutoLoadSessionKey(), count);
}
function _shouldExcludeTopReadPinnedTopics(query, page) {
return prefs.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 _startTagStyleIndexLoad() {
if (tags.loaded || tags.loading) return;
loadTagStyleIndex().then(() => {
if (prefs.feedModeEnabled && host.feedListEl && resident.topics.length > 0) renderTopics();
});
}
async function loadTopics() {
if (disposed || !prefs.feedModeEnabled || !host.feedListEl) return;
if (loadCycle) {
loadCycle.pending = true;
invalidateRequests();
return;
}
const cycle = { pending: false };
loadCycle = cycle;
invalidateRequests();
let requestQuery = null;
const requestToken = ++activeLoadToken;
activeLoadMoreToken++;
activeRefreshToken++;
_beginSidebarIncomingViewSettling();
_resetSidebarIncomingCandidatesForQueryChange();
isLoading = true;
isLoadingMore = false;
isRefreshing = false;
resident.reset({ clearUsers: false });
if (host.feedScrollEl) host.feedScrollEl.scrollTop = 0;
reading.away = null;
_syncIncomingHeadAction();
_resetAutoLoadState();
reading.away = null;
topicList.showLoading();
_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);
resident.loadHead(topics, rawTopics.length, data.topic_list.more_topics_url);
renderTopics();
_removeSidebarIncomingTopicIds(topics.map((topic) => topic.id));
_syncIncomingHeadAction();
} else {
topicList.showMessage(t("emptyTopics"));
resident.endPages();
}
} catch (e) {
if (requestToken !== activeLoadToken || requestQuery && !FeedQuery.isCurrent(requestQuery)) return;
console.error("[SFP] loadTopics error:", e);
topicList.showError(e, () => {
loadTopics();
});
} finally {
if (loadCycle === cycle) {
loadCycle = null;
isLoading = false;
if (cycle.pending) loadTopics();
else {
_flushQueuedSidebarIncomingApply();
_finishSidebarIncomingViewSettling();
_restartAutomaticRefreshTimers();
}
}
}
}
async function loadMoreTopics({ source = "manual" } = {}) {
if (disposed || !prefs.feedModeEnabled || isLoading || isLoadingMore || !resident.hasMore) 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, resident.page + 1);
if (requestToken !== activeLoadMoreToken || !FeedQuery.isCurrent(requestQuery)) return;
_processUsers(data);
if (data?.topic_list?.topics) {
const topics = data.topic_list.topics;
const newTopics = resident.append(topics, data.topic_list.more_topics_url);
if (newTopics.length === 0) {
if (resident.hasMore) _renderPaginationFooter({ note: !isAutoLoad ? t("nextPageNoMatch") : "" });
else _showNoMore();
} else {
const filteredNew = _applyFilter(newTopics);
topicList.append(filteredNew);
if (isAutoLoad) _recordAutoLoadFilterResult(filteredNew.length);
_renderPaginationFooter({ note: !isAutoLoad && filteredNew.length === 0 ? t("nextPageNoMatch") : "" });
}
} else {
resident.endPages();
_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) {
resident.addUsers(data?.users);
}
async function refreshCurrentView() {
return _refreshCurrentView({ logPrefix: "manual refresh" });
}
function _mergeAndRenderTopics(fetchedTopics, { mode = "prepend", moreTopicsUrl = "", incomingCandidateIds = [], filterTopic = null, clearIncomingForQuery = null, resetFeedDepth = true, sortQuery = null } = {}) {
const topics = mode !== "replace-head" && typeof filterTopic === "function" ? fetchedTopics.filter((topic) => filterTopic?.(topic)) : fetchedTopics;
if (topics.length === 0) {
if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds);
_syncIncomingHeadAction();
return false;
}
const highlightTopicIds = resident.merge(topics, {
mode,
moreTopicsUrl,
resetFeedDepth,
sortQuery
}) || [];
const scrollAnchor = topicList.captureAnchor();
renderTopics(highlightTopicIds);
if (clearIncomingForQuery) _removeSidebarIncomingTopicsForQuery(clearIncomingForQuery);
else if (incomingCandidateIds.length) _removeSidebarIncomingTopicIds(incomingCandidateIds);
_syncIncomingHeadAction();
topicList.restoreAnchor(scrollAnchor);
_syncHeadActionState();
return true;
}
async function _refreshCurrentView({ requireDefaultView = false, logPrefix = "refresh" } = {}) {
if (disposed || !prefs.feedModeEnabled || isLoading || isLoadingMore || isRefreshing) return false;
if (requireDefaultView && !_canUseSidebarIncomingRefresh()) return false;
if (!host.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;
resident.detectVanished(data.topic_list.topics, requestQuery);
return _mergeAndRenderTopics(_excludeTopReadPinnedTopics(data.topic_list.topics, requestQuery, 0), {
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();
}
}
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 (!host.feedListEl) return;
if (disposed || !prefs.feedModeEnabled) return;
const applyEpoch = viewEpoch;
const endRefreshBusy = _beginRefreshButtonBusy();
let requestToken = null;
try {
const incomingCandidateIds = await _refreshSidebarIncomingFilter();
if (disposed || applyEpoch !== viewEpoch) return;
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 (prefs.autoSilentRefreshInterval <= 0) return;
_resetAutoSilentRefreshCountdown();
silentRefresh.start(prefs.autoSilentRefreshInterval, () => {
if (prefs.feedModeEnabled && !isLoading && !isLoadingMore && !isRefreshing && !_shouldSkipAutomaticRefresh()) {
if (_canUseSidebarIncomingRefresh()) _applySidebarIncomingTopics({
requireDefaultView: true,
logPrefix: "auto silent refresh interval",
queueIfBusy: false
});
else _refreshCurrentView({ logPrefix: "auto silent refresh interval" });
}
});
}
function _resetAutoSilentRefreshCountdown() {
if (_isAutoSilentRefreshActive() && prefs.autoSilentRefreshInterval > 0) silentRefresh.reset(prefs.autoSilentRefreshInterval);
}
function _stopAutoSilentRefresh() {
silentRefresh.stop();
}
function _isPageIdleForAutoRefresh() {
return activity.idle(document.visibilityState === "hidden");
}
function _shouldSkipAutomaticRefresh() {
return _isPageIdleForAutoRefresh() || !_isAtFeedHead();
}
function _setupPageActivityTracking() {
activity.start(appScope);
}
function _startAutoRefresh() {
_stopAutoRefresh();
if (!active || disposed) return;
if (_isLatestActivityView()) return;
if (!prefs.autoRefreshEnabled) return;
if (!_isAtFeedHead()) return;
_resetAutoRefreshCountdown();
automaticRefresh.start(prefs.autoRefreshInterval, () => {
if (prefs.feedModeEnabled && !isLoading && !isLoadingMore && !_shouldSkipAutomaticRefresh()) _refreshCurrentView({ logPrefix: "auto refresh" });
});
}
function _resetAutoRefreshCountdown() {
if (prefs.autoRefreshEnabled) automaticRefresh.reset(prefs.autoRefreshInterval);
}
function _stopAutoRefresh() {
automaticRefresh.stop();
}
function renderTopics(newTopicIds = []) {
const filtered = _applyFilter(resident.topics);
let emptyMessage = t("emptyTopics");
if (resident.topics.length) {
emptyMessage = prefs.currentFilter === "unseen" ? t(prefs.currentTab !== "all" ? "noUnreadInCategory" : "noUnread") : t(prefs.currentFilter === "read" ? "noRead" : "noMatchingTopics");
if (resident.hasMore && !isLoadingMore) emptyMessage = t("currentPagePrefix") + emptyMessage;
}
topicList.render(filtered, emptyMessage, newTopicIds);
_syncHeadActionState();
}
function markTopicAsRead(topic, itemElement) {
if (resident.markRead(topic)) topicRenderer.markRead(itemElement);
}
function _applyFilter(topics) {
if (prefs.currentFilter === "all") return topics;
let result = topics;
if (prefs.currentFilter === "unseen") result = result.filter((t) => _hasUnreadMarker(t));
if (prefs.currentFilter === "read") result = result.filter((t) => !_hasUnreadMarker(t));
return result;
}
function init() {
if (started || disposed) return;
started = true;
appScope.listen(document, "click", () => _closeFloatingPanels());
_setupPageActivityTracking();
controls.start();
waitForStableHeaderMount(() => {
if (disposed) return;
createToggle();
RouteWatcher.start();
if (prefs.feedModeEnabled) appScope.timeout(() => activateFeed(), 300);
else {
removeResizer();
restoreSidebarWidth();
}
});
}
function dispose() {
if (disposed) return;
disposed = true;
RouteWatcher.stop();
deactivateFeed();
viewScope.dispose();
appScope.dispose();
host.dispose();
controls.dispose();
restoreSidebarWidth();
}
return {
start: init,
activate: () => {
prefs.feedModeEnabled = true;
activateFeed();
},
deactivate: () => {
prefs.feedModeEnabled = false;
deactivateFeed();
},
dispose,
clearCaches: clearDataCaches,
normalizeSiteState: _normalizeCurrentSiteState
};
}
function createDiscourseBridge(pageWindow) {
let scope = new Lifetime();
let cachedCsrfToken = null;
function getCsrfToken() {
if (cachedCsrfToken === null) cachedCsrfToken = document.querySelector("meta[name=\"csrf-token\"]")?.getAttribute("content") || "";
return cachedCsrfToken;
}
function toAbsoluteSiteUrl(path) {
if (!path) return "";
return new URL(path, location.origin).href;
}
function navigateTo(path) {
const script = document.createElement("script");
script.textContent = `window.require("discourse/lib/url").default.routeTo(${JSON.stringify(path)});`;
document.documentElement.appendChild(script);
script.remove();
}
function getDiscourse() {
try {
return pageWindow?.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 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, onMiddleActivate } = {}) {
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 = 15e3) {
const start = Date.now();
function check() {
if (scope.disposed) return;
try {
if (getDiscourse()?.__container__) {
callback();
return;
}
} catch (e) {}
if (Date.now() - start < maxWait) scope.timeout(check, 500);
else console.warn("[SFP] Timed out waiting for Ember");
}
check();
}
function waitForStableHeaderMount(callback) {
waitForEmber(() => {
const run = () => {
scope.frame(() => {
scope.frame(() => {
callback();
});
});
};
if (document.readyState === "complete") {
run();
return;
}
scope.listen(window, "load", run, { once: true });
});
}
return {
dispose: () => scope.dispose(),
getCsrfToken,
toAbsoluteSiteUrl,
navigateTo,
getDiscourse,
getMessageBus,
getAvatarUrl,
getUserProfileUrl,
openPathInNewTab,
handlePointerNavigation,
waitForEmber,
waitForStableHeaderMount
};
}
var _GM_addStyle = (() => typeof GM_addStyle != "undefined" ? GM_addStyle : void 0)();
var _GM_deleteValue = (() => typeof GM_deleteValue != "undefined" ? GM_deleteValue : void 0)();
var _GM_getValue = (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
var _GM_registerMenuCommand = (() => typeof GM_registerMenuCommand != "undefined" ? GM_registerMenuCommand : void 0)();
var _GM_setValue = (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
var _unsafeWindow = (() => typeof unsafeWindow != "undefined" ? unsafeWindow : void 0)();
var gm = {
get: _GM_getValue,
set: _GM_setValue,
delete: (key) => {
if (typeof _GM_deleteValue === "function") _GM_deleteValue(key);
},
addStyle: _GM_addStyle,
registerMenu: (label, callback) => {
if (typeof _GM_registerMenuCommand === "function") _GM_registerMenuCommand(label, callback);
},
pageWindow: () => typeof _unsafeWindow !== "undefined" ? _unsafeWindow : window
};
function createSiteStorage(origin, values) {
const keyFor = (key) => `${SITE_STORAGE_PREFIX}:${encodeURIComponent(origin)}:${key}`;
return {
get: (key, fallback) => values.get(keyFor(key), fallback),
set: (key, value) => values.set(keyFor(key), value),
delete: (key) => values.delete(keyFor(key)),
migrateLegacy() {
if (origin !== "https://linux.do") return;
for (const key of SITE_SCOPED_STORAGE_KEYS) {
const legacy = values.get(key, STORAGE_MISSING);
if (legacy === "__SFP_STORAGE_MISSING__") continue;
if (values.get(keyFor(key), "__SFP_STORAGE_MISSING__") === "__SFP_STORAGE_MISSING__") values.set(keyFor(key), legacy);
values.delete(key);
}
}
};
}
var Preferences = class {
storage;
_feedModeEnabled;
_currentOrder;
_currentPeriod;
_sfpSidebarWidth;
_currentTab;
_currentFilter;
_hidePinned;
_showIncomingHint;
_autoSilentRefreshEnabled;
_autoSilentRefreshInterval;
_autoRefreshEnabled;
_autoRefreshInterval;
constructor(storage) {
this.storage = storage;
this._feedModeEnabled = storage.get(STATE_KEY, false);
this._currentOrder = storage.get(ORDER_KEY, "activity");
if (this._currentOrder === "default") {
this._currentOrder = "activity";
storage.set(ORDER_KEY, this._currentOrder);
}
this._currentPeriod = storage.get(PERIOD_KEY, "all");
this._sfpSidebarWidth = storage.get(WIDTH_KEY, 272);
this._currentTab = storage.get(TAB_KEY, "all");
this._currentFilter = storage.get(FILTER_KEY, "all");
this._hidePinned = storage.get(HIDE_PINNED_KEY, false);
this._showIncomingHint = storage.get(SHOW_INCOMING_HINT_KEY, true);
this._autoSilentRefreshEnabled = storage.get(AUTO_SILENT_REFRESH_KEY, false);
this._autoSilentRefreshInterval = Math.max(0, Number(storage.get("sfp_auto_silent_refresh_interval", 0)) || 0);
this._autoRefreshEnabled = storage.get(AUTO_REFRESH_ENABLED_KEY, false);
this._autoRefreshInterval = Math.max(1, Number(storage.get("sfp_auto_refresh_interval", 10)) || 10);
}
get feedModeEnabled() {
return this._feedModeEnabled;
}
set feedModeEnabled(value) {
this._feedModeEnabled = value;
this.storage.set(STATE_KEY, value);
}
get currentOrder() {
return this._currentOrder;
}
set currentOrder(value) {
this._currentOrder = value;
this.storage.set(ORDER_KEY, value);
}
get currentPeriod() {
return this._currentPeriod;
}
set currentPeriod(value) {
this._currentPeriod = value;
this.storage.set(PERIOD_KEY, value);
}
get sfpSidebarWidth() {
return this._sfpSidebarWidth;
}
set sfpSidebarWidth(value) {
this._sfpSidebarWidth = value;
this.storage.set(WIDTH_KEY, value);
}
get currentTab() {
return this._currentTab;
}
set currentTab(value) {
this._currentTab = value;
this.storage.set(TAB_KEY, value);
}
get currentFilter() {
return this._currentFilter;
}
set currentFilter(value) {
this._currentFilter = value;
this.storage.set(FILTER_KEY, value);
}
get hidePinned() {
return this._hidePinned;
}
set hidePinned(value) {
this._hidePinned = value;
this.storage.set(HIDE_PINNED_KEY, value);
}
get showIncomingHint() {
return this._showIncomingHint;
}
set showIncomingHint(value) {
this._showIncomingHint = value;
this.storage.set(SHOW_INCOMING_HINT_KEY, value);
}
get autoSilentRefreshEnabled() {
return this._autoSilentRefreshEnabled;
}
set autoSilentRefreshEnabled(value) {
this._autoSilentRefreshEnabled = value;
this.storage.set(AUTO_SILENT_REFRESH_KEY, value);
}
get autoSilentRefreshInterval() {
return this._autoSilentRefreshInterval;
}
set autoSilentRefreshInterval(value) {
this._autoSilentRefreshInterval = value;
this.storage.set(AUTO_SILENT_REFRESH_INTERVAL_KEY, value);
}
get autoRefreshEnabled() {
return this._autoRefreshEnabled;
}
set autoRefreshEnabled(value) {
this._autoRefreshEnabled = value;
this.storage.set(AUTO_REFRESH_ENABLED_KEY, value);
}
get autoRefreshInterval() {
return this._autoRefreshInterval;
}
set autoRefreshInterval(value) {
this._autoRefreshInterval = value;
this.storage.set(AUTO_REFRESH_INTERVAL_KEY, value);
}
saveTabOrder(ids) {
this.storage.set(TAB_ORDER_KEY, ids);
}
};
function createTagStyles({ storage, site }) {
let scope = new Lifetime();
const _getSiteValue = storage.get;
const _setSiteValue = storage.set;
const { loadSiteData, _extractPreloadedSiteData } = site;
const tagStyleByKey = new Map();
let tagStylePromise = null;
let tagStyleLoaded = false;
function _tagIndexKeys(tag) {
const keys = [];
const add = (value) => {
if (value === null || value === void 0) 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 [];
return (Array.isArray(site?.top_tags) ? site.top_tags : []).map(_normalizeTagRecord).filter((tag) => !!tag);
}
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 !== 1 || !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: 1,
savedAt: Date.now(),
entries: Array.from(tagStyleByKey.entries())
});
} catch (e) {
console.warn("[SFP] save tag style cache failed:", e);
}
}
function _extractTagStylesFromDocument(doc) {
Array.from(doc.querySelectorAll("a.discourse-tag[data-tag-name]")).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, loadScope, timeoutMs = 12e3) {
return new Promise((resolve) => {
const forget = loadScope.defer(() => resolve(null));
const finish = (value) => {
forget();
resolve(value);
};
const start = Date.now();
const tick = () => {
let doc = null;
try {
doc = iframe.contentDocument;
if (doc && doc.querySelector("a.discourse-tag[data-tag-name]")) {
finish(doc);
return;
}
} catch (e) {
finish(null);
return;
}
if (Date.now() - start >= timeoutMs) {
finish(doc);
return;
}
loadScope.timeout(tick, 250);
};
tick();
});
}
async function loadTagStyleIndex() {
if (scope.disposed) return;
if (tagStyleLoaded) return;
if (tagStylePromise) return tagStylePromise;
const loadScope = scope;
tagStylePromise = (async () => {
let iframe = null;
try {
let siteTags = [];
try {
siteTags = _getTopTagsFromSiteData(await loadSiteData());
} catch (e) {
siteTags = _getTopTagsFromSiteData(_extractPreloadedSiteData());
}
if (loadScope.disposed) return;
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 forgetIframe = loadScope.defer(() => iframe?.remove());
const doc = await _waitForIframeTags(iframe, loadScope);
forgetIframe();
if (loadScope.disposed) return;
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();
if (loadScope === scope) tagStylePromise = null;
}
})();
return tagStylePromise;
}
function _resetRuntimeTagStyleData() {
scope.dispose();
scope = new Lifetime();
tagStylePromise = null;
tagStyleLoaded = false;
tagStyleByKey.clear();
}
return {
get loaded() {
return tagStyleLoaded;
},
get loading() {
return !!tagStylePromise;
},
loadTagStyleIndex,
_tagDisplayName,
_getTagStyle,
reset: _resetRuntimeTagStyleData,
dispose: () => scope.dispose()
};
}
var feed_default = "/* ===== 切换按钮 ===== */\n.sfp-toggle-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: var(--secondary);\n color: var(--primary-medium);\n cursor: pointer;\n border-radius: 6px;\n padding: 0;\n margin-left: 6px;\n vertical-align: middle;\n transition:\n color 0.2s,\n background 0.2s;\n flex-shrink: 0;\n}\n.sfp-toggle-btn:hover {\n color: var(--primary);\n background: var(--primary-low);\n}\n.sfp-toggle-btn.active {\n color: var(--secondary);\n background: var(--tertiary);\n}\n.sfp-toggle-btn svg {\n width: 18px;\n height: 18px;\n fill: currentColor;\n}\n.home-logo-wrapper-outlet .title {\n display: flex;\n align-items: center;\n gap: 2px;\n}\n\n/* ===== 侧边栏 Feed 模式 ===== */\n.sidebar-wrapper:has(> .sidebar-container.sfp-feed-mode) {\n overflow-x: hidden !important;\n}\n.sidebar-container.sfp-feed-mode {\n overflow-x: hidden !important;\n}\n.sidebar-container.sfp-feed-mode .sfp-feed-container,\n.sidebar-container.sfp-feed-mode .sfp-feed-container * {\n box-sizing: border-box;\n}\n/* 隐藏所有非 feed 的直接子元素 */\n.sidebar-container.sfp-feed-mode > :not(.sfp-feed-container):not(.sfp-resizer) {\n display: none !important;\n}\n/* 显式隐藏常见 sidebar 组件(嵌套情况兜底) */\n.sidebar-container.sfp-feed-mode .sidebar-sections,\n.sidebar-container.sfp-feed-mode .sidebar-footer-container,\n.sidebar-container.sfp-feed-mode .sidebar-footer-wrapper,\n.sidebar-container.sfp-feed-mode .sidebar-footer,\n.sidebar-container.sfp-feed-mode .sidebar-custom-sections,\n.sidebar-container.sfp-feed-mode .sidebar-section-wrapper,\n.sidebar-container.sfp-feed-mode .sidebar-section-header,\n.sidebar-container.sfp-feed-mode .sidebar-section-link-wrapper {\n display: none !important;\n}\n.sidebar-container.sfp-feed-mode .sfp-feed-container {\n display: flex;\n flex-direction: column;\n position: relative;\n width: 100%;\n min-width: 0;\n height: 100%;\n overflow: hidden;\n max-width: 100%;\n}\n.sidebar-wrapper.sfp-width-animating,\n.sidebar-container.sfp-width-animating,\n#d-sidebar.sfp-width-animating {\n transition:\n width 220ms ease,\n max-width 220ms ease;\n}\n\n/* ===== 拖拽调整宽度 ===== */\n.sfp-resizer {\n position: absolute;\n top: 0;\n right: -2px;\n width: 5px;\n height: 100%;\n cursor: ew-resize;\n z-index: 10001;\n transition: background 0.2s;\n}\n.sfp-resizer:hover,\n.sfp-resizer.sfp-resizing {\n background: var(--tertiary);\n}\n\n/* ===== Feed Header ===== */\n.sfp-feed-header {\n position: relative;\n flex-shrink: 0;\n padding: 8px 12px;\n border-bottom: 1px solid var(--primary-low);\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n align-items: center;\n overflow: visible;\n}\n.sfp-feed-header .sfp-header-spacer {\n flex: 1 1 auto;\n min-width: 8px;\n}\n.sfp-feed-header .sfp-refresh-btn,\n.sfp-feed-header .sfp-settings-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n border: none;\n background: var(--primary-very-low);\n color: var(--primary-medium);\n cursor: pointer;\n border-radius: 6px;\n padding: 0;\n flex-shrink: 0;\n transition:\n color 0.2s,\n background 0.2s;\n}\n.sfp-feed-header .sfp-refresh-btn:hover,\n.sfp-feed-header .sfp-settings-btn:hover,\n.sfp-settings-wrap.open .sfp-settings-btn {\n color: var(--tertiary);\n background: var(--primary-low);\n}\n.sfp-feed-header .sfp-refresh-btn.spinning svg {\n animation: sfp-spin 0.6s linear infinite;\n}\n.sfp-feed-header .sfp-refresh-btn.spinning {\n color: var(--tertiary);\n background: var(--primary-low);\n}\n.sfp-feed-header\n .sfp-refresh-btn.sfp-back-top-enter:not(.sfp-has-incoming-count)\n svg {\n animation: sfp-back-top-enter 0.18s ease;\n}\n.sfp-feed-header .sfp-refresh-btn .sfp-refresh-count {\n display: inline-block;\n min-width: 3ch;\n color: var(--tertiary);\n font-size: 13px;\n font-weight: 700;\n line-height: 1;\n text-align: center;\n white-space: nowrap;\n}\n@keyframes sfp-spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n@keyframes sfp-back-top-enter {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n.sfp-feed-header .sfp-refresh-btn svg,\n.sfp-feed-header .sfp-settings-btn svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n}\n.sfp-settings-btn {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n gap: 3px;\n flex-direction: column;\n}\n.sfp-settings-line {\n width: 14px;\n height: 2px;\n border-radius: 2px;\n background: currentColor;\n transition:\n transform 0.28s ease,\n opacity 0.2s ease;\n transform-origin: center;\n}\n.sfp-settings-wrap.open .sfp-settings-line-1 {\n transform: translateY(5px) rotate(45deg);\n}\n.sfp-settings-wrap.open .sfp-settings-line-2 {\n opacity: 0;\n transform: scaleX(0);\n}\n.sfp-settings-wrap.open .sfp-settings-line-3 {\n transform: translateY(-5px) rotate(-45deg);\n}\n.sfp-settings-wrap {\n position: relative;\n width: 28px;\n height: 28px;\n flex-shrink: 0;\n overflow: visible;\n}\n.sfp-settings-shell {\n position: absolute;\n top: 0;\n right: 0;\n width: 28px;\n height: 28px;\n background: transparent;\n border: none;\n border-radius: 6px;\n box-shadow: none;\n z-index: 10003;\n overflow: hidden;\n transition:\n width 0.36s cubic-bezier(0.25, 1, 0.5, 1),\n height 0.36s cubic-bezier(0.25, 1, 0.5, 1),\n border-radius 0.24s ease,\n background 0.2s ease;\n}\n.sfp-settings-wrap.open .sfp-settings-shell {\n width: 204px;\n height: var(--sfp-settings-shell-height, 128px);\n overflow: visible;\n background: var(--secondary);\n border: 1px solid var(--primary-low);\n border-radius: 8px;\n box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 14%, transparent);\n}\n.sfp-settings-panel {\n box-sizing: border-box;\n width: 204px;\n padding: 36px 10px 8px 10px;\n opacity: 0;\n visibility: hidden;\n transform: translateY(-8px);\n pointer-events: none;\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n visibility 0.18s;\n}\n.sfp-settings-wrap.open .sfp-settings-panel {\n opacity: 1;\n visibility: visible;\n transform: translateY(0);\n pointer-events: auto;\n transition:\n opacity 0.26s ease 0.12s,\n transform 0.26s ease 0.12s,\n visibility 0.26s 0.12s;\n}\n.sfp-setting-row {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n column-gap: 8px;\n font-size: 12px;\n color: var(--primary);\n line-height: 1.3;\n padding: 4px 0;\n}\n.sfp-setting-label {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n min-width: 0;\n white-space: nowrap;\n}\n.sfp-setting-help-wrap {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex: 0 0 14px;\n pointer-events: none;\n}\n.sfp-setting-help {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n box-sizing: border-box;\n appearance: none;\n border: none;\n background: transparent;\n color: var(--primary-medium);\n cursor: help;\n padding: 0;\n pointer-events: auto;\n}\n.sfp-setting-help svg {\n width: 13px;\n height: 13px;\n display: block;\n fill: currentColor;\n pointer-events: none;\n}\n.sfp-setting-help:hover {\n color: var(--tertiary);\n outline: none;\n}\n.sfp-help-tooltip {\n position: fixed;\n z-index: 10004;\n width: 178px;\n max-width: calc(100vw - 32px);\n padding: 8px 9px;\n border: 1px solid var(--primary-low);\n border-radius: 6px;\n background: var(--secondary);\n box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent);\n color: var(--primary);\n font-size: 12px;\n font-weight: 400;\n line-height: 1.45;\n text-align: left;\n white-space: normal;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-4px);\n transition:\n opacity 0.16s ease,\n transform 0.16s ease;\n}\n.sfp-help-tooltip.visible {\n opacity: 1;\n transform: translateY(0);\n}\n.sfp-setting-row input[type=\"checkbox\"] {\n flex-shrink: 0;\n margin: 0;\n}\n.sfp-setting-interval {\n display: none;\n grid-template-columns: minmax(0, 1fr) 58px auto;\n align-items: center;\n column-gap: 6px;\n margin-top: 6px;\n font-size: 12px;\n color: var(--primary-medium);\n}\n.sfp-setting-interval.visible {\n display: grid;\n}\n.sfp-setting-row.hidden,\n.sfp-setting-interval.hidden {\n display: none;\n}\n.sfp-setting-interval input {\n width: 58px;\n height: 26px;\n padding: 2px 6px;\n border: 1px solid var(--primary-low);\n border-radius: 4px;\n background: var(--secondary);\n color: var(--primary);\n font-size: 12px;\n}\n\n/* ===== 自定义下拉 ===== */\n.sfp-custom-select {\n position: relative;\n flex-shrink: 0;\n}\n.sfp-custom-select-btn {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 4px 10px;\n font-size: 12px;\n height: 28px;\n border: none;\n background: var(--primary-very-low);\n color: var(--primary);\n border-radius: 6px;\n cursor: pointer;\n white-space: nowrap;\n user-select: none;\n transition:\n background 0.2s,\n color 0.2s;\n}\n.sfp-custom-select-btn:hover {\n background: var(--primary-low);\n}\n.sfp-custom-select-btn::after {\n content: \"\";\n width: 0;\n height: 0;\n border-left: 4px solid transparent;\n border-right: 4px solid transparent;\n border-top: 5px solid currentColor;\n}\n.sfp-custom-select-dropdown {\n position: absolute;\n top: calc(100% + 4px);\n left: 0;\n min-width: 100%;\n background: var(--secondary);\n border: 1px solid var(--primary-low);\n border-radius: 6px;\n box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);\n z-index: 10002;\n display: none;\n overflow: hidden;\n}\n.sfp-custom-select.open .sfp-custom-select-dropdown {\n display: block;\n}\n.sfp-custom-select-option {\n display: block;\n width: 100%;\n padding: 6px 14px;\n font-size: 12px;\n border: none;\n background: none;\n color: var(--primary);\n cursor: pointer;\n text-align: left;\n white-space: nowrap;\n transition: background 0.15s;\n}\n.sfp-custom-select-option:hover {\n background: var(--primary-very-low);\n}\n.sfp-custom-select-option.selected {\n color: var(--tertiary);\n font-weight: 600;\n}\n\n/* ===== 分类标签栏 ===== */\n.sfp-tab-shell {\n position: relative;\n display: grid;\n grid-template-columns: minmax(0, 1fr) 36px;\n align-items: stretch;\n width: 100%;\n min-width: 0;\n max-width: 100%;\n border-bottom: 1px solid var(--primary-low);\n flex-shrink: 0;\n background: var(--d-content-background, var(--secondary));\n}\n.sfp-tab-bar {\n display: flex;\n gap: 8px;\n overflow-x: auto;\n overflow-y: hidden;\n -webkit-overflow-scrolling: touch;\n scrollbar-width: none;\n width: 100%;\n min-width: 0;\n max-width: 100%;\n padding: 8px 12px;\n margin: 0;\n flex-shrink: 0;\n background: transparent;\n}\n.sfp-tab-bar::-webkit-scrollbar {\n display: none;\n}\n.sfp-tab-item {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 4px 12px;\n font-size: 13px;\n color: var(--primary-medium);\n cursor: pointer;\n white-space: nowrap;\n border-radius: 16px;\n background: var(--primary-very-low);\n transition: all 0.2s;\n border: 1px solid transparent;\n flex-shrink: 0;\n user-select: none;\n}\n.sfp-tab-item:hover {\n color: var(--primary);\n background: var(--primary-low);\n}\n.sfp-tab-item.active {\n color: var(--secondary);\n background: var(--tertiary);\n border-color: var(--tertiary);\n}\n.sfp-tab-item svg {\n width: 12px;\n height: 12px;\n fill: currentColor;\n flex-shrink: 0;\n}\n.sfp-category-color-marker {\n width: 0.72em;\n height: 0.72em;\n border-radius: 50%;\n background: var(--sfp-category-marker-color, currentColor);\n box-shadow: inset 0 0 0 1px\n color-mix(in srgb, var(--primary) 12%, transparent);\n flex: 0 0 auto;\n}\n.sfp-tab-more-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 36px;\n min-width: 36px;\n padding: 0;\n border: none;\n border-left: 1px solid var(--primary-low);\n background: var(--d-content-background, var(--secondary));\n color: var(--primary-medium);\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n.sfp-tab-more-btn:hover,\n.sfp-tab-shell.open .sfp-tab-more-btn {\n background: var(--primary-very-low);\n color: var(--primary);\n}\n.sfp-tab-more-btn svg {\n width: 16px;\n height: 16px;\n fill: currentColor;\n}\n.sfp-tab-panel {\n position: absolute;\n top: 100%;\n right: 8px;\n left: 8px;\n display: none;\n padding: 10px;\n max-height: min(58vh, 420px);\n overflow-y: auto;\n background: var(--secondary);\n border: 1px solid var(--primary-low);\n border-radius: 8px;\n box-shadow: 0 10px 28px color-mix(in srgb, var(--primary) 16%, transparent);\n z-index: 10002;\n}\n.sfp-tab-shell.open .sfp-tab-panel {\n display: block;\n}\n.sfp-tab-panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 8px;\n font-size: 12px;\n color: var(--primary-medium);\n line-height: 1.3;\n}\n.sfp-tab-panel-title {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n}\n.sfp-tab-panel-title svg {\n width: 13px;\n height: 13px;\n fill: currentColor;\n flex-shrink: 0;\n}\n.sfp-tab-panel-close {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n padding: 0;\n border: none;\n border-radius: 4px;\n background: transparent;\n color: var(--primary-medium);\n cursor: pointer;\n}\n.sfp-tab-panel-close:hover {\n background: var(--primary-very-low);\n color: var(--primary);\n}\n.sfp-tab-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));\n gap: 6px;\n}\n.sfp-tab-grid-item {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 5px;\n min-width: 0;\n min-height: 32px;\n padding: 6px 8px;\n border: 1px solid transparent;\n border-radius: 6px;\n background: var(--primary-very-low);\n color: var(--primary-medium);\n cursor: pointer;\n font-size: 12px;\n line-height: 1.2;\n text-align: center;\n user-select: none;\n transition:\n background 0.15s,\n border-color 0.15s,\n color 0.15s,\n opacity 0.15s;\n}\n.sfp-tab-grid-item svg {\n width: 12px;\n height: 12px;\n fill: currentColor;\n flex: 0 0 auto;\n}\n.sfp-tab-grid-item .sfp-category-color-marker {\n width: 0.75em;\n height: 0.75em;\n}\n.sfp-tab-grid-item span {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.sfp-tab-grid-item:hover {\n background: var(--primary-low);\n color: var(--primary);\n}\n.sfp-tab-grid-item.active {\n background: var(--tertiary);\n border-color: var(--tertiary);\n color: var(--secondary);\n}\n.sfp-tab-grid-item.dragging {\n opacity: 0.45;\n}\n.sfp-tab-grid-item.drop-target {\n border-color: var(--tertiary);\n box-shadow: inset 0 0 0 1px var(--tertiary);\n}\n\n/* ===== 筛选栏 ===== */\n.sfp-filter-bar {\n position: relative;\n z-index: 3;\n display: flex;\n align-items: center;\n gap: 12px;\n width: 100%;\n min-width: 0;\n max-width: 100%;\n padding: 8px 16px;\n margin: 0;\n background: var(--primary-very-low);\n border-bottom: 1px solid var(--primary-low);\n font-size: 12px;\n color: var(--primary-medium);\n flex-shrink: 0;\n}\n.sfp-filter-item {\n cursor: pointer;\n padding: 2px 6px;\n border-radius: 4px;\n transition: all 0.2s;\n user-select: none;\n}\n.sfp-filter-item:hover {\n color: var(--tertiary);\n background: var(--primary-low);\n}\n.sfp-filter-item.active {\n color: var(--secondary);\n background: var(--tertiary);\n}\n\n/* ===== Feed 滚动区 ===== */\n.sfp-feed-scroll {\n position: relative;\n flex: 1;\n min-width: 0;\n max-width: 100%;\n overflow-y: auto;\n overflow-x: hidden;\n -webkit-overflow-scrolling: touch;\n overscroll-behavior-y: contain;\n touch-action: pan-y pinch-zoom;\n --scrollbarBg: transparent;\n --scrollbarThumbBg: var(--d-selected, var(--token-color-surface-hovered));\n --scrollbarWidth: var(--space-2, 0.5em);\n scrollbar-gutter: stable;\n scrollbar-color: var(--scrollbarThumbBg) var(--scrollbarBg);\n}\n.sfp-feed-scroll::-webkit-scrollbar {\n width: var(--scrollbarWidth);\n}\n.sfp-feed-scroll::-webkit-scrollbar-thumb {\n background-color: var(--scrollbarThumbBg);\n border-radius: calc(var(--scrollbarWidth) / 2);\n}\n.sfp-feed-scroll::-webkit-scrollbar-track {\n background-color: transparent;\n}\n.sfp-content-wrapper {\n position: relative;\n min-width: 0;\n max-width: 100%;\n min-height: 100%;\n}\n\n/* ===== 帖子列表项 ===== */\n.sfp-topic-item {\n display: block;\n padding: 12px 20px;\n border-bottom: 1px solid var(--primary-very-low);\n color: inherit;\n cursor: pointer;\n text-decoration: none;\n transition: background 0.2s;\n position: relative;\n min-width: 0;\n max-width: 100%;\n overflow-wrap: break-word;\n word-break: break-word;\n}\n.sfp-topic-item:visited,\n.sfp-topic-item:hover,\n.sfp-topic-item:focus {\n color: inherit;\n text-decoration: none;\n}\n.sfp-topic-item:hover {\n background: var(--primary-very-low);\n}\n.sfp-topic-item:focus-visible {\n outline: 2px solid var(--tertiary);\n outline-offset: -2px;\n}\n.sfp-topic-item.sfp-filter-mismatch {\n opacity: 0.48;\n filter: grayscale(0.85);\n}\n.sfp-topic-item.sfp-topic-unavailable {\n opacity: 0.62;\n}\n.sfp-topic-item.sfp-topic-unavailable .sfp-topic-title-line {\n text-decoration: line-through;\n}\n.sfp-topic-item.sfp-new-highlight {\n --sfp-new-highlight-color: var(--tertiary-med-or-tertiary, var(--tertiary));\n animation: sfp-new-pulse 10s ease-out forwards;\n position: relative;\n}\n@keyframes sfp-new-pulse {\n 0% {\n box-shadow: inset 0 0 0 2px var(--sfp-new-highlight-color);\n background: color-mix(\n in srgb,\n var(--sfp-new-highlight-color) 15%,\n transparent\n );\n }\n 100% {\n box-shadow: inset 0 0 0 0px transparent;\n background: transparent;\n }\n}\n\n/* 未读圆点 — 紧跟在时间后 */\n.sfp-topic-item .sfp-topic-time {\n font-size: 12px;\n color: var(--primary-medium);\n white-space: nowrap;\n margin-left: auto;\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n gap: 4px;\n line-height: 1;\n}\n.sfp-topic-item .sfp-unread-dot {\n width: 8px;\n height: 8px;\n flex: 0 0 8px;\n display: inline-block;\n border-radius: 50%;\n color: var(--tertiary-med-or-tertiary, var(--tertiary));\n background: currentColor;\n opacity: 0.75;\n vertical-align: middle;\n}\n.sfp-topic-item .sfp-unread-dot.sfp-unread-dot--hidden {\n visibility: hidden;\n}\n\n/* 头像 + 用户信息行 */\n.sfp-topic-item .sfp-topic-header {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 5px;\n}\n.sfp-topic-item .sfp-topic-user-link {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n min-width: 0;\n color: inherit;\n text-decoration: none;\n cursor: pointer;\n}\n/* 防止父级 topic 链接的 hover/focus 样式把用户入口改成普通文本。 */\n.sfp-topic-item .sfp-topic-user-link:hover,\n.sfp-topic-item .sfp-topic-user-link:focus {\n color: inherit;\n text-decoration: none;\n}\n.sfp-topic-item .sfp-topic-user-link:focus-visible {\n outline: 2px solid var(--tertiary);\n outline-offset: 2px;\n border-radius: 4px;\n}\n.sfp-topic-item .sfp-topic-avatar {\n width: 28px;\n height: 28px;\n border-radius: 50%;\n flex-shrink: 0;\n object-fit: cover;\n}\n.sfp-topic-item .sfp-topic-meta-col {\n display: flex;\n flex-direction: column;\n min-width: 0;\n flex: 1;\n}\n.sfp-topic-item .sfp-topic-user-info {\n display: flex;\n align-items: center;\n gap: 5px;\n flex-wrap: wrap;\n overflow: hidden;\n}\n.sfp-topic-item .sfp-topic-username {\n font-size: 13px;\n color: var(--primary);\n font-weight: 500;\n cursor: pointer;\n transition: color 0.2s;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.sfp-topic-item .sfp-topic-username:hover {\n color: var(--tertiary);\n}\n.sfp-topic-item .sfp-topic-name {\n font-size: 12px;\n color: var(--primary-medium);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n/* 标题 */\n.sfp-topic-item .sfp-topic-title {\n font-size: 14px;\n font-weight: bold;\n color: var(--primary);\n line-height: 1.4;\n margin: 0;\n word-break: break-word;\n overflow: hidden;\n text-overflow: ellipsis;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n transition: color 0.2s;\n}\n.sfp-topic-item .sfp-topic-title:hover {\n color: var(--tertiary);\n}\n.sfp-topic-item.sfp-read .sfp-topic-title {\n color: var(--title-color--read, var(--primary-medium));\n}\n.sfp-topic-item .sfp-topic-title-line {\n display: inline;\n}\n.sfp-topic-item .sfp-topic-status-badges {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n margin-left: 2px;\n flex-shrink: 0;\n}\n.sfp-topic-item .topic-status-card {\n --badge-accent: var(--primary-medium);\n --badge-bg: var(--primary-very-low);\n --badge-border: var(--primary-low);\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 1px 6px;\n border: 1px solid var(--badge-border);\n border-radius: var(--d-border-radius, 8px);\n background: var(--badge-bg);\n color: var(--badge-accent);\n font-size: 11px;\n font-weight: 700;\n line-height: 1.45;\n margin-right: 5px;\n vertical-align: middle;\n white-space: nowrap;\n box-shadow: inset 0 0 0 1px\n color-mix(in srgb, var(--badge-accent) 8%, transparent);\n}\n.sfp-topic-item .topic-status-card.--hot {\n --badge-accent: var(--danger);\n --badge-bg: var(--danger-low, var(--d-hover, var(--tertiary-low)));\n --badge-border: color-mix(in srgb, var(--danger) 28%, transparent);\n}\n.sfp-topic-item .topic-status-card.--pinned {\n --badge-accent: var(--primary-medium);\n --badge-bg: var(--primary-very-low);\n --badge-border: var(--primary-low);\n}\n.sfp-topic-item .topic-status-card.--unavailable {\n --badge-accent: var(--danger);\n --badge-bg: var(--danger-low, var(--d-hover, var(--tertiary-low)));\n --badge-border: color-mix(in srgb, var(--danger) 28%, transparent);\n}\n.sfp-topic-item .topic-status-card__name {\n color: var(--badge-accent);\n font-size: inherit;\n font-weight: inherit;\n line-height: inherit;\n margin: 0;\n}\n.sfp-topic-item .topic-status-card .d-icon {\n color: var(--badge-accent);\n width: 0.92em;\n height: 0.92em;\n flex-shrink: 0;\n}\n.sfp-topic-item .topic-statuses {\n float: left;\n}\n.sfp-topic-item .topic-statuses .topic-status {\n display: inline-flex;\n align-items: center;\n color: var(--primary-medium);\n margin: 0 0.18em 0 0;\n --icon-size: 0.86em;\n}\n.sfp-topic-item .topic-statuses .topic-status .d-icon {\n width: var(--icon-size);\n height: var(--icon-size);\n color: currentColor;\n}\n\n/* 分类 + 标签行 */\n.sfp-topic-item .sfp-topic-category-tags {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px;\n margin-top: 5px;\n}\n.sfp-topic-item .sfp-category-badge {\n --badge-category-bg: light-dark(\n oklch(from var(--category-badge-color) 97% calc(c * 0.3) h),\n oklch(from var(--category-badge-color) 45% calc(c * 0.5) h)\n );\n --badge-category-text: light-dark(\n oklch(from var(--category-badge-color) 35% calc(c * 0.6) h),\n oklch(from var(--category-badge-color) 95% calc(c * 0.2) h)\n );\n display: inline-flex;\n align-items: center;\n gap: 0.33em;\n font-size: 11px;\n padding: 2px 6px;\n border-radius: var(--d-border-radius, 4px);\n background-color: var(--badge-category-bg, var(--primary-very-low));\n color: var(--badge-category-text, var(--primary-medium));\n flex-shrink: 0;\n max-width: 120px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.sfp-topic-item .sfp-category-badge .badge-category {\n min-width: 0;\n align-items: center;\n}\n.sfp-topic-item .sfp-category-badge .badge-category__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--badge-category-text, var(--primary-medium));\n}\n.sfp-topic-item .sfp-category-badge .d-icon {\n width: 0.9em;\n height: 0.9em;\n flex-shrink: 0;\n}\n@supports not (color: light-dark(tan, tan)) {\n .sfp-topic-item .sfp-category-badge {\n --badge-category-bg: color-mix(\n in srgb,\n var(--category-badge-color) 16%,\n transparent\n );\n --badge-category-text: var(--primary-high);\n }\n}\n\n/* 标签 */\n.sfp-topic-item .sfp-topic-tags {\n display: flex;\n gap: 3px;\n flex-wrap: wrap;\n}\n.sfp-topic-item .sfp-tag {\n font-size: 11px;\n padding: 2px 6px;\n border-radius: var(--d-border-radius, 4px);\n line-height: 1.4;\n max-width: 96px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n flex-shrink: 1;\n}\n.sfp-topic-item .sfp-tag .tag-icon .d-icon {\n width: 0.9em;\n height: 0.9em;\n}\n\n/* 统计行 */\n.sfp-topic-item .sfp-topic-stats {\n display: flex;\n gap: 12px;\n margin-top: 8px;\n font-size: 12px;\n color: var(--primary-medium);\n}\n.sfp-topic-item .sfp-topic-stat {\n display: flex;\n align-items: center;\n gap: 4px;\n}\n.sfp-topic-item .sfp-topic-stat .d-icon {\n width: 1em;\n height: 1em;\n}\n\n/* ===== 加载状态 ===== */\n.sfp-loading {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n padding: 40px 20px;\n color: var(--primary-medium);\n font-size: 13px;\n gap: 12px;\n}\n.sfp-spinner {\n width: 28px;\n height: 28px;\n border: 3px solid var(--primary-low);\n border-top-color: var(--tertiary);\n border-radius: 50%;\n animation: sfp-spin 0.8s linear infinite;\n}\n.sfp-empty {\n text-align: center;\n padding: 40px 10px;\n color: var(--primary-medium);\n font-size: 13px;\n}\n.sfp-load-more {\n padding: 14px 10px;\n text-align: center;\n font-size: 12px;\n color: var(--primary-medium);\n cursor: pointer;\n transition: color 0.2s;\n}\n.sfp-load-more-error {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: 8px;\n cursor: default;\n}\n.sfp-load-more-error .sfp-load-more-retry {\n padding: 4px 10px;\n border: none;\n border-radius: 4px;\n background: var(--tertiary);\n color: var(--secondary);\n cursor: pointer;\n font-size: 12px;\n}\n.sfp-load-more:hover {\n color: var(--tertiary);\n}\n.sfp-load-more .sfp-load-more-spinner {\n display: inline-block;\n width: 14px;\n height: 14px;\n border: 2px solid var(--primary-low);\n border-top-color: var(--tertiary);\n border-radius: 50%;\n animation: sfp-spin 0.8s linear infinite;\n vertical-align: middle;\n margin-right: 6px;\n}\n.sfp-no-more {\n padding: 14px 10px;\n text-align: center;\n font-size: 11px;\n color: var(--primary-low-mid);\n}\n.sfp-load-more-note {\n padding: 10px 10px 0;\n text-align: center;\n font-size: 12px;\n color: var(--primary-medium);\n}\n.sfp-error {\n padding: 40px 20px;\n text-align: center;\n color: var(--danger);\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 10px;\n}\n.sfp-error-icon {\n font-size: 32px;\n}\n.sfp-error-msg {\n font-size: 14px;\n font-weight: 600;\n}\n.sfp-error-detail {\n font-size: 12px;\n color: var(--primary-medium);\n word-break: break-word;\n}\n.sfp-error .sfp-retry-btn {\n margin-top: 6px;\n padding: 6px 16px;\n background: var(--tertiary);\n color: var(--secondary);\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n transition: opacity 0.2s;\n}\n.sfp-error .sfp-retry-btn:hover {\n opacity: 0.85;\n}\n";
var instance = null;
var menuRegistered = false;
function startFeedPanel() {
if (instance) return instance;
const storage = createSiteStorage(location.origin, gm);
storage.migrateLegacy();
const discourse = createDiscourseBridge(gm.pageWindow());
const prefs = new Preferences(storage);
let controller;
const site = createSiteData({
storage,
getCsrfToken: discourse.getCsrfToken,
onChange: () => {
controller?.normalizeSiteState();
}
});
const tags = createTagStyles({
storage,
site
});
controller = createFeedController({
storage,
discourse,
prefs,
site,
tags
});
const style = gm.addStyle(feed_default);
const application = {
clearCaches: controller.clearCaches,
dispose() {
if (instance !== application) return;
instance = null;
controller?.dispose();
tags.dispose();
site.dispose();
discourse.dispose();
style?.remove();
}
};
instance = application;
if (!menuRegistered) {
gm.registerMenu("SFP: 清空分类和标签缓存", () => instance?.clearCaches({ reload: true }));
menuRegistered = true;
}
try {
const page = gm.pageWindow();
page.SFPFeedPanel = Object.assign(page.SFPFeedPanel || {}, {
clearCaches: () => instance?.clearCaches({ reload: true }),
dispose: () => instance?.dispose(),
start: () => {
startFeedPanel();
}
});
} catch (error) {
console.warn("[SFP] setup cache controls failed:", error);
}
controller.start();
return application;
}
if (window.top === window.self) startFeedPanel();
})();