即将上线的帖子菜单更改——如何准备主题和插件

作为我们持续改进 Discourse 代码库工作的一部分,我们正在移除对旧版“widget”渲染系统的使用,并将其替换为 Glimmer 组件。

最近,我们 对帖子菜单进行了现代化改造,目前该功能已在 Discourse 中通过 glimmer_post_menu_mode 设置提供。

此设置接受三个可能的值:

  • disabled:使用旧版“widget”系统
  • auto:将检测您当前插件和主题的兼容性。如果有任何不兼容项,它将使用旧版系统;否则将使用新菜单。
  • enabled:将使用新菜单。如果您有任何不兼容的插件或主题,您的站点可能会出错。

我们已经更新了官方插件以兼容新菜单,但如果您仍有与新版菜单不兼容的第三方插件、主题或主题组件,则必须对其进行升级。

浏览器控制台中将打印警告信息,指出不兼容性的来源。

:timer_clock: 推出时间表

这些是粗略估计,可能会发生变化

2024 年第四季度:

  • :white_check_mark: 核心实现已完成
  • :white_check_mark: 官方插件已更新
  • :white_check_mark: 已在 Meta 站点上启用
  • :white_check_mark: glimmer_post_menu_mode 默认值为 auto;已启用控制台弃用消息
  • :white_check_mark: 已发布升级建议

2025 年第一季度:

  • :white_check_mark: 第三方插件和主题应已完成更新
  • :white_check_mark: 弃用消息开始生效,任何剩余问题都将触发管理员警告横幅
  • :white_check_mark: 默认启用新帖子菜单

2025 年第二季度

  • :white_check_mark: 4 月 1 日 - 移除功能标志设置和旧代码

:eyes: 这对您意味着什么?

如果您的插件或主题使用任何“widget”API 来自定义帖子菜单,则需要进行更新以兼容新版本。

:person_tipping_hand: 我如何尝试新帖子菜单?

在最新版本的 Discourse 中,如果您没有安装不兼容的插件或主题,新帖子菜单将自动启用。

如果您已安装了不兼容的扩展,作为管理员,您仍然可以将设置更改为 enabled 以强制使用新菜单。请谨慎使用此功能,因为根据您安装的自定义内容,您的站点可能会出错。

如果这种自动系统未按预期工作(这种情况很少见),您可以使用上述设置暂时覆盖此“自动功能标志”。如果您需要这样做,请在此主题中告知我们。

:technologist: 我需要更新我的插件和主题吗?

如果您的插件或主题执行了以下任何自定义操作,则需要进行更新:

  • 在这些 widget 上使用 decorateWidgetchangeWidgetSettingreopenWidgetattachWidgetAction

    • post-menu
    • post-user-tip-shim
    • small-user-list
  • 使用以下任何 API 方法:

    • addPostMenuButton
    • removePostMenuButton
    • replacePostMenuButton

:bulb: 如果您有执行上述自定义操作的扩展,当您访问主题页面时,控制台中将打印警告信息,指出需要升级的插件或组件。

弃用 ID 为:discourse.post-menu-widget-overrides

:warning: 如果您在实例中使用了多个主题,请务必检查所有主题,因为警告信息仅针对当前启用的插件以及正在使用的主题和主题组件打印。

替代方案是什么?

我们引入了值转换器 post-menu-buttons 作为自定义帖子菜单的新 API。

该值转换器提供一个 DAG 对象,允许添加、替换、删除或重新排序按钮。它还提供上下文信息,例如与菜单关联的帖子、正在显示的帖子状态以及按钮键,以便更轻松地放置项目。

DAG API 期望接收 Ember 组件,如果 API 需要新的按钮定义(如 .add.replace)。

每个自定义操作都不同,但以下是针对最常见用例的一些指导:

addPostMenuButton

之前:

withPluginApi("1.34.0", (api) => {
  api.addPostMenuButton("solved", (attrs) => {
    if (attrs.can_accept_answer) {
      const isOp = currentUser?.id === attrs.topicCreatedById;
      return {
        action: "acceptAnswer",
        icon: "far-check-square",
        className: "unaccepted",
        title: "solved.accept_answer",
        label: isOp ? "solved.solution" : null,
        position: attrs.topic_accepted_answer ? "second-last-hidden" : "first",
      };
    }
  });
});

之后:

以下示例使用 Ember 的 模板标签格式 (gjs)

// components/solved-accept-answer-button.gjs
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d_button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";

export default class SolvedAcceptAnswerButton extends Component {
  // 指示按钮是直接显示还是隐藏在“显示更多”按钮之后
  static hidden(args) { 
    return args.post.topic_accepted_answer;
  }

  ...

  <template>
    <DButton
      class="post-action-menu__solved-unaccepted unaccepted"
      ...attributes
      @action={{this.acceptAnswer}}
      @icon="far-check-square"
      @label={{if this.showLabel "solved.solution"}}
      @title="solved.accept_answer"
    />
  </template>
}

// initializer.js
import SolvedAcceptAnswerButton from "../components/solved-accept-answer-button";

...
withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer(
    "post-menu-buttons",
    ({
      value: dag, 
      context: {
        post,
        firstButtonKey, // 第一个按钮的键
        secondLastHiddenButtonKey, // 倒数第二个隐藏按钮的键
        lastHiddenButtonKey, // 最后一个隐藏按钮的键
      },
    }) => {
        dag.add(
          "solved",
          SolvedAcceptAnswerButton,
          post.topic_accepted_answer
            ? {
                before: lastHiddenButtonKey,
                after: secondLastHiddenButtonKey,
              }
            : {
                before: [
                  "assign", // 由 assign 插件添加的按钮
                  firstButtonKey,
                ],
              }
        );
    }
  );
});

:bulb: 为按钮设置样式

建议在您的组件中包含 ...attributes,如上例所示。

结合使用组件 DButtonDMenu 时,这将处理样板类,并确保您的按钮遵循帖子菜单中其他按钮的格式。

可以使用自定义类指定额外的格式。

replacePostMenuButton

  • 之前:
withPluginApi("1.34.0", (api) => {
  api.replacePostMenuButton("like", {
    name: "discourse-reactions-actions",
    buildAttrs: (widget) => {
      return { post: widget.findAncestorModel() };
    },
    shouldRender: (widget) => {
      const post = widget.findAncestorModel();
      return post && !post.deleted_at;
    },
  });
});
  • 之后:
import ReactionsActionButton from "../components/discourse-reactions-actions-button";

...

withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer(
    "post-menu-buttons",
    ({ value: dag, context: { buttonKeys } }) => {
      // ReactionsActionButton 是新的按钮组件
      dag.replace(buttonKeys.LIKE, ReactionsActionButton);
    }
  );
});

removePostMenuButton

  • 之前:
withPluginApi("1.34.0", (api) => {
  api.removePostMenuButton('like', (attrs, state, siteSettings, settings, currentUser) => {
    if (attrs.post_number === 1) {
      return true;
    }
  });
});
  • 之后:
withPluginApi("1.34.0", (api) => {
  api.registerValueTransformer(
    "post-menu-buttons",
    ({ value: dag, context: { post, buttonKeys } }) => {
      if (post.post_number === 1) {
        dag.delete(buttonKeys.LIKE);
      }
    }
  );
});

:sos: 其他自定义操作怎么办?

如果您的自定义操作无法使用我们引入的新 API 实现,请创建一个新的开发主题进行讨论。

:sparkles: 我是插件/主题作者。如何在过渡期间更新主题/插件以同时支持旧版和新版帖子菜单?

我们在插件中使用了以下模式来同时支持旧版和新版帖子菜单:

function customizePostMenu(api) {
  const transformerRegistered = api.registerValueTransformer(
    "post-menu-buttons",
    ({ value: dag, context }) => {
      // 新帖子菜单自定义操作
      ...
    }
  );

  const silencedKey =
    transformerRegistered && "discourse.post-menu-widget-overrides";

  withSilencedDeprecations(silencedKey, () => customizeWidgetPostMenu(api));
}

function customizeWidgetPostMenu(api) {
  // 旧版“widget”代码自定义操作放在这里
  ...
}

export default {
  name: "my-plugin",

  initialize(container) {
    withPluginApi("1.34.0", customizePostMenu);
  }
};

:star: 更多示例

您可以查看我们的官方插件,了解如何使用新 API 的示例:

我们将 Glimmer Post Menu 默认设置为 enabled

一旦您的 Discourse 实例升级,这将导致未更新到新 API 的现有自定义设置将不被应用。

目前,管理员在更新剩余自定义设置的同时,仍可以将设置改回 disabled

旧代码将在第一季度初被移除。

我赞赏拥有完整组件 API 的灵活性。我喜欢 Glimmer 组件的整体语法,并且我明白它可能在降低代码库复杂性方面具有优势。

但是,对于基本用例(我想添加一个按钮并为其添加图标),旧的 API 方法在客观上更简洁易懂(导入更少,操作更少,暴露的 API 占用空间更小)。旧的 API 方法是否有任何理由不能继续支持?我想象如果将它们用作便捷函数,并使用新的 Glimmer 组件执行底层实现,那么 API 方法也可以执行版本兼容性检查。

这将对使用这些方法的任何人造成的干扰要小得多,并且可以减少插件生态系统中条件逻辑的代码爆炸。

我对现有组件的主要抱怨是它们缺乏文档。这篇宣布移除它们的帖子是我见过的关于这些特定方法存在以及如何使用它们的 सर्वात清晰的文档之一。事实上,我来这里是为了弄清楚如何使用旧的 API。

我喜欢转换器在一个地方,通过字符串字面量进行注册。我认为这使得文档(和插件开发)工作变得容易得多。

