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.
| Member | Signature | Description |
|---|---|---|
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 }) => void | Soft-deletes a reply (sets deletedAt). |
undoDeleteComment | (params: { threadId; commentId }) => void | Clears 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 }) => boolean | Who 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 }) => void | Adds the current user’s reaction. |
removeReaction | (params: { threadId; commentId; emoji }) => void | Removes it. |
getThreadsForExport | (editorId: string) => Promise<Thread[]> | Non-hook fetch of the threads DOCX export writes into the file. |
currentUserId | string | The 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
useThreadsis called twice per editor, withresolved: falseandresolved: true, and the results are merged. Return only threads whoseresolvedmatches exactly and that belong toeditorId(store themetadata.editorIdyou get increateThread). Returning everything from both calls shows every thread twice.- Keep returning soft-deleted threads with
deletedAtset. The UI hides them, but needs them to undo a deletion and to restore a thread when its anchors come back. - Report
isLoadinghonestly. 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
| Action | Allowed by default | Override |
|---|---|---|
| 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 reply | The reply’s author | none |
| Edit a comment | The comment’s author | none |
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"oreditable={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-rasterdata: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.
| Option | Type | Default | Description |
|---|---|---|---|
resolved | boolean | false | Which set to read (exact match). |
editorId | string | the current editor | Which editor’s threads to read. |
UI components
Exported from @stesura/comments-react:
| Export | Purpose |
|---|---|
commentsUiExtension | Registers everything below plus the toolbar groups (Insert, Review), the text-menu button and the context-menu items. |
createCommentsUiExtension | The same, with options. commentsUiExtension is createCommentsUiExtension(). |
ThreadsContainer | The 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. |
AnchoredThreads | The rail: thread cards next to the page, aligned with their anchors. |
FloatingThreads | The selected thread in a popover under its anchor. |
FloatingComposer | The 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
- Comments: setup, anchors, plugins and commands.
- Collaboration: backends and sync.