安装此主题组件
主题预览模态框 – 在不离开主题列表的情况下打开并交互主题
我创建了一个新的 Discourse 主题组件,名为 Topic Preview Modal(主题预览模态框)。
这个想法相当简单:
直接在主题列表中通过原生 Discourse 模态框打开主题,阅读并与其交互,然后继续浏览列表,而无需导航离开当前页面。
它起源于 Facebook-style Topic Modal - Is it better? ,但最终需要与 Discourse 的主题、帖子流、编辑器、模态框、书签、路由、在线状态、阅读跟踪和预取系统进行大量集成。
为什么?
Discourse 的正常流程是:
- 你正在浏览主题列表。
- 你点击一个主题。
- Discourse 导航到
/t/...。 - 你阅读/回复/与该主题交互。
- 你返回主题列表。
对于许多工作流来说,这完全没问题。
然而,当浏览繁忙的主题列表时,有时我只想快速查看一个主题,阅读几个帖子,检查最新回复,对某些内容做出反应,或者回答一个简单的问题。
对于这种使用场景,离开主题列表感觉成本太高了。
因此,该组件的目标是让主题列表的行为更像收件箱:
主题列表 → 预览 → 交互 → 关闭 → 继续之前的位置。
它的功能
预览不仅仅是静态摘录。
它在原生 DModal 中渲染实际的 Discourse 帖子组件。
这意味着用户可以:
- 阅读帖子
- 滚动浏览主题
- 加载更早的帖子
- 在下方加载更多帖子
- 对帖子做出反应
- 收藏帖子
- 引用文本
- 回复主题
- 回复单个帖子
- 在允许时编辑帖子
- 在允许时删除/恢复帖子
- 举报帖子
- 查看帖子历史
- 执行各种正常的帖子操作
- 查看主题在线状态
- 点击链接跳转到同一主题内的其他帖子
- 直接跳转到相关帖子
- 在需要时打开完整主题
其意图是让预览尽可能接近实际打开主题的体验。
两种触发模式
有两种方式可以打开预览。
1. 整个主题列表行
这是默认设置。
整个主题列表行变为可点击,而常见的交互元素,例如:
- 用户卡片
- 参与者
- 分类链接
- 标签
- 主题状态链接
- 批量选择
则被排除在模态框触发范围之外。
这使得在浏览主题列表时的体验非常快速。
2. 显式展开按钮
或者,该组件可以通过 Discourse 插件出口(plugin outlet)渲染一个小展开图标。自定义主题可以简单地创建一个新的 <PluginOutlet /> 来显示触发器。
在这种模式下,正常的主题列表行为完全保持不变。
用户点击展开图标以打开预览,而点击主题标题仍然执行正常的 Discourse 导航。
如果站点希望保留标准的主题列表交互模型,这很有用。
设置如下:
trigger_style:
row
或:
trigger_style:
button
如果使用按钮模式,出口(outlet)也是可配置的。
预览从用户的未读位置开始
一个重要细节是,模态框并不是简单地加载第一个帖子。
当主题已被部分阅读时,预览会计算:
last_read_post_number + 1
并围绕该帖子打开。
因此,如果一个主题有 200 个帖子,而用户已读到第 165 个帖子,打开预览时将从第 166 个帖子左右开始。
这使得预览在实际浏览中更加实用。
这也意味着组件必须处理帖子流的两侧:
- 在必要时加载更早的帖子
- 在下方加载更新的帖子
当当前加载范围上方存在帖子时,会显示更早的帖子按钮,而一个 IntersectionObserver 哨兵会在用户到达底部时自动加载更多帖子。
预取
该组件最大的部分之一是它的预取系统。
像这样的模态框的问题是,用户期望它感觉是即时的。
如果我们只在用户点击后才开始加载主题,模态框可能仍会花费明显的时间等待网络。
相反,该组件可以在用户浏览列表时主动预取主题。
当主题行接近视口时,IntersectionObserver 可以安排一次预取。
为了防止这变成不受控制的后台流量,有几个保障措施。
防抖
主题不会仅仅因为短暂出现在视口中就立即触发请求。
组件会等待配置的防抖时间。
默认值:
400 ms
这在快速滚动长主题列表时特别有用。
根边距
预取可以在主题实际进入视口之前稍微提前开始。
默认值:
50 px
这为请求提供了一点提前量。
并发请求限制
同时进行的预取数量是有限制的。
默认值:
2
该设置允许 1 到 6 个并发预取。
每分钟预算
还有一个第二保护机制:
max_prefetches_per_minute
默认值为:
15
因此,即使用户继续滚动浏览数百个主题,组件也不会持续生成推测性请求。
0 表示禁用此限制。
可以完全禁用预取
如果站点不希望有任何推测性网络流量:
enable_prefetch = false
组件将继续正常工作。主题只需在打开预览时加载。
预取数据与正常主题导航保持分离
这里有一个重要的实现细节。
预取的响应不会立即写入 Discourse 正常的 topic_<id> 预加载键。
相反,组件使用自己的命名空间:
topic-preview-modal:prefetch:<topicId>
只有当用户实际打开预览时,预取的 Promise 才会提升为核心主题预加载键。
这是有意为之的。
预览可能正在从 last_read_post_number + 1 开始加载主题,我不希望这种特定于预览的响应泄漏到正常的主题路由导航中。
因此,生命周期基本上是:
主题进入视口
↓
预取
↓
私有预加载存储
↓
用户打开预览
↓
提升预加载
↓
Topic.find()/PostStream 使用同一个 Promise
这也意味着模态框不必等待预取请求完成才能打开。
模态框可以立即以其骨架屏打开,同时同一个 Promise 继续解析。
移动端支持
这实际上是我在实现上花费更多时间的原因之一。
初始想法在桌面上工作得还算不错,但移动端暴露了围绕以下方面的问题:
- 触摸交互
- 模态框滚动
- 焦点
- 嵌套菜单
- 编辑器
- 帖子可见性
- 图片加载
- 性能
因此,最终实现避免了将模态框视为一个完全独立的微型论坛。
相反,它尽可能重用 Discourse 现有的基础设施。
真实的 Discourse 帖子组件
模态框不使用简化的自定义模板重新创建帖子。
它渲染 Discourse 实际的:
Post
PostSmallAction
组件。
这很重要,否则预览很快就会变成帖子 UI 的第二套实现。
组件将相关操作传递给正常的帖子组件,包括:
- 回复
- 编辑
- 删除
- 恢复
- 举报
- 历史
- 收藏
- Wiki
- 锁定/解锁
- 帖子类型
- 所有权变更
- 徽章
- 隐藏帖子
- 引用
- 等等
结果是,预览可以比传统的“预览”组件更像正常的主题。
回复和编辑器
编辑器是更复杂的部分之一。
预览可以为以下情况打开正常的 Discourse 编辑器:
回复主题
主题编辑器使用主题模型和正确的草稿信息打开。
回复特定帖子
帖子被传递给编辑器,以便回复行为像正常的帖子回复一样。
引用选中文本
组件还与 PostTextSelection 集成。
这意味着用户可以在预览中选择文本,并使用 Discourse 正常的引用/回复流程。
嵌套模态框
另一个棘手部分是 Discourse 的模态框系统。
帖子可以打开其他模态框和对话框:
- 举报
- 历史
- 徽章相关对话框
- 所有权变更
- 删除确认
- 等等
如果允许它们正常与全局模态框服务交互,打开其中一个可能会关闭整个主题预览。
为了避免这种情况,组件创建了一个本地子模态框机制。
概念上:
主题预览模态框
│
├── 举报模态框
├── 历史模态框
├── 删除确认
├── 徽章模态框
└── 其他帖子相关模态框
预览保持在下方挂载状态。
组件在其活动期间临时修补相关的模态框服务方法,并在销毁时恢复它们。
模态框内的路由
另一个重要细节是同一主题内帖子的链接。
例如,如果一个帖子包含指向以下内容的链接:
/t/my-topic/123
预览不需要关闭并导航离开。
相反,组件拦截同主题导航,并在模态框内跳转到请求的帖子。
同样适用于指向主题但没有特定帖子编号的链接。
这使用户保持在预览中。
如果链接指向真正不同的主题,组件首先恢复其临时服务修补并关闭自身,然后才允许正常的 Discourse 路由转换。
这种清理很重要,否则预览的订阅和计时跟踪器可能在真实主题路由初始化期间仍然保持活跃。
阅读跟踪和时间跟踪
我还希望预览从 Discourse 的角度来看行为正确。
打开预览不应意味着完全绕过阅读跟踪。
因此,组件处理:
- 主题访问跟踪
- 可见帖子跟踪
- 主题计时
- 最后阅读帖子更新
计时跟踪器使用 IntersectionObserver 来确定哪些帖子实际上是可见的。
每 5 秒,可见帖子计时会刷新到:
/topics/timings
当模态框关闭时,会执行最后一次刷新,以免丢失最后几秒的数据。
实现还将单个计时间隔限制在 60 秒以内。
保持主题列表的未读状态同步
这里还有一个微妙的问题。
仅更新 Discourse 的主题跟踪状态并不足以更新直接显示在主题列表行上的未读徽章。
因此,组件在计时信息刷新后更新与该行关联的实际主题对象。
它会更新诸如以下值:
last_read_post_number
unread_posts
unread
new_posts
(在适当的时候)。
这意味着在模态框内阅读主题后,主题列表可以立即反映新的阅读状态,而无需完全刷新页面。
帖子可见性
预览使用共享的 IntersectionObserver 来确定单个帖子何时变得可见。
在观察器附加时还有一个同步的可见性检查。
这处理了一个边缘情况:当帖子挂载时已经可见,但异步的第一个 IntersectionObserver 回调尚未触发。
这对于非常短的主题特别相关,因为当模态框打开时,整个主题可能已经可见。
性能考虑
一个主要目标是避免将模态框变成性能沉重的微型主题页面。
为此专门做了一些事情。
渐进式渲染
初始加载不会立即渲染每个帖子。
组件首先渲染足够多的帖子以达到目标位置。
然后使用以下方法渐进式渲染剩余的帖子:
requestIdleCallback
(如果可用),否则回退到 setTimeout。
这在围绕帖子流中较远位置的帖子打开长主题时特别有用。
CSS 包含
帖子使用:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
这允许浏览器避免对当前不可见的帖子进行不必要的渲染工作。
懒加载图片
尚未指定加载模式的图片会自动获得:
loading="lazy"
decoding="async"
这可以防止包含许多图片的长主题立即加载所有内容。
加载状态
模态框在请求进行时并不只是显示空白/空区域。
它有一个骨架 UI,包含:
- 头像占位符
- 用户名/名称占位符
- 帖子正文占位符
- 微光动画
微光效果尊重:
prefers-reduced-motion
因此,对于请求减少动效的用户,动画将被禁用。
保持滚动位置稳定
组件需要在几个地方手动操作滚动位置。
例如,当加载更早的帖子时,新插入的内容会增加滚动高度。
简单地前置帖子会使用户当前位置跳动。
因此,组件记录之前的滚动高度,并在帖子插入后对差异进行补偿。
这使得当前可见的内容保持在大约相同的位置。
跳转到特定帖子时也适用同样的逻辑。
组件执行渲染后的定位步骤,并在后续帧中再次验证位置,以考虑可能仍在稳定的内容。
主题在线状态
当相关主题数据可用时,预览还可以在模态框底部显示 Discourse 的主题在线状态信息。
这样,用户无需离开预览即可看到谁正在查看该主题。
与移动端菜单和焦点的交互
移动端引入了另一类问题。
一些 Discourse UI 元素使用共享的模态框/菜单服务,而这些服务不一定知道主题预览当前正在充当嵌套浏览上下文。
因此,组件在以下方面有额外的处理:
modal.close()- Float Kit 菜单
- 焦点恢复
- 编辑器
- 灯箱键盘控制
- 正文滚动锁定
例如,如果菜单内部试图调用全局模态框关闭方法,它不应该意外地关闭整个主题预览。
同样,当编辑器打开时,焦点需要保留在编辑器内部,而不是被拉回预览的焦点上下文中。
配置
该组件目前公开以下设置:
| 设置 | 默认值 | 描述 |
|---|---|---|
trigger_style |
row |
使整行可点击或使用显式按钮 |
plugin_outlet |
topic-list-after-title |
按钮触发器使用的出口 |
enable_prefetch |
true |
启用/禁用后台主题预取 |
max_concurrent_prefetches |
2 |
最大同时预取请求数 |
prefetch_debounce_ms |
400 |
开始预取前的延迟 |
prefetch_root_margin_px |
50 |
在行进入视口前多少像素开始预取 |
max_prefetches_per_minute |
15 |
每分钟最大推测性请求数 |
预取控制是有意可配置的,因为不同社区可能具有非常不同的流量模式和托管/网络特性。
主要设计目标之一:不要破坏正常的 Discourse
我试图让组件尽可能接近 Discourse 现有的架构。
它不实现自己的帖子渲染器、自己的编辑器、自己的主题模型或完全独立的帖子流。
相反,它围绕 Discourse 现有的组件和服务构建一个临时的浏览上下文。
这也是为什么实现的某些部分比最初看起来更复杂的原因。
更有趣的挑战是:
当主题实际上显示在另一个 UI 上下文中时,它能否表现得几乎像一个正常的 Discourse 主题?
这需要处理 Discourse 全局服务与本地预览之间的边界。





