Comment notifications
Track unread comments and build a notifications feed.
import {
Avatar,
CanvasComments,
CommentAuthor,
commentToolOverrides,
commentTools,
filterMentionMembers,
formatRelativeTime,
MentionMember,
putCommentRecords,
revealThread,
richTextToPlaintext,
useComments,
useCommentThreads,
} from '@tldraw/commenting'
import { getLicenseKey } from '@tldraw/dotcom-shared'
import { useMemo } from 'react'
import {
atom,
commentSchemaRecords,
createComment,
createCommentThread,
createShapeId,
createTLSchema,
createTLStore,
Editor,
TLComment,
TLCommentId,
TLCommentThread,
TLCommentThreadId,
TLComponents,
Tldraw,
TldrawUiButton,
TldrawUiButtonLabel,
TLRichText,
toRichText,
useEditor,
useValue,
} from 'tldraw'
import '@tldraw/commenting/commenting.css'
import 'tldraw/tldraw.css'
import './comment-notifications.css'
const ME = 'me'
const ADA_AVATAR =
'data:image/svg+xml,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><rect width="28" height="28" fill="#0E9F6E"/><circle cx="14" cy="11" r="5" fill="#fff"/><ellipse cx="14" cy="24" rx="9" ry="7" fill="#fff"/></svg>`
)
const MEMBERS: MentionMember[] = [
{ id: ME, name: 'You', color: '#EC5E41', you: true },
{ id: 'ada', name: 'Ada Lovelace', color: '#0E9F6E', image: ADA_AVATAR },
{ id: 'grace', name: 'Grace Hopper', color: '#4465E9' },
]
const AUTHORS: Record<string, CommentAuthor> = Object.fromEntries(MEMBERS.map((m) => [m.id, m]))
const resolveAuthor = (id: string): CommentAuthor => AUTHORS[id] ?? { name: id }
const resolveName = (id: string) => AUTHORS[id]?.name
// [1]
const readComments = atom<ReadonlySet<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
})
// Your own comment is never news to you, so it's marked read the moment it's posted rather than
// waiting for the thread view to report it.
const onPostComment = (comment: TLComment) => onCommentsRead([comment.id])
// [2]
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
}
/**
* A comment body of the form `<before>@Name<after>`, as the composer would produce it. Empty
* `before`/`after` segments are dropped — a zero-length text node is invalid rich text.
*/
function bodyMentioning(before: string, memberId: string, after: string): TLRichText {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
...(before ? [{ type: 'text', text: before }] : []),
{ type: 'mention', attrs: { id: memberId, label: resolveName(memberId) ?? memberId } },
...(after ? [{ type: 'text', text: after }] : []),
],
},
],
}
}
// [3]
type NotificationReason = 'mention' | 'reply'
interface Notification {
comment: TLComment
reason: NotificationReason
unread: boolean
}
const REASON_LABEL: Record<NotificationReason, string> = {
mention: 'mentioned you',
reply: 'replied in your thread',
}
const MINUTE = 60 * 1000
function handleMount(editor: Editor) {
const boxId = createShapeId()
editor.run(
() => {
editor.createShapes([
{
id: boxId,
type: 'geo',
x: 180,
y: 140,
props: { geo: 'rectangle', w: 260, h: 160, richText: toRichText('Hero image') },
},
])
},
{ history: 'ignore' }
)
const pageId = editor.getCurrentPageId()
const now = Date.now()
// A thread you started, with a reply from Ada — a `reply` notification.
const yourThread = createCommentThread({
pageId,
anchor: { type: 'point', x: 540, y: 200 },
createdBy: ME,
now: now - 90 * MINUTE,
})
// A thread Ada started on the shape, mentioning you — a `mention` notification.
const adaThread = createCommentThread({
pageId,
anchor: { type: 'shape', shapeId: boxId, x: 0.5, y: 0.5, isPrecise: true },
createdBy: 'ada',
now: now - 20 * MINUTE,
})
putCommentRecords(editor, [
yourThread,
adaThread,
createComment({
threadId: yourThread.id,
pageId,
authorId: ME,
body: toRichText('Should this sit above the fold?'),
now: now - 90 * MINUTE,
}),
createComment({
threadId: yourThread.id,
pageId,
authorId: 'ada',
body: toRichText('Yes — it reads as an afterthought where it is now.'),
now: now - 45 * MINUTE,
}),
createComment({
threadId: adaThread.id,
pageId,
authorId: 'ada',
body: bodyMentioning('', ME, ' can you swap in the final crop?'),
now: now - 20 * MINUTE,
}),
])
// Padded well to the left of the content, so the fitted view leaves the strip under the inbox
// panel empty and both seeded pins land in the clear.
editor.zoomToBounds({ x: -160, y: 80, w: 900, h: 400 }, { immediate: true })
}
function NotificationsPanel() {
const editor = useEditor()
const comments = useComments(editor)
const threads = useCommentThreads(editor)
const read = useValue(readComments)
// The threads you're a part of: ones you started, plus any you've commented in.
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])
// [4]
const notifications = useMemo(() => {
const result: Notification[] = []
for (const comment of comments) {
// A notification is always about someone else's comment, never your own.
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.comment.createdAt)
}, [comments, yourThreadIds, read])
const unreadCount = notifications.filter((n) => n.unread).length
// The newest thread of yours that Ada could reply into.
const replyTarget = useMemo(
() =>
threads
.filter((thread) => yourThreadIds.has(thread.id))
.sort((a, b) => b.createdAt - a.createdAt)[0],
[threads, yourThreadIds]
)
const mentionTarget = useMemo(
() => [...threads].sort((a, b) => b.createdAt - a.createdAt)[0],
[threads]
)
// [5]
const postAsAda = (body: TLRichText, thread: TLCommentThread | undefined) => {
if (!thread) return
putCommentRecords(editor, [
createComment({ threadId: thread.id, pageId: thread.pageId, authorId: 'ada', body }),
])
}
return (
<div className="tlui-menu comment-notifications">
<div className="comment-notifications__header">
<span className="comment-notifications__title">Inbox</span>
{unreadCount > 0 && <span className="comment-notifications__badge">{unreadCount}</span>}
<TldrawUiButton
type="normal"
disabled={unreadCount === 0}
onClick={() => readComments.set(new Set(comments.map((c) => c.id)))}
>
<TldrawUiButtonLabel>Mark all read</TldrawUiButtonLabel>
</TldrawUiButton>
</div>
{notifications.length === 0 ? (
<p className="comment-notifications__empty">
Nothing here yet. Comments that mention you, or that reply in one of your threads, land in
this list.
</p>
) : (
<ul className="comment-notifications__list">
{notifications.map(({ comment, reason, unread }) => (
<li key={comment.id}>
<button
className="comment-notifications__row"
data-unread={unread}
// [6]
onClick={() => revealThread(editor, comment.id)}
>
<Avatar author={resolveAuthor(comment.authorId)} />
<span className="comment-notifications__body">
<span className="comment-notifications__meta">
<b>{resolveAuthor(comment.authorId).name}</b> {REASON_LABEL[reason]} ·{' '}
{formatRelativeTime(new Date(comment.createdAt).toISOString())}
</span>
<span className="comment-notifications__preview">
{richTextToPlaintext(comment.body, resolveName)}
</span>
</span>
{unread && <span className="comment-notifications__dot" />}
</button>
</li>
))}
</ul>
)}
<div className="comment-notifications__actions">
<TldrawUiButton
type="normal"
disabled={!replyTarget}
onClick={() =>
postAsAda(
toRichText('One more thought on this — the spacing feels tight.'),
replyTarget
)
}
>
<TldrawUiButtonLabel>Ada replies</TldrawUiButtonLabel>
</TldrawUiButton>
<TldrawUiButton
type="normal"
disabled={!mentionTarget}
onClick={() => postAsAda(bodyMentioning('', ME, ' thoughts?'), mentionTarget)}
>
<TldrawUiButtonLabel>Ada mentions you</TldrawUiButtonLabel>
</TldrawUiButton>
</div>
</div>
)
}
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 is a licensed feature. Every feature is enabled in local development, but a
// deployed app needs a license key that includes commenting — swap in your own key here.
licenseKey={getLicenseKey()}
store={store}
onMount={handleMount}
tools={commentTools}
overrides={[commentToolOverrides]}
components={components}
>
<NotificationsPanel />
</Tldraw>
</div>
)
}
/*
[1]
Read receipts are the host's data, not the SDK's. A comment record says who wrote it and when; who
has *seen* it is one row per (comment, reader), which a real app keeps in its own database so the
count survives a reload and follows you between devices.
Here that store is a local atom, so the whole flow runs in memory. An atom rather than React state
because the two callbacks below are handed to `CanvasComments` once: keeping them stable avoids
re-creating the comments layer every time something is read, and `useValue` gives the panel its
reactive read of the same set.
[2]
An `@`-mention is a `{ type: 'mention', attrs: { id } }` node inside the comment's rich-text body,
where `attrs.id` is the member id the composer's picker chose. Walking the body for those ids is
how "this comment mentions me" is decided — the notification reason with the strongest claim on
someone's attention.
[3]
Why a comment is worth telling someone about is a product decision, so the SDK doesn't make it.
This example uses the two reasons that need no server: the comment mentions you, or it lands in a
thread you're part of. A hosted app usually adds more — activity on a board you own, say — and
resolves them server-side so the feed spans every board rather than the one that's open.
[4]
The feed is derived, not stored: `useComments` and `useCommentThreads` give the comment records
reactively, and each one is tagged with a reason and checked against the read set. Nothing needs to
be written when a comment arrives.
Read comments stay in the list — the unread dot and the header badge are what clear. Filtering the
list down to unread only would make the feed lose its history the moment you read it.
[5]
Comments are ordinary records, so "someone else commented" is just a write. This stands in for the
sync connection a real app would have; opening this example in two tabs against a synced store
would show the same thing arriving on its own.
[6]
`revealThread` is the jump-to-thread entry point for anything outside the canvas — this feed, a
deep link, an email. It's forgiving about timing: `CanvasComments` waits for the records to sync
in, switches pages, unhides pins, and zooms far enough to split the thread out of any cluster
before opening it. It takes a thread id or, as here, the id of any comment in it.
Opening the thread is also what marks it read: the thread view reports every unread comment it
displays through `onCommentsRead`, batched per report, including replies that arrive while it stays
open.
*/
The commenting layer doesn't decide what's unread or what's worth notifying someone about — it takes both from the host. isCommentUnread reports read status, onCommentsRead records receipts for the unread comments a thread view displays, and revealThread jumps to a thread from anywhere outside the canvas.
That's enough to build a feed: derive it from the comment records with useComments, tag each one with why it's there, and check it against your read state. This example uses two reasons — the comment mentions you, or it lands in a thread you're part of.
Use the buttons to post as another user, then click a row to jump to the thread and watch it mark itself read.
Is this page helpful?
Prev
Drawn comment reactionsNext
Persist to storage