Skip to Content
PackagesCommentsComments UI

Comments (React)

The comments UI. @stesura/comments-react has the components and commentsUiExtension; the CommentAdapter type, CommentProvider and hooks come from @stesura/editor-react, with CommentProvider and useCommentCtx re-exported by @stesura/comments-react.

For setup, the document model and commands see Comments.

CommentAdapter

Your storage backend, passed to <CommentProvider value={adapter}>. Import the type from @stesura/editor-react. Only useThreads and createThread are required; each optional method enables the matching UI (no resolveThread, no Resolve button). None is called in view mode.

MemberSignatureDescription
useThreads(query: { resolved: boolean; editorId: string }) => { threads: Thread[]; isLoading: boolean }Required. React hook returning one editor’s threads.
createThread(params: { body: JSONContent; metadata: Record<string, unknown> }) => Promise<Thread>Required. Creates a thread with its first comment. metadata carries { editorId }.
addComment(params: { threadId; body }) => void | Promise<void>Adds a reply. A returned promise that rejects shows an error toast.
editComment(params: { threadId; commentId; body }) => Promise<void>Edits a comment.
deleteComment(params: { threadId; commentId }) => voidSoft-deletes a reply (sets deletedAt).
undoDeleteComment(params: { threadId; commentId }) => voidClears deletedAt.
deleteThread(threadId: string) => Promise<void>Soft-deletes a thread (sets deletedAt). Also called for orphaned threads.
undoDeleteThread(threadId: string) => Promise<void>Clears deletedAt. Also called for restored threads.
canDeleteThread(thread: { threadId: string; createdBy: string | undefined; createdAt: Date }) => booleanWho may delete a thread. Default: its author only. See Who can delete what.
resolveThread(threadId: string) => Promise<void>Marks a thread resolved.
unresolveThread(threadId: string) => Promise<void>Reopens it.
addReaction(params: { threadId; commentId; emoji }) => voidAdds the current user’s reaction.
removeReaction(params: { threadId; commentId; emoji }) => voidRemoves it.
getThreadsForExport(editorId: string) => Promise<Thread[]>Non-hook fetch of the threads DOCX export writes into the file.
currentUserIdstringThe current user: only their own comments can be edited, replies deleted, and (without canDeleteThread) threads deleted. Their reactions show as active. Without it nothing can be edited or deleted.

Contract

  • useThreads is called twice per editor, with resolved: false and resolved: true, and the results are merged. Return only threads whose resolved matches exactly and that belong to editorId (store the metadata.editorId you get in createThread). Returning everything from both calls shows every thread twice.
  • Keep returning soft-deleted threads with deletedAt set. The UI hides them, but needs them to undo a deletion and to restore a thread when its anchors come back.
  • Report isLoading honestly. Orphan cleanup waits until both calls have loaded: a partial list makes live threads look unknown, and their anchors would be removed.
  • Reject on failure. The UI waits for the promise-returning methods: a failed edit keeps the draft in the composer, and success toasts (with Undo) only show once the call resolves.
  • Normalize before modifying. An adapter that reads, modifies and writes back a stored thread must run normalizeSerializedThread (from @stesura/core/helpers) on what it reads. The reaction helpers assume one group per emoji and one entry per user.

Who can delete what

ActionAllowed by defaultOverride
Delete a thread (its first comment’s Delete, or Delete comment in the context menu)The thread’s author (currentUserId equals the first comment’s author)canDeleteThread
Delete a replyThe reply’s authornone
Edit a commentThe comment’s authornone

Word works the same way: anyone with edit rights may delete a thread, but only an author touches their own replies. To match it, allow every thread:

const adapter: CommentAdapter = { ...baseAdapter, canDeleteThread: () => true, };

or a moderator rule, e.g. the author or an admin, or anything older than a week:

canDeleteThread: ({ createdBy, createdAt }) => createdBy === me.id || me.isAdmin || Date.now() - createdAt.getTime() > WEEK_MS,

Some rules hold whatever the adapter says:

  • View mode (mode: "view" or editable={false}) offers no comment action at all, deletion included. Review mode doesn’t restrict comments.
  • Resolved threads can’t be deleted from the card: reopen them first.
  • No deleteThread, no thread deletion.

Authorization is the adapter’s job

canDeleteThread, authorship and view mode only decide what the UI offers. They are affordances, not security: re-check who is asking on the server, for every mutation, with the same rule. The collab backend’s thread route upserts whole thread blobs, so put that check in your route’s authorize (see Backend API).

