• Tayama

    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 };
    }
    })();

    主楼
  • 還記得你說家是唯一的城堡_隨著稻香河流繼

    discourse : 跟我作对是吧^-^

    #1
  • Tayama

    开发者知道移动端交互很糟糕,但还是默认启用了。

    图片

    图片

    #2

发表回复

登录后回复