Comments API
Reference for @vitnode/core/tanstack/comments, covering useContentComments, its typed callbacks and controller, the ContentComments section, ContentCommentsRoot for custom layouts, and the comment item, actions and composer components.
@vitnode/core/tanstack/comments turns a Content Engine content type with thread into a comment section. useContentComments holds the thread's state and actions, and the components render them. For a guided setup, follow Comments, Comment actions and Replies.
Everything here is browser code. It calls the Content Engine's own routes for the thread, permissions, writes, deletes, hiding and reports, and those routes decide every permission.
useContentComments
import { useContentComments } from '@vitnode/core/tanstack/comments'
const comments = useContentComments({ queryKey, thread, ...actions })Options
| Name | Type | Required | Description |
|---|---|---|---|
queryKey | QueryKey | Yes | The thread's cache key prefix, unique per root record, e.g. [manifest.id, "comments", article.id]. The thread is cached under [...queryKey, "thread"] and the permissions under [...queryKey, "permissions", ...], so invalidating queryKey refreshes both |
thread | ({ after, first }) => Promise<Response> | Yes | GET /thread with root, first and after |
maxDepth | number | No | The type's thread.maxDepth. Without it the comments are flat and Reply never shows |
pageSize | number | No | Top-level comments per page, 1-50. Defaults to 20 |
permissions | (ids) => Promise<Response> | No | GET /permissions with ids joined by commas. Without it every comment has no actions |
create | ({ body, parentId }) => Promise<Response> | No | POST / with the root record, body and parent: parentId. Without it there is no composer |
edit | { load(id), save(id, { body, expectedVersion }) } | No | GET /{id}/edit and PATCH /{id}. expectedVersion is a number with editorial and undefined without it |
delete | (id) => Promise<Response> | No | POST /{id}/delete |
visibility | { hide(id, { reason }), unhide(id) } | No | POST /{id}/hide with an optional reason (null when empty, at most 500 characters) and POST /{id}/unhide |
report | (id) => ContentTarget | No | contentTarget(manifest.id, commentContentType, id) from @vitnode/core/content |
record | ZodType<TRecord> | No | Validates every loaded comment's public record and gives it a type. The thread route types records loosely, so pass it when your own rendering reads their fields |
field | string | No | The rich-text field with the comment's text. Defaults to "body" |
onChanged | (queryClient) => void | Promise<void> | No | Refreshes your other queries after a successful write, such as a comment count elsewhere on the page |
branches | (ids) => Promise<Response> | No | GET /thread/branches with root, plus ids joined by commas only when there are any. Writes then re-read only the branch they touched instead of every loaded page. live needs it |
live | { contentTypeId, rootId } | No | The comment content type's id and the root record's id. Keeps the thread current over the WebSocket while it is mounted; ignored without branches. See Live comments |
Callback contract
- Write each
fetchercall inline and return its response unchanged. The plugin, module, path, arguments and answer then stay typed from your routes; the hook infers the comment id type (number, orstringfor a uuid type) fromthread. - Don't check the status yourself. The hook reads
okandstatus: a2xxis a success, anything else is reported by the section, and a thrown error counts as a failed request. - An action you leave out never shows. One you wire up still shows only where the server's
can*answer allows it.
Controller
useContentComments returns the controller every component reads. The fields you are most likely to use in a custom layout:
| Name | Description |
|---|---|
tree | The loaded comments as a tree: ContentCommentTreeNode[], replies in children |
items | The same entries, flat and depth first |
status | "pending" before the first page, "error" when it failed, then "success" - also after a failed background refresh, which refreshError reports |
hasMore, loadMore(), isLoadingMore | The next page of top-level comments. loadMore resolves to false when it failed |
isRefreshing, retry() | A refetch over data on screen (not Load more), and retrying a failed first load |
refreshError, retryRefresh() | A background read of the comments on screen failed, and they stayed as they were. retryRefresh() asks again for what it missed and resolves when done |
hasNewComments, showNewComments() | With live: comments appeared after the last loaded one, at the end of the thread. showNewComments() reads only those and adds them below; it resolves to false when that failed |
liveStatus | "off" (no live, or no open WebSocket), "joining", "live", or "denied" when the server refused or revoked the subscription |
trackDraft(id, open) | Call with true while an edit or reply form is open on id and false when it closes. A live update that removes that comment keeps it on screen with removed: true and its record until then. useContentCommentItem calls it for you |
replyCount, truncated | Every public comment at any depth, and whether a page held more replies than one answer carries |
permissionsOf(id) | { canDelete, canEdit, canHide, canReport }, all false until known or after a failed batch |
canComment, canReply(node) | Whether the composer and Reply show: signed in, create given, under maxDepth, not hidden |
features | Which actions you wired up |
busy | The action running right now, { action, id } or null. A second action waits for it |
remove(id), hide(id, reason), unhide(id) | Run an action and refresh after success - only that comment's branch with branches. Resolve to { ok: true } or { ok: false, status }; status is 409 when another action was still running and 0 for a network failure |
refresh() | Re-reads every loaded page, its permissions and onChanged's queries |
userId | The signed-in visitor's id, or null |
Thread entries
Each entry of tree and items is a ContentCommentNode:
| Field | Description |
|---|---|
id, parentId | The comment and the comment it answers, null at the top level |
depth | 1 for a comment on the root record, 2 for a reply to it |
record | The public record, or null for a placeholder |
deleted | For a placeholder: { byAuthor, createdAt }, with no text and no author |
hidden, hiddenReason | Set only for a hidden comment this reader may see: its author or a moderator |
buildContentCommentTree(items) and countContentCommentReplies(node) are exported for layouts that work on the flat list.
contentCommentsQueryOptions
The thread's infinite query, for warming the first page in a route's loader. It takes the same queryKey, thread, pageSize and record as the hook and reads the same cache entry:
await context.queryClient
.infiniteQuery(contentCommentsQueryOptions({ queryKey, thread }))
.catch(() => null)Its queryFn throws when the thread route answers anything but 2xx.
ContentComments
The complete section: the count, the thread, Load more comments, the composer or a sign-in link, the actions menu, the delete confirmation and the report dialog. It scrolls to and rings the comment a #comment-... link points to, loading more pages until it appears.
<ContentComments comments={comments} />| Prop | Description |
|---|---|
comments | The controller from useContentComments |
className | Classes for the <section> |
title | Replaces the "N comments" heading |
anchorKey(record) | What follows comment- in each comment's element id. Defaults to the id; return the slug to match slug permalinks |
permalink(anchorId) | The link on each comment's date. Defaults to #comment-... on the same page |
render.author(record) | Replaces the author's name |
render.body(record) | Replaces the text |
render.meta(record) | Adds details after the date, such as a badge |
render.actions(record) | Adds buttons next to Reply |
The section's element id is comments, so #comments links scroll to it. Its messages live in the core.comments namespace; add it to the page's route messages to load them with the page.
Your own layout
ContentCommentsRoot provides the controller to the building blocks below and renders the dialogs they open. It adds the comment messages to the page's, so your own components inside keep their translations.
import {
ContentCommentActions,
ContentCommentComposer,
ContentCommentsComposer,
ContentCommentsLoadMore,
ContentCommentsRoot,
useContentCommentItem,
} from '@vitnode/core/tanstack/comments'
const MyComment = ({ node }) => {
const item = useContentCommentItem(node)
if (item.editing) {
return (
<ContentCommentComposer
editable={item.editing}
id={node.id}
label="Edit your comment"
mode="edit"
onCancel={item.stopEditing}
onDone={item.stopEditing}
/>
)
}
return (
<article>
{/* your own rendering of node.record */}
<ContentCommentActions item={item} node={node} />
</article>
)
}
;<ContentCommentsRoot comments={comments}>
{comments.tree.map((node) => (
<MyComment key={node.id} node={node} />
))}
<ContentCommentsLoadMore />
<ContentCommentsComposer />
</ContentCommentsRoot>| Export | What it is |
|---|---|
ContentCommentsRoot | Provides comments to the components below; takes a fallback shown while the messages load |
useContentCommentItem(node) | One comment's state: permissions (the server's answers limited to wired actions), canReply, editing, startEditing(), stopEditing(), isReplying, setIsReplying, isBusy, and isRemoved when a live update removed the comment under an open form |
ContentCommentActions | The More actions menu with the hide-reason prompt. children adds your own menu items |
ContentCommentComposer | The comment form, lazy-loaded: mode="create" with parentId, or mode="edit" with id and editable. unavailable (a translated reason) keeps the text but hides the submit button |
ContentCommentsLiveStatus | Show new comments and the refresh-failed notice, with a polite screen reader announcement. Renders nothing while there is nothing to say |
ContentCommentItem | One comment as the default section draws it |
ContentCommentPlaceholder | The anonymous stand-in for a removed comment |
ContentCommentsComposer, ContentCommentsLoadMore | The new-comment card and the load-more button |
contentCommentAuthor(record), contentCommentAuthorName(person) | Read the author person and the name to show |
useContentCommentErrorToast() | Shows the section's message for a failed action's status |
The Blog plugin's plugins/blog/src/pages/post-comments.tsx is a complete custom layout: rail connectors, collapsible branches, share buttons and slug permalinks, with every action from these components.
Outcomes and errors
- Writes. The composer posts through
useContentPublicWrite: a refused write keeps the text and shows the reason, and witheditoriala stale edit shows the version-conflict alert. A success reads the answer'sstatusandapproval: Comment posted only when it is public, saved as a draft or waiting for a moderator otherwise. - Actions. Delete, hide and unhide show Comment deleted, Comment hidden or Comment visible again after the server accepts them. When it refuses, the section shows one message for the cause: signed out, not allowed, gone, busy or unknown.
- Loading. A failed first load shows Try again; a failed Load more keeps the loaded comments and says it failed. A failed background refresh keeps them too;
ContentCommentsLiveStatusoffers Try again.
GET /thread/branches
Every content type with thread also gets GET /{publicApi.path}/thread/branches. It re-reads a few top-level comments already on screen, each with every reply below it, using the same root check, visibility, placeholders, depth and truncation as /thread. useContentComments calls it through branches.
GET /api/newsroom/content/article-comments/thread/branches?root=1&ids=4,9| Query | Required | Description |
|---|---|---|
root | Yes | The root record's id |
ids | No | Up to 10 top-level comment ids, joined by commas. Duplicates are ignored. Without it, only the counts are read |
| Response field | Description |
|---|---|
branches | One entry per requested id, in the order asked: { id, status: "ok", items } or { id, status: "gone" } |
items | The top-level comment first, then every reply below it, as thread entries |
pageInfo.totalCount, replyCount, truncated | The thread's current counts, as on /thread |
gone means this reader no longer sees the branch: deleted, hidden or unpublished with no visible reply left, not a top-level comment, or under another root. An unreadable root answers 404, as on /thread; more than 10 or malformed ids answer 400.
Guests and readers who can't see hidden comments get the same answer, served from the public read cache with Cache-Control: no-cache. Readers who may see hidden comments, and readers of a hidden root, get a private, no-store answer.
Related
- Comments: the guided setup.
- Permissions API:
useContentPermissions, which the hook uses forpermissionsOf. - thread reference: the
threadoption on the content type.