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.

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).