Adapter data is untrusted

In a shared document, thread bodies, author names, avatars and mention IDs are written by other users:

  • Mention IDs reach resolveUsers({ userIds }) verbatim. Encode them before putting them in a URL, query or filter.
  • Avatar URLs are sanitized (javascript: and non-raster data: URLs fall back to initials), but any web URL is still fetched by every viewer. Filter them in the adapter if that matters.
  • Comment bodies are validated against the comment schema; a body that doesn’t parse is shown as unavailable, never rendered as markup.

Hooks

useCommentCtx()

Returns the adapter, or null without a provider. For calling adapter methods directly.

import { useCommentCtx } from "@stesura/editor-react"; const adapter = useCommentCtx();

useComments(options?)

import { useComments } from "@stesura/editor-react"; const { threads, isLoading } = useComments({ resolved: false }); const { threads: intro } = useComments({ resolved: true, editorId: "intro" });

Calls the adapter’s useThreads. Safe without a provider. threads: undefined means no adapter or not loaded yet, not zero threads.

OptionTypeDefaultDescription
resolvedbooleanfalseWhich set to read (exact match).
editorIdstringthe current editorWhich editor’s threads to read.

UI components

Exported from @stesura/comments-react:

ExportPurpose
commentsUiExtensionRegisters everything below plus the toolbar groups (Insert, Review), the text-menu button and the context-menu items.
createCommentsUiExtensionThe same, with options. commentsUiExtension is createCommentsUiExtension().
ThreadsContainerThe comments root: the rail or floating panel, the composer, screen-reader announcements, and the sync between adapter, stores and document (visible threads, orphan cleanup). Renders nothing without an adapter.
AnchoredThreadsThe rail: thread cards next to the page, aligned with their anchors.
FloatingThreadsThe selected thread in a popover under its anchor.
FloatingComposerThe new-comment composer, shown next to the pending range or node.

The display mode is the showComments user preference, set from the Review tab: anchored (rail), minimized (rail of bubbles), floating or none. The rail modes aren’t available in the compact layout.

Composer formatting

Comment bodies carry bold, italic, strikethrough, code, links and a textStyle mark (text colour, highlight, font size and family). The composer has colour and highlight buttons; Ctrl+B and Ctrl+I toggle bold and italic. To keep marks out of new comments, exclude them:

const comments = createCommentsUiExtension({ composer: { excludeMarks: ["textStyle"] } });

An excluded mark loses its buttons and shortcut, and pastes drop it. Comments that already carry it (from another client, or a DOCX import) still render. Create the extension once, at module level or in a useMemo: uiExtensions must stay stable. When you mount ThreadsContainer yourself, wrap it in CommentComposerOptionsProvider instead.

Render the components yourself only in a custom editor shell. ThreadsContainer must stay mounted: without it no thread is marked visible and nothing is highlighted.

Author names, avatars and mention suggestions come from resolveUsers and resolveMentionSuggestions on StesuraUserProvider, see Getting started.

Implementing your own adapter

An in-memory adapter:

import type { Thread } from "@stesura/core/types"; import type { CommentAdapter } from "@stesura/editor-react"; import { useMemo, useState } from "react"; function useMemoryCommentAdapter(currentUserId: string): CommentAdapter { const [threads, setThreads] = useState<Thread[]>([]); return useMemo(() => { const update = (id: string, change: Partial<Thread>) => setThreads((all) => all.map((t) => (t.id === id ? { ...t, ...change } : t))); return { currentUserId, useThreads: ({ resolved, editorId }) => { const matching = useMemo( () => threads.filter((t) => t.resolved === resolved && t.metadata.editorId === editorId), [threads, resolved, editorId] ); return { threads: matching, isLoading: false }; }, createThread: async ({ body, metadata }) => { const id = crypto.randomUUID(); const now = new Date(); const thread: Thread = { id, resolved: false, createdAt: now, metadata, comments: [ { id: crypto.randomUUID(), threadId: id, createdAt: now, body, author: { id: currentUserId } }, ], }; setThreads((all) => [...all, thread]); return thread; }, deleteThread: async (id) => update(id, { deletedAt: new Date() }), undoDeleteThread: async (id) => update(id, { deletedAt: undefined }), resolveThread: async (id) => update(id, { resolved: true }), unresolveThread: async (id) => update(id, { resolved: false }), }; }, [threads, currentUserId]); }

For collaboration, use createCollabCommentAdapter from @stesura/collab-adapter-react instead, see Collaboration.

Next steps

Last updated on