Agora
Client

Comments & versions

The two REST-backed hooks — their full shape, which operations update optimistically and which refetch, error handling, and why they keep working while the socket is down.

useComments and useVersions are the client's half of the data that lives beside the document rather than inside it. Both go over REST, both load on mount, and both expose a refresh() you control.

useComments

const {
  comments,   // CollabComment[] — oldest first
  loading,
  error,
  create,
  resolve,
  remove,
  refresh,
} = useComments({ docName, space: 'text' })

Omit space to get every space; pass one to scope the list to a single surface. Changing space refetches.

await create({
  space: 'text',
  anchor: { kind: 'text-range', start: 120, end: 160, selectedText: 'the sentence' },
  body: 'rephrase this',
  authorName: currentUser.name,
})

There is no `userId` in that call. The route attributes the comment to whoever the request
authenticated as and ignores a body-supplied author, so asking the component for one would only be
asking it to supply a value that is thrown away.

await resolve(comment.id)        // resolve
await resolve(comment.id, false) // reopen
await remove(comment.id)

The three write operations differ in how the list updates, and the difference is deliberate:

OperationLocal listWhy
createrefetchedthe server assigns the id and timestamps — the row you get back is the truth
resolveupdated in placeonly resolvedAt changes, and you already know its new value
removeupdated in placethe row is gone; there is nothing to learn from a refetch

So create costs a round trip and resolve/remove feel instant. If you need create to feel instant too, render an optimistic placeholder and let the refetch replace it.

resolve and remove are author-or-elevated on the server: you may always mutate your own comment, and mutating someone else's needs canWrite on the document — canComment alone answers 403. Since both update the local list optimistically, a rejection leaves the sidebar briefly showing a change that did not happen; refresh() in the catch puts it back.

A comment anchor is yours to build from your editor's selection:

function useCommentOnSelection(editor: Editor, docName: string) {
  const { create } = useComments({ docName, space: 'text' })

  return (body: string) => {
    const { from, to } = editor.state.selection
    return create({
      space: 'text',
      anchor: {
        kind: 'text-range',
        start: from,
        end: to,
        selectedText: editor.state.doc.textBetween(from, to),
      },
      body,
    })
  }
}

Store selectedText: offsets in a document other people are editing drift, and the quote is what lets the sidebar still show what a comment was about. See Comments.

useVersions

const { versions, loading, error, create, restore, refresh } = useVersions({ docName })

const version = await create('before review')  // label is optional
await restore(versions[0].id)

Both writes refetch, because both change the list. create adds a row with a server-assigned seq; restore adds one too — the state it replaced, saved as restored from #<seq> before the old content is applied.

Render seq rather than id: it is the friendly 1, 2, 3… the library maintains for exactly this.

{versions.map((version) => (
  <li key={version.id}>
    <strong>Version {version.seq}</strong>
    {version.label && <em> — {version.label}</em>}
    <time>{new Date(version.createdAt).toLocaleString()}</time>
    <button onClick={() => restore(version.id)}>Restore</button>
  </li>
))}

Restoring is immediate and visible to everyone

restore applies the old content to the live document, so every connected client sees it happen — including someone mid-sentence. Put a confirmation in front of the button.

You do not need to snapshot first: restoreVersion writes the state it is about to replace into the history itself, labelled restored from #<seq> and attributed to the caller, so the restore is already undoable. Call create('before restore') beforehand only when you want your own label on that checkpoint.

They work when sync does not

Both hooks are REST-backed, and that is all they are: neither one opens a document session or holds a Y.Doc. They take the document name, they call the routes, and they never touch the WebSocket. A reconnecting editor still renders its comment sidebar and its history — and so does a page that never mounts an editor at all, which is what makes a comment count in a document list cheap.

The flip side: they are not live. Someone else's new comment shows up when you refresh(). Trigger it from whatever signal you already have — a poll, an SSE stream, a flag in the shared document that everyone is watching anyway:

// The document is already a broadcast channel — use it.
useEffect(() => {
  const flag = doc.getMap('meta')
  const onChange = () => void refresh()
  flag.observe(onChange)
  return () => flag.unobserve(onChange)
}, [doc, refresh])

Errors

Both hooks expose error rather than throwing, and both keep the last good list while a refetch fails — so a transient error does not blank the sidebar. Failures from the write operations do reject, so await create(...) in a try/catch is the right shape for a form.

REST failures are CollabRestError, carrying status and the response body:

import { CollabRestError } from '@adonis-agora/collaboration-client'

try {
  await create({ /* … */ })
} catch (cause) {
  if (cause instanceof CollabRestError && cause.status === 403) {
    toast('You do not have permission to comment on this document.')
  }
}

On resolve and remove, 403 means something narrower — the comment is someone else's and you do not have canWrite — and 404 means the id is not in this document. Both are worth their own message; a generic "something went wrong" on a permission answer is the kind of error people file a bug about.

On this page