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);useCommentsreturnsunreadCount(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-storageand put the URL in the body — first-classattachments[]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.listsubscription 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).