tldraw 评论通知实战:基于 Commenting SDK 的已读回调构建评论通知收件箱
本文以 tldraw 官方示例 comment-notifications 为主线,讲清如何借助 @tldraw/commenting 提供的三个宿主回调(isCommentUnread、onCommentsRead、revealThread)派生出一套完整的评论通知流:追踪未读评论、记录已读回执、从画布外任意位置跳转到目标线程,并让被打开的线程自动标记为已读。读完本文,你将掌握“通知 feed 派生而非存储”的设计模式,并能把该方案迁移到自己的 React 协作应用中。
设计原则:SDK 不替你决定“未读”和“该通知谁”
tldraw 的评论层(@tldraw/commenting)刻意把两件事留给宿主应用:一条评论是否未读,以及一条评论值不值得通知某人。SDK 只负责画布上的评论呈现与交互,已读状态与通知策略都是“宿主数据”。
这个边界在 SDK 源码中有明确的体现。所有评论表面(画布图层、侧边栏等)共享一个“实时上下文”接口 CommentingContext,其中与已读/通知直接相关的字段就是下面这几个(见 context.ts):
export interface CommentingContext {
currentUserId: string | null
resolveAuthor(id: string): CommentAuthor | undefined
/** 任何评论(新线程首条或回复)发布后被调用 */
onPostComment?(comment: TLComment): void
/** 该评论对当前用户是否未读(未读返回 true) */
isCommentUnread?(commentId: TLCommentId): boolean
/**
* 以打开的线程弹层中展示给用户的全部未读评论调用,
* 按每次上报批量传入,宿主可据此记录已读回执而无需逐条写入。
* 需要 isCommentUnread 来判断什么是未读的。
*/
onCommentsRead?(commentIds: TLCommentId[]): void
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
// ...
}
isCommentUnread 是纯查询:宿主回答“这条我读没读”;onCommentsRead 是事件上报:线程视图把“我刚展示给你的未读评论”批量告诉宿主,由宿主去落库。二者组合,就形成了本文示例的整条已读链路。
已读回执的数据形态:用宿主自己的存储存
示例源码 CommentNotificationsExample.tsx 用一个本地 atom 模拟真实应用中的“已读回执表”:
const ME = 'me'
const readComments = atom<ReadreadonlySet<TLCommentId>>('read comments', new Set())
const isCommentUnread = (commentId: TLCommentId) => !readComments.get().has(commentId)
const onCommentsRead = (commentIds: TLCommentId[]) =>
readComments.update((read) => {
const unread = commentIds.filter((id) => !read.has(id))
if (unread.length === 0) return read
const next = new Set(read)
for (const id of unread) next.add(id)
return next
})
示例注释里点出了这条设计在真实应用中的含义:评论记录只描述“谁在何时写了什么”,而“谁看过”是 (评论, 读者) 维度的一行数据,应该放在宿主自己的数据库里,这样未读计数才能在刷新后存活、跨设备跟随用户。示例用 atom 只是为了让整个流程跑在内存中。
有两处工程细节值得注意:
- 为什么用 atom 而不是 React state:
isCommentUnread与onCommentsRead这两个回调只传给CanvasComments一次。保持它们引用稳定,可以避免每次已读状态变化都重建整个评论图层;面板侧则用useValue(readComments)对同一份集合做响应式读取。 - 自己的评论立即视为已读:示例中
onPostComment = (comment) => onCommentsRead([comment.id])——“自己的评论对自己永远不算新闻”,不必等线程视图上报,发布瞬间就标记为已读。这防止了刚发出去的评论在自己的通知流里显示未读点。
线程视图如何触发 onCommentsRead
“打开即已读”的机制在 SDK 侧由线程视图实现。见 thread-view.tsx:
// isCommentUnread to false, so re-runs find nothing to report.
if (!isCommentUnread || !onCommentsRead) return
const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
onCommentsRead(unreadIds)
即:每当打开的线程中评论集合变化(包括线程保持打开期间新到达的回复),视图会重新收集所有仍未读的评论 id,批量调用一次 onCommentsRead。这正是“批量按上报”语义的来源——宿主不需要为每条评论写一次回执。注意其幂等性依赖宿主的 isCommentUnread:回执写入后再次运行会找不到未读项,不会重复上报。
派生通知流:从评论记录中算出“谁该收到通知”
示例没有为“通知”建立任何独立的存储,而是把通知流当作派生数据:用 useComments 拿到全部评论记录,逐条打上“它为什么在这”的标签,再对照已读集合即可。相关 API 均定义在 hooks.ts:
useComments(editor):全部未删除的评论(按创建时间升序),响应式;useCommentThreads(editor):全部未删除的线程,响应式。
示例中“你的线程”定义为:你发起的线程 + 你评论过的线程:
const yourThreadIds = useMemo(() => {
const ids = new Set<TLCommentThreadId>(
threads.filter((thread) => thread.createdBy === ME).map((thread) => thread.id)
)
for (const comment of comments) {
if (comment.authorId === ME) ids.add(comment.threadId)
}
return ids
}, [threads, comments])
识别 @提及:遍历富文本里的 mention 节点
一条 @ 提及在评论富文本 body 中是一个 { type: 'mention', attrs: { id } } 节点,attrs.id 是成员 id。示例用一个小递归遍历出所有被提及者:
function mentionedIds(body: TLRichText): string[] {
const ids: string[] = []
const visit = (node: any) => {
if (!node || typeof node !== 'object') return
if (node.type === 'mention' && typeof node.attrs?.id === 'string') ids.push(node.attrs.id)
if (Array.isArray(node.content)) node.content.forEach(visit)
}
visit(body)
return ids
}
组合出通知列表
通知判定规则示例采用了两条“无需服务端即可成立”的理由(产品决策应由宿主做,SDK 不做这个决定):
type NotificationReason = 'mention' | 'reply'
const notifications = useMemo(() => {
const result: Notification[] = []
for (const comment of comments) {
// 通知永远关于别人的评论,不关于你自己的
if (comment.authorId === ME) continue
const reason: NotificationReason | null = mentionedIds(comment.body).includes(ME)
? 'mention'
: yourThreadIds.has(comment.threadId)
? 'reply'
: null
if (!reason) continue
result.push({ comment, reason, unread: !read.has(comment.id) })
}
return result.sort((a, b) => b.comment.createdAt - a.createdAt)
}, [comments, yourThreadIds, read])
规则要点:
- 提及优先:提到你 > 在你参与的线程里回复;
- 自己的评论被跳过:通知永远不是“你自己说了什么”;
- 已读不剔除:已读评论仍留在列表中,清掉的是行上的未读点和头部徽标数字。若过滤成“仅未读”,feed 一被阅读就失去历史。
示例还演示了 bodyMentioning(before, memberId, after) 这种“像编辑器一样”构造带 mention 的富文本 body 的写法(空段落会被丢弃,因为零长度文本节点不是合法富文本),供“Ada 回复你 / Ada @你”两个演示按钮使用。
revealThread:从画布外任意位置跳转并打开线程
通知行点击后的跳转入口是 revealThread(editor, threadOrCommentId),它接受线程 id,或其中任意一条评论的 id。实现非常轻——见 state.ts:
export function revealThread(editor: Editor, threadOrCommentId: string): void {
revealThreadRequest.set(editor, threadOrCommentId)
}
它只是把一个“待处理揭示请求”写进按 editor 作用域的 EditorAtom,真正的执行由 CanvasComments 图层接管(comments-overlay.tsx)。这一间接层带来两个重要性质:
- 对时序宽容:可以在评论记录尚未同步到位时提前调用。图层会等待记录到达、切换页面、取消隐藏 pin,并在聚簇(clustering)开启时把镜头放大到足以把该线程从簇徽标中“分裂”出来再打开它(相关聚簇感知逻辑见 cluster-model.ts)。因此通知流、深链、邮件里的链接都能安全地调用它;
- 依赖图层挂载:
CanvasComments未挂载时请求无人消费。SDK 还导出getRevealThreadPending/useRevealThreadPending(state.ts)供宿主侦测“一直没落地的揭示请求”——通常是深链指向了已删除的评论;由于请求在记录同步期间也会滞留,建议给一段宽限期后再复查。
打开线程同时完成已读
revealThread 打开线程弹窗后,线程视图按前述机制把展示给你的未读评论批量上报给 onCommentsRead——这就是 README 中“点击一行跳到线程并看到它把自己标记为已读”的完整闭环:跳转与已读是同一动作的两个后果,宿主无需额外写任何标记逻辑。
组装到应用:挂载 CanvasComments 与初始化数据
整个示例应用的关键装配代码如下(取自 CommentNotificationsExample.tsx):
const components: TLComponents = {
InFrontOfTheCanvas: () => (
<CanvasComments
currentUserId={ME}
resolveAuthor={resolveAuthor}
getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
isCommentUnread={isCommentUnread}
onCommentsRead={onCommentsRead}
onPostComment={onPostComment}
/>
),
}
export default function CommentNotificationsExample() {
const store = useMemo(
() => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
[]
)
return (
<div className="tldraw__editor">
<Tldraw
// 评论是许可功能:本地开发默认全部开启,
// 部署的应用需要包含 commenting 的 license key
licenseKey={getLicenseKey()}
store={store}
onMount={handleMount}
tools={commentTools}
overrides={[commentToolOverrides]}
components={components}
>
<NotificationsPanel />
</Tldraw>
</div>
)
}
装配要点:
CanvasComments通过TLComponents的InFrontOfTheCanvas插槽挂到编辑器之上;传入的CommentingContext字段集合可以在多个评论表面间共享(构建一次、逐个展开);- store 必须包含评论 schema:
createTLSchema({ records: commentSchemaRecords }); - 写入评论走统一的变异入口
putCommentRecords(editor, records)(comment-mutations.ts),创建记录则用createComment/createCommentThread工厂函数。示例中“Ada 发言”就是一段普通的记录写入,代替了真实应用的同步连接——用同步 store 开两个标签页,会看到同样的评论自己到达; - 评论功能受 license 保护:本地开发环境默认启用,部署到生产时需要包含 commenting 的 license key(示例源码注释明确提示将
getLicenseKey()换成你自己的 key)。
演示数据:两种通知理由各一例
示例的 handleMount 在挂载时种入了两条演示数据(CommentNotificationsExample.tsx):
- reply 型:你发起的线程(
createdBy: ME),Ada 45 分钟前回复了一条——命中“你参与的线程里有新回复”; - mention 型:Ada 锚定在图形上的线程(
anchor: { type: 'shape', ... }),首条评论用bodyMentioning('', ME, ' can you swap in the final crop?')@ 了你——命中“被提及”。
页面还提供 “Ada replies” / “Ada mentions you” 两个按钮实时产生新通知,用于验证未读点、徽标计数、revealThread 跳转与自动已读的完整链路。
小结:把这套模式带回你的应用
| 关注点 | 谁负责 | 本文示例/SDK 中的对应物 |
|---|---|---|
| 评论记录(谁、何时、内容、锚点) | SDK 记录 + 同步 store | commentSchemaRecords、putCommentRecords |
| 已读状态查询 | 宿主 | isCommentUnread(commentId) 回调 |
| 已读回执写入 | 宿主 | onCommentsRead(ids) 批量回调 |
| 通知规则(提及/回复/板主活动等) | 宿主(产品决策) | mention / reply 两种理由 |
| 从画布外跳转到线程 | SDK | revealThread(editor, id) |
| 打开线程时自动已读 | SDK 线程视图 + 宿主回执 | thread-view.tsx 的批量上报 |
这套模式的收益在于职责清晰:SDK 提供记录、呈现、跳转与上报时机,宿主拥有通知策略与已读存储——把示例中的本地 atom 换成数据库或后端接口,其余结构(派生 feed、理由标签、未读点、revealThread 跳转)可以原样保留。
延伸阅读
- 示例完整源码与逐段注释:CommentNotificationsExample.tsx(文件尾部的
[1]–[6]注释对已读存储、提及节点、通知规则、派生 feed、模拟写入、revealThread逐一展开) - 宿主上下文接口定义:context.ts
- 揭示请求状态与 API:state.ts
- 评论记录读写入口:comment-mutations.ts
- 其他协作类示例(同目录):collaboration 目录
- examples 应用的介绍与运行方式:apps/examples/README.md
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00