对于组件,它们似乎都通过 createWidget 方法注册,该方法然后调用 createWidgetFrom。我在这里看到的问题是,_注册表是一个文件作用域的全局变量,由一个 API 保护,而该 API 不允许任何迭代。如果我们能在组件注册表中获得一个迭代函数,那么我们就可以实时发现当前注册的组件。应该记录“在浏览器控制台中运行此行 JavaScript 来调用 API 并列出注册表”。然后,我们可以获得与 transformer 注册表提供的功能非常相似的功能。

在插件开发方面,另一件有帮助的事情是,在组件/组件渲染的任何根 DOM 元素上看到一个属性,告诉我们您正在查看哪个组件/组件。例如“data-widget=foo”。这可以是一个调试功能,也可以默认启用。它是开源的,所以您并不是通过隐藏来实现安全性。

我赞扬向 Glimmer 组件的转变。但这需要时间,在此期间,人们需要处理很多组件。因此,我认为如上所述改进组件的可视性可能会让每个人都更容易过渡。

至于那些 API 方法……看起来有人费尽心思为 JavaScript API 添加了详细的注释,但从未为此生成文档站点。为什么不呢?

如果可行,我很乐意提交一个用于迭代组件注册表的拉取请求。

此外,如果我只想实现新的功能,我应该将我的插件兼容性锁定到哪个Discourse API版本?你在所有示例中都使用了 withPluginApi("1.34.0" ...)。我认为这是一个较旧的版本,并不代表这一变更何时进行的?但请澄清。谢谢!

这是正确的版本。您可以在此处查看更新日志:

https://github.com/discourse/discourse/blob/main/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md?plain=1#L64-L67

此外,此功能可能有所帮助:Pinning plugin and theme versions for older Discourse installs (.discourse-compatibility)

小部件的移除已经进行了一两年,我们希望在未来几个月内完成。所以我认为在此之前我们不会对底层系统进行任何更改。

您试过 Ember Inspector 了吗?我认为它应该能解决您描述的问题,甚至还能显示当前未渲染任何 DOM 元素的 Ember 组件。

非常近期,这个版本号不再是必需的。你可以这样做

export default apiInitializer((api) => {

或者

withPluginApi((api) => {

我们很快就会更新文档以反映这一变化。管理版本互通性的现代首选是 @Arkshine 提到的 .discourse-compatibility 文件:

这真是太好了!每次我都会忘记更新那个数字 :rofl:

@david @Arkshine 哇,很棒的帖子,真的很有帮助!

另一个问题——关于调用 api.registerValueTransformer 时共享的“上下文”,我该如何知道会传递给我什么上下文? 我想我可以直接 console.log 上下文,但如果能提前知道有什么可用就好了。

对于我现在正在编写的插件,我正在为主题的作者授予特殊的审核权限。为了做到这一点,我需要知道“当前主题作者”、“当前用户”和“登录用户的组成员身份”。

也许这个具体的例子有助于为我的问题提供背景。

编辑:

我的最终代码如下,供其他有类似兴趣的人参考:

const currentUser = api.getCurrentUser()
const validGroups = currentUser.groups.filter(g => ['admins', 'staff', 'moderators'].includes(g.name))

// 如果当前用户是帖子的作者,或者他们在特权组中
if (post?.topic?.details?.created_by?.id === currentUser.id || validGroups.length > 0) {
  // 授予他们访问该功能的权限
  ...
}

恐怕我们目前没有任何中央文档。应用程序的某些区域有自己的特定文档例如,主题列表,其中列出了上下文参数。但除此之外,最好的办法是在核心代码库中搜索 applyTransformer 调用,或者使用 console.log

关于 transformer 的一般文档可以在这里找到:Using Transformers to customize client-side values and behavior

搜索“applyTransformer”返回 0 个结果。是我找错地方了吗?

我确实发现搜索“api.registerValueTransformer”返回了一些有用的示例。但这些示例当然没有提供返回的上下文的全面文档——它们只显示了对该特定示例有用的那些。

从我特定示例中 contextconsole.log 输出来看,我看到返回了 post 但没有返回 user。那么,我该如何访问不包含在上下文中的其他应用程序状态呢?

我了解到之前可能会在 api.decorateWidget 上下文中调用 helper.getModel()helper.currentUser。我假设现在有一些现成的方法可以获得类似的结果。

感谢您的所有帮助。

哦,我想我找到了答案。 这个例子 展示了 api.getCurrentUser() 的用法。所以,API 的那部分实际上没有改变,仍然与 Glimmer 范式兼容。

我认为他指的是 applyValueTransformerapplyBehaviorTransformer。您可以在以下文件中找到这些函数:discourse/app/assets/javascripts/discourse/app/lib/transformer.js at main · discourse/discourse · GitHub

旧帖子菜单代码已被移除。感谢所有更新了他们的主题和插件 :rocket: 的人。