Writing your own
Build a pinned-messages extension step by step, from stored fields and an endpoint to UI in the admin and on a website.
This page builds pins(): people who can post in a channel pin a message, everyone who can read it sees "Pinned by Anna" under it, and a conversation holds at most a few pinned messages. It touches every part of the extension surface that a real feature needs. The patterns are the ones reactions() uses; its source (src/reactions/ in the package) is the reference to read next to this page.
The finished extension has three files:
extensions/pins/
index.ts server: options, fields, decorate, endpoints, slots
PinAction.tsx admin: menu item and footer note
shared.ts types both sides use1. Name and shared types
The extension's name becomes its endpoint path (/api/conversations/<instance>/pins), its key in message.ext, and the name the browser asks for. It must not collide with a core endpoint (messages, read, poll, mentions, subscribe, events, pusher-auth).
export const PINS = 'pins'
/** What `decorate` puts at `message.ext.pins` on a pinned message. */
export type PinInfo = { at: string; by: string }
/** Public settings, sent to the browser with every subscribe. */
export type PinsClientData = { max: number }2. Where the data lives
A pin is two values per message, bounded and small, so it belongs on the message itself: messageFields adds them to <instance>-messages. No extra collection, no extra query per page, and the rows go with the conversation when its target is deleted.
import {
type ConversationMessage,
type ConversationsExtension,
defineExtension,
type ExtensionEndpoint,
projectUsers,
} from '@10x-media/conversations'
import type { Field } from 'payload'
import { PINS, type PinInfo, type PinsClientData } from './shared'
export type PinsOptions = {
/** Pinned messages per conversation. Default 5. */
max?: number
}
type Stored = ConversationMessage & { pinnedAt?: null | string; pinnedBy?: null | string }
const fields: Field[] = [
{ name: 'pinnedAt', type: 'date', admin: { hidden: true } },
{ name: 'pinnedBy', type: 'text', admin: { hidden: true } },
]Fields an extension stores are its own: the core strips them from every response, so the browser never sees pinnedBy as a raw user key. What clients see is what decorate returns. On Postgres, new fields are new columns, so create a migration after adding the extension.
If the data were unbounded (who read each message, a vote per person), a collection of your own added in after would fit better, with a unique index doing the concurrency work. Reactions shows both shapes.
3. Per-message data
decorate runs for every response that carries messages and returns a record keyed by message id. Here it names who pinned, through projectUsers, which projects user keys the same way the core projects authors: one query per users collection for the whole batch.
const decorate: NonNullable<ConversationsExtension['decorate']> = async ({
instance,
messages,
req,
}) => {
const pinned = (messages as Stored[]).filter((message) => message.pinnedAt && message.pinnedBy)
if (pinned.length === 0) return {}
const names = await projectUsers(
req,
instance,
pinned.map((message) => message.pinnedBy as string)
)
return Object.fromEntries(
pinned.map((message): [string, PinInfo] => [
String(message.id),
{ at: message.pinnedAt as string, by: names[message.pinnedBy as string]?.name ?? '' },
])
)
}Return nothing for messages without data; they simply get no ext.pins. Never query per message here: a feed page is 30 messages.
4. The endpoints
Two endpoints, pin and unpin, both idempotent, so a retried request never flips a pin back. The collections are closed over REST, so these are the only way in, and ctx brings the core's access rules along:
const change =
(pin: boolean, max: number): ExtensionEndpoint['handler'] =>
async ({ ctx, instance, req }) => {
const body = await ctx.body()
const id =
typeof body.message === 'string' || typeof body.message === 'number' ? String(body.message) : ''
if (!id) ctx.fail('message is required', 400)
// Readable by the viewer, and in a channel they can post in (else 404 / 403).
const message = (await ctx.readableMessage(id, { deleted: !pin, write: true })) as Stored
if (pin && !message.pinnedAt) {
const { totalDocs } = await req.payload.db.count({
collection: instance.messagesSlug,
req,
where: { and: [{ key: { equals: message.key } }, { pinnedAt: { exists: true } }] },
})
if (totalDocs >= max) ctx.fail(`At most ${max} pinned messages`, 409)
}
if (Boolean(message.pinnedAt) !== pin) {
await req.payload.db.updateOne({
collection: instance.messagesSlug,
id: message.id,
data: pin
? { pinnedAt: new Date().toISOString(), pinnedBy: ctx.viewer() }
: { pinnedAt: null, pinnedBy: null },
req,
returning: false,
})
// Move `updatedAt` and publish, so every open feed picks the change up.
await ctx.touch(message.id)
}
const { authors, messages } = await ctx.respond([
await ctx.readableMessage(id, { deleted: true }),
])
return Response.json({ authors, message: messages[0] })
}What each helper is for:
ctx.readableMessage(id, { write: true })runs conversation access and the channel rules exactly as a send does. Withoutwriteit only requires read access;deleted: truelets the handler reach a deleted message (here: unpinning one).- The write goes through
req.payload.db, the adapter, so the message's collection hooks do not run: pinning is not an edit, sets noeditedAt, and fires noafterMessage. ctx.touchis not optional. Every transport, polling included, only says "this key changed", judged byupdatedAt. Without it the pinner sees the pin and nobody else does.ctx.respondreturns the message the way the core does, decorated by every extension (pins, reactions, yours) and with authors, so the browser can merge it.
The check "at most max" is a count then a write, so two people pinning at the same moment can end one over. That is fine for pins. When a limit must hold exactly, check after the write and undo, as reactions does for maxPerUser.
5. Put it together
export const pins = (options: PinsOptions = {}) => {
const max = options.max ?? 5
const client: PinsClientData = { max }
return defineExtension<PinsOptions>({
name: PINS,
options,
client,
messageFields: fields,
decorate,
endpoints: [
{ method: 'post', path: '/pin', handler: change(true, max) },
{ method: 'post', path: '/unpin', handler: change(false, max) },
],
slots: {
messageActions: '/extensions/pins/PinAction#PinAction',
messageFooter: '/extensions/pins/PinAction#PinnedNote',
},
})
}conversations({
slug: 'comments',
// ...
extensions: [comments({ collections: { posts: true } }), pins({ max: 3 })],
})Slot paths are Payload component paths, resolved through the import map like any other: a project path (/extensions/..., relative to admin.importMap.baseDir) or a package export (@acme/conversations-pins/client#PinAction) if you publish the extension. Run pnpm payload generate:importmap after adding it.
No onTargetDelete is needed: the fields go with the message rows. An extension with its own collection removes its rows there, one deleteMany on key.
6. Admin UI
messageActions components render as items in each message's "⋯" menu, after Reply, Edit and Delete. They get { instance, conversationKey, channel, message }. useMessageMenu() gives close; useExtensionApi calls the endpoints and merges the answer into every open window, in this tab and the others.
'use client'
import { type ChatSlotProps, useMessageMenu } from '@10x-media/conversations/client'
import {
ChatScope,
type ConversationMessage,
ConversationsRequestError,
useExtension,
useExtensionApi,
type WindowMessage,
} from '@10x-media/conversations/react'
import { PopupList, toast } from '@payloadcms/ui'
import { PINS, type PinInfo, type PinsClientData } from './shared'
const pinOf = (message: ConversationMessage) => message.ext?.[PINS] as PinInfo | undefined
const PinButton = ({ message }: { message: ConversationMessage }) => {
const menu = useMessageMenu()
const api = useExtensionApi(PINS)
const settings = useExtension<PinsClientData>(PINS)
const pinned = Boolean(pinOf(message))
return (
<PopupList.Button
onClick={async () => {
menu?.close()
try {
const result = await api.request<{ message: WindowMessage }>(pinned ? '/unpin' : '/pin', {
body: { message: message.id },
})
api.merge([result.message])
} catch (error) {
const full = error instanceof ConversationsRequestError && error.status === 409
toast.error(full ? `At most ${settings?.max} pinned messages` : 'Could not pin')
}
}}
>
{pinned ? 'Unpin' : 'Pin'}
</PopupList.Button>
)
}
/** `messageActions`: Pin or Unpin in the message menu. */
export const PinAction = ({ instance, message }: ChatSlotProps) =>
message && !message.deletedAt ? (
<ChatScope instance={instance}>
<PinButton message={message} />
</ChatScope>
) : null
/** `messageFooter`: who pinned it. Renders on deleted placeholders too, so check. */
export const PinnedNote = ({ message }: ChatSlotProps) => {
const pin = message ? pinOf(message) : undefined
return pin ? <div className="pinned-note">📌 Pinned by {pin.by}</div> : null
}ChatScope selects the instance for the hooks below it when a page has several instances; slot components get the instance as a prop for exactly this. Use Payload's own components (PopupList.Button, Button, toast) and CSS variables, and the extension looks native. For what else a slot can do, including server components, see Slots.
The menu item appears on every message, also in read-only channels, where the endpoint answers 403. To hide it there, read the channel's canCreate with useChannels(message.key), as useReactions().readOnly does.
7. Website UI
On a website there is no import map. The registry's ConversationUIProvider takes React components in slots; menu items get { close, message }:
'use client'
import { useExtensionApi, type WindowMessage } from '@10x-media/conversations/react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
export function PinItem({ close, message }: { close: () => void; message: WindowMessage }) {
const api = useExtensionApi('pins')
const pinned = Boolean(message.ext?.pins)
if (message.deletedAt) return null
return (
<DropdownMenuItem
onClick={async () => {
close()
const result = await api.request<{ message: WindowMessage }>(pinned ? '/unpin' : '/pin', {
body: { message: message.id },
})
api.merge([result.message])
}}
>
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)
}<ConversationUIProvider slots={{ messageActions: [PinItem], messageFooter: [PinnedFooter] }}>
<ConversationPanel conversationKey={key} />
</ConversationUIProvider>The hooks come from @10x-media/conversations/react and import nothing from @payloadcms/ui, so keep the shared types in a file without admin imports. See Website UI.
Data that comes with a new message
Pins change messages that already exist, so they go through an endpoint. When the data is part of writing a message (a poll's options, files, a priority flag), send it with the message instead of a second request after it:
- In the composer, a component in the
composerBeloworcomposerActionsslot (on a website, anywhere inside the composer) registers an addon withuseComposerAddon('priority', { prepare, reset }).preparereturns the extension's part of the send. - On the server, the extension's
sendhook reads that part asinput, validates it and returns values for itsmessageFields. They are stored in the same write as the message.
// server
send: ({ input, fail }) => {
if (input === undefined) return undefined
if (input !== 'high' && input !== 'normal') fail('Unknown priority', 400)
return { priority: input }
},
// client, in a composerActions slot
const [high, setHigh] = useState(false)
useComposerAddon('priority', {
prepare: () => (high ? 'high' : undefined),
reset: () => setHigh(false),
})The same send runs for postMessage({ ext: { priority: 'high' } }) from server code. Attachments is the full example: its addon uploads files in prepare, its send checks the ids. See Part of the send and Adding to the composer.
8. Test it
Extensions are tested through the real endpoints, the way the plugin's own suite does it: boot Payload with the instance and your extension, sign users in, and call /api/conversations/<instance>/pins/pin through Payload's handleEndpoints. Worth covering:
- a user without access gets 404, a user in a read-only channel gets 403;
- pin, then the message in a normal feed page carries
ext.pinsfor another viewer; - a repeated pin changes nothing, and
updatedAtmoves only on a real change; - the limit answers 409;
- both Mongo and Postgres, since adapter writes (
db.updateOne,db.count) are exactly where the two differ.
Checklist
nameis unique and not a core endpoint name.- Every change a feed shows moves
updatedAt(ctx.touch, ortouchMessagefrom hooks and jobs). decorateis one query per batch, and returns only what the viewer may see.- Endpoints start with
ctx.readableMessage(or at leastctx.viewer()); passwrite: truefor anything that changes a conversation. - Endpoints are idempotent (
add/remove,pin/unpin), not toggles. - Own rows are removed in
onTargetDelete(and inonMessageDeleteif they belong to one message). - Data that comes with a new message goes through
sendand a composer addon, not a second request. - Slot components check
message.deletedAtwhere it matters; footers render on placeholders.