Discourse 在用户资料页开启了实验性的侧边导航,横向 tab 被替换为侧栏,移动端则把面板进放进页面顶部的汉堡菜单。新导航模式在移动端不够直观,操作步骤变多,体验极差。
Discourse Meta – 24 Sep 26
Experimental user sidebar navigation
News and Events
Announcements
We use contextual navigation in various parts of Discourse now (admin, documentation categories, chat, AI conversations)… one place with a lot of navigation we haven’t applied this yet is user profiles. This is an area where we have quite a few...
阅读时间: 1 分钟 🕑
赞: 28 ❤
这个脚本可以恢复用户资料页原本的横向 tab 导航 ,也可以通过油猴 GM 菜单切换为侧栏模式 。
// ==UserScript==
// @name Discourse User Nav Restore
// @name:zh-CN Discourse 用户资料页导航恢复
// @namespace discourse-user-nav-restore
// @version 0.1.0
// @description Restore horizontal tab navigation on Discourse user profile pages (override the experimental sidebar_user_navigation from discourse/discourse#44050), or force the sidebar mode - switchable from the userscript menu.
// @description:zh-CN 恢复 Discourse 用户资料页原本的横向 tab 导航(客户端覆盖 PR #44050 引入的实验性 sidebar_user_navigation 设置),也可强制侧栏模式;在脚本菜单中切换。
// @author Cyan
// @match https://linux.do/*
// @run-at document-start
// @noframes
// @license MIT
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant unsafeWindow
// ==/UserScript==
/*
* 原理(基于 discourse/discourse 源码核实,对应 PR #44050):
*
* 该实验功能开启时,横向用户导航在 Ember 层被条件渲染为"不渲染"(controller 的
* showUserNavigation getter 直接 return false),所以纯 CSS 无法恢复,必须让
* sidebar_user_navigation 的值本身回到目标状态。
*
* 1) 启动前补丁:Discourse 从 <div id="data-preloaded"> 的 textContent 读取 JSON
* (外层 JSON.parse 后,每个 value 再各自 JSON.parse)。脚本在应用 JS 执行前改写
* 其中 siteSettings.sidebar_user_navigation;另外 upcomingChanges /
* themeSiteSettingOverrides 两个预加载块的同名值会覆盖 siteSettings
* (见 site-settings 服务的 createSiteSettingsFromPreloaded),需一并删除。
*
* 2) 启动后兜底:若补丁失败(数据结构变化/时序异常),等 Discourse.__container__
* 就绪后,直接修改 site-settings 服务实例上的值 —— 它是 tracked 代理对象,
* 赋值会触发 Ember 响应式重渲染(body class 摘除、横向导航重新渲染),
* 同时也是菜单"免刷新切换"的实现。
*
* 改动只存在于本浏览器会话,不影响服务器配置和其他用户。
*/
(function () {
"use strict";
const SETTING_KEY = "sidebar_user_navigation";
const PRELOADED_ID = "data-preloaded";
const OVERRIDE_KEYS = ["upcomingChanges", "themeSiteSettingOverrides"];
const MODE_KEY = "nav_mode";
const LOG = "[Discourse User Nav Restore]";
const SERVICE_NAMES = [
"service:site-settings",
"service:siteSettings",
"service:site-settings-service",
"site-settings:main",
];
let sawDiscourse = false;
let menuId = null;
const pageWindow = () =>
typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
function getMode() {
return GM_getValue(MODE_KEY, "tab") === "sidebar" ? "sidebar" : "tab";
}
function safeParse(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* 纯函数:改写预加载 map(raw 是外层 JSON.parse 的结果,各 value 为 JSON 字符串)。
* 返回是否有改动。
*/
function patchPreloadedData(raw, desired) {
let changed = false;
if (raw && typeof raw.siteSettings === "string") {
const settings = safeParse(raw.siteSettings);
if (settings && typeof settings === "object") {
if (settings[SETTING_KEY] !== desired) {
settings[SETTING_KEY] = desired;
raw.siteSettings = JSON.stringify(settings);
changed = true;
}
}
}
// 这两个块的同名值会在 site-settings 服务初始化时覆盖 siteSettings,直接删除
for (const key of OVERRIDE_KEYS) {
if (typeof raw?.[key] !== "string") continue;
const obj = safeParse(raw[key]);
if (obj && typeof obj === "object" && SETTING_KEY in obj) {
delete obj[SETTING_KEY];
raw[key] = JSON.stringify(obj);
changed = true;
}
}
return changed;
}
function waitFor(check, timeoutMs, intervalMs = 10) {
return new Promise((resolve) => {
const deadline = Date.now() + timeoutMs;
const tick = () => {
let value = null;
try {
value = check();
} catch {
value = null;
}
if (value) return resolve(value);
if (Date.now() >= deadline) return resolve(null);
setTimeout(tick, intervalMs);
};
tick();
});
}
/** 在应用 JS 执行前改写预加载数据。返回 "applied" | "noop" | "failed"。 */
async function applyPreBootPatch(desired) {
const el = await waitFor(
() => document.getElementById(PRELOADED_ID),
15000,
8
);
if (!el || !el.textContent) return "failed";
sawDiscourse = true;
try {
const raw = JSON.parse(el.textContent);
if (!raw || typeof raw !== "object") return "failed";
const settings = safeParse(raw.siteSettings);
const overridden = OVERRIDE_KEYS.some((key) => {
const obj = safeParse(raw[key]);
return obj && typeof obj === "object" && SETTING_KEY in obj;
});
// 站点本来就没开、目标又是关闭 -> 不碰页面
if (
desired === false &&
!overridden &&
!(settings && settings[SETTING_KEY] === true)
) {
return "noop";
}
if (patchPreloadedData(raw, desired)) {
el.textContent = JSON.stringify(raw);
console.debug(LOG, "pre-boot patch applied:", SETTING_KEY, "->", desired);
return "applied";
}
return "failed";
} catch (err) {
console.warn(LOG, "pre-boot patch failed:", err);
return "failed";
}
}
/** 通过应用容器直接修改 site-settings 服务;成功返回 true。 */
function setPageSetting(desired) {
const w = pageWindow();
const container = w.Discourse && w.Discourse.__container__;
if (!container) return false;
for (const name of SERVICE_NAMES) {
let svc;
try {
svc = container.lookup(name);
} catch {
continue;
}
// available_locales 是服务初始化时必然存在的客户端设置,用来确认身份
if (!svc || !("available_locales" in svc)) continue;
try {
if (svc[SETTING_KEY] !== desired) svc[SETTING_KEY] = desired;
return svc[SETTING_KEY] === desired;
} catch {
try {
svc.set(SETTING_KEY, desired);
return true;
} catch {
continue;
}
}
}
return false;
}
async function applyPostBootFix(desired) {
const found = await waitFor(() => {
const w = pageWindow();
return w.Discourse && w.Discourse.__container__ ? true : null;
}, 30000, 25);
if (!found) return false;
sawDiscourse = true;
const ok = setPageSetting(desired);
if (ok) {
console.debug(LOG, "post-boot fix applied:", SETTING_KEY, "->", desired);
} else {
console.warn(LOG, "site-settings service not found; fallback unavailable");
}
return ok;
}
function registerMenu() {
if (typeof GM_registerMenuCommand !== "function") return;
if (menuId !== null) {
if (typeof GM_unregisterMenuCommand !== "function") return;
try {
GM_unregisterMenuCommand(menuId);
} catch {
/* ignore */
}
menuId = null;
}
const mode = getMode();
menuId = GM_registerMenuCommand(
mode === "tab"
? "Discourse 用户导航: 横向 Tab(点击切换为 Sidebar 侧栏)"
: "Discourse 用户导航: Sidebar 侧栏(点击切换为横向 Tab)",
() => {
const next = getMode() === "tab" ? "sidebar" : "tab";
GM_setValue(MODE_KEY, next);
registerMenu();
// 优先实时翻转(Ember 响应式,免刷新);失败时刷新走启动前补丁路径;
// 非 Discourse 页面只保存偏好,下次访问生效。
if (!setPageSetting(next === "sidebar")) {
if (sawDiscourse) location.reload();
}
console.debug(LOG, "mode ->", next);
}
);
}
async function main() {
registerMenu();
const desired = getMode() === "sidebar";
const outcome = await applyPreBootPatch(desired);
// 预补丁失败时,等应用启动后用响应式兜底补上
if (outcome === "failed") await applyPostBootFix(desired);
}
// 浏览器里跑主流程;node 环境导出纯函数供单测
if (
typeof window !== "undefined" &&
typeof document !== "undefined" &&
typeof GM_getValue === "function"
) {
main();
} else if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
module.exports = { patchPreloadedData };
}
})();