Plugins
Conversations

Quick start

Install the plugin, decide who sees which conversation, and add comments to a collection.

Install

pnpm add @10x-media/conversations@beta

The plugin needs payload, @payloadcms/ui and @payloadcms/richtext-lexical 3.83 or later, and React 19. Messages are Lexical rich text, validated against an editor the plugin builds per instance.

Configure an instance

The example gives staff (the users collection) an internal channel, and lets customers (a second auth collection) talk to staff about the persons they own.

payload.config.ts
import { conversations, perTarget } from '@10x-media/conversations'
import { comments } from '@10x-media/conversations/comments'

const isStaff = ({ req }) => req.user?.collection === 'users'

export default buildConfig({
  collections: [users, customers, persons],
  plugins: [
    conversations({
      slug: 'comments',
      users: ['users', 'customers'],
      // Staff see every person; a customer only the persons they own.
      access: perTarget(
        ({ doc, req }) => isStaff({ req }) || String(doc?.owner) === String(req.user?.id),
        { load: true }
      ),
      channels: [
        {
          slug: 'internal',
          label: 'Internal',
          cue: { label: 'Internal · staff only', tone: 'neutral' },
          access: { read: isStaff, create: isStaff },
        },
        {
          slug: 'shared',
          label: 'Shared',
          // Staff need the warning; the customer is the audience.
          cue: ({ req }) =>
            isStaff({ req }) ? { label: 'Shared · visible to the customer', tone: 'warning' } : null,
          access: { read: () => true, create: () => true },
        },
      ],
      // Who `@` offers and stores: staff mention staff, customers nobody.
      mentions: {
        users: ({ req, collection }) =>
          isStaff({ req }) && collection === 'users' ? {} : { id: { exists: false } },
      },
      hooks: {
        afterMention: async ({ req, users, message }) => {
          // Notify through your own system, ideally by queueing a job.
        },
      },
      extensions: [comments({ collections: { persons: true } })],
    }),
  ],
})

Keep mentions.users when you copy this. Without it, anyone who can read a channel customers read can search every user of every listed users collection through the mention endpoint (GET /api/conversations/<instance>/mentions?q=): staff and other customers alike, found by useAsTitle or, without one, by email. The channel's read rule only narrows candidates to people who can read that channel, and shared is readable by everyone. See Mentions.

List conversations() after any plugin that adds your target or users collections: the plugin checks targets against the collections it can see.

Then regenerate the import map, since the plugin registers admin components:

pnpm payload generate:importmap

Open a person. The document controls now show a Comments button with a count and an unread dot. It opens a drawer with a tab per readable channel, the feed, and a composer. A thread opens as a second drawer on top.

What you get

  • Two hidden collections, comments-messages and comments-reads, closed over REST and GraphQL.
  • Endpoints under /api/conversations/comments.
  • An admin provider that watches the conversations on screen through the instance's transport (polling by default), and nothing else.
  • When a person is deleted, its messages and read cursors go with it.

Post from server code

A hook, a job or a seed can post a message of any type:

import { collectionKey, postMessage } from '@10x-media/conversations'

await postMessage(req, {
  instance: 'comments',
  key: collectionKey('persons', person.id),
  channel: 'internal',
  author: `users:${req.user.id}`,
  text: 'Passport verified.',
})

Collection hooks still run, so validation, mentions and afterMessage behave as they do for a message sent from the admin. See Messages.

Next steps

On this page