Comments

Comment threads on any app entity — replies, resolve/reopen, @-mentions with notifications, reactions, unread counters — live over the reactive engine, with an ejectable thread UI.

@voltro/plugin-comments hangs discussion threads on anything your app can name — an order, a document, a row, a section anchor — and keeps every open view LIVE: a second client sees a new comment without a reload, because comments.list declares the plugin's reactivity channel as its source: and every write publishes it. No second push mechanism, no vendor websocket.

// app.config.ts
import { commentsPlugin } from '@voltro/plugin-comments'

export default {
  // …
  plugins: [
    commentsPlugin({
      access: {
        viaEntity: async ({ anchor, subject, store }) => {
          // Resolve the anchor to YOUR entity and answer with YOUR rules —
          // the same guard your queries use. A row the guard cannot read
          // (including a soft-deleted one) is a refusal.
          const [table, rowId] = anchor.split(':')
          if (table !== 'orders' || store === undefined) return false
          const rows = await store.query({
            table: 'orders', predicate: eq('id', rowId!),
            order: [], take: 1, skip: undefined, projection: undefined,
          })
          return rows.length > 0
        },
      },
      resolveMentions: async ({ query, subject }) =>
        searchTeamMembers(query, subject.tenantId),
    }),
  ],
}

Access follows the anchor — fail-closed

Only your app knows who may see the entity a thread hangs on. The plugin therefore takes a declared rule and REFUSES every read and write when none is declared — a comments surface with no access rule serves nobody rather than everybody:

  • access.viaEntity — guard delegation (above). Receives the anchor, the calling subject and the bound store.
  • access.scope — one scope every comment reader/writer must hold, for team-internal comment surfaces.

A soft-deleted anchor is the same door: your guard cannot approve what it cannot read, so the whole thread answers CommentAccessRefused — an inbox notification that still points at it finds "no longer available", not a leak.

The live thread UI

import { CommentsThread } from '@voltro/ui'

const OrderPage = ({ order }) => <CommentsThread anchor={`orders:${order.id}`} />

Try it — this is the real plugin against this docs site's demo backend. Open this page in a second tab: a comment typed in one appears in the other without a reload (tabs share your per-browser visitor identity; other visitors' threads are isolated):

import { CommentsThread } from '@voltro/ui'

<CommentsThread anchor="demo:comments" />

<CommentsThread> is deliberately unstyled (semantic markup + data-* hooks) and ejectable; underneath it is useComments(anchor):

import { useComments } from '@voltro/plugin-comments/web'

const { threads, unreadCount, create, resolve, react, markRead } = useComments(`orders:${id}`)

useComments(anchor, apiName?) subscribes to comments.list, whose source: is the plugin's own reactivity channel — so a second client sees a new comment without a reload, and every action on the handle (create, edit, resolve, remove, react, markRead) re-runs the list for all subscribers. unreadCount is the sum of the threads' own counters, i.e. the badge number.

Two focused hooks sit beside it, both from the same entry point:

import { useThread, useMentionSearch } from '@voltro/plugin-comments/web'

const thread = useThread(`orders:${id}`, threadId)   // ThreadView | undefined
const people = useMentionSearch(term)                // [{ subjectId, label }]

useThread(anchor, threadId, apiName?) is a projection over the SAME live list — it opens no second subscription, so a detail pane beside the thread list costs nothing. useMentionSearch(query, apiName?) drives the @-autocomplete over comments.mentionSearch; the directory it searches is the app's own resolveMentions seam, already tenant-filtered by the rule below.

Comments on a passage

A new thread can carry a quote: the text the editor selected, with up to 32 characters before and after it (the W3C Web Annotation text-quote shape). The passage is quoted rather than addressed by offset, so it is found again after edits elsewhere in the text.

import { findQuote, textQuote, useComments } from '@voltro/plugin-comments/web'

const { threads, create } = useComments(`doc:${id}#body`)
// The editor selected characters 36–41 of the text.
await create('Is larch still right here?', { quote: textQuote(text, 36, 41) })
// { exact: 'larch', prefix: 'd them from larch. Larch lasts; ', suffix: ' is light.' }
threads[0].quote
// Where it stands now — { start, end } — or null once the text no longer holds it.
findQuote(currentText, threads[0].quote)
  • textQuote(text, start, end) builds the quote from a text control's selection, at most 1,000 characters (QUOTE_MAX_LENGTH). findQuote picks, among repeated passages, the one whose surroundings match best; a text that no longer holds the passage answers null, so a UI can say the text has changed instead of pointing at the wrong words.
  • The quote belongs to the thread: it is set when the thread is opened and ignored on a reply. Threads without one read quote: null. The access rule sees the anchor as before; the quote is not part of it.
  • <CommentsThread> shows a thread's quote above its comments, as blockquote[data-thread-quote].

Mentions are tenant-safe by construction

The resolveMentions seam RECEIVES the calling subject — the signature makes forgetting impossible — and the plugin re-filters whatever your resolver returns to the caller's tenant (opt out with crossTenant: true for single-tenant apps). Mentions are ALSO re-validated at create time against the same resolver, so a hand-crafted mention on a foreign tenant is dropped, not delivered: the @-autocomplete cannot leak existence or names across the boundary, and no notification ever crosses it.

A validated mention delivers through plugin-notifications when it is configured — recipient preferences, quiet hours and digests apply (ten mentions inside a digest window roll into ONE delivery). Without the notifications plugin the mention still renders in the thread; the push half is simply absent (a log note, never an error).

Moderation is opt-in, honestly

Nothing is filtered automatically. To moderate comment bodies, add one rule to plugin-moderation:

moderationPlugin({ rules: [{ match: /^comments\./, fields: ['body'] }] })

Deleting others' comments takes the comments:moderate scope; editing is always author-only.

What else ships

  • Reactions — per-emoji toggle, aggregated with count + mine, in the live delta.
  • Thread unread — a per-subject read marker (markRead); useComments returns unreadCount (your own comments are never unread for you).
  • Resolve / reopen — anyone who may read the anchor may resolve (the Liveblocks semantic).
  • Attachments are a declared limit: grant an upload via plugin-storage and put the URL in the body — first-class attachments[] is deliberately not built until the storage grant flow is the proven shape.
  • Known reactivity granularity: the live feed is channel-wide — every comment write re-runs every open comments.list subscription app-wide. Fine for team-scale commenting; the read-set work on the realtime roadmap is the named narrowing.

Observability

GET /_voltro/inspect/plugins/comments/threads + a Comments panel in both dashboards (volume, open/resolved, recent threads).

Inspect thread contents

In either dashboard, open a thread anchor in Comments to read its messages, authors and timestamps. Contents are requested only for the selected thread and displayed in pages of 50. A missing thread is reported explicitly. The inspect endpoint accepts threadId and offset on /threads; it remains an authenticated operator surface.

The thread inventory is searchable by anchor, thread ID or author across the full store, with 50 threads per page ordered by creation time and ID (newest first). Submit a search to return to the first page; clear it to browse all threads again. Global thread/comment counts remain separate from the matching result count. The list endpoint accepts search (up to 200 characters) and offset, and returns page metadata with the applied search, offset, total matches and page size. Search is case-insensitive. offset also pages messages when threadId is supplied.

Replica views in the dashboard

Dashboard reads retain the identity of the answering replica. Select one replica or compare several in separate panels. Store-backed snapshots are process observations: memory and custom stores need not be shared, while durable counters may already aggregate shared data. Do not sum these snapshots across replicas.

Thread details can be closed while messages are loading or a read has failed. Reload messages retries a failed thread read; Reload threads retries a failed inventory read. These actions preserve the selected page and search and do not write comments. Failed reads never display an empty thread as though loading had succeeded.