Skip to main content
Docs
SDK reference

Messaging reference

Channels, messages, reactions, members, and read receipts.

The messaging API ships as drop-in schemas and React hooks. Add the schemas to your app's RecordRoom, mount the providers (already wired in the scaffold), and call the hooks.

import {
  useChannels, useMessages, useReactions,
  useChannelMembers, useReadReceipts, useConversation,
  formatMessageTime, formatFullTimestamp, shouldGroupMessages,
  getThreadCounts, groupReactionsForMessage, parseMessageMetadata,
  getConversationDisplayName, getConversationParticipantIds,
  isDMConversation,
} from 'deepspace'

Every messaging hook returns status: 'loading' | 'ready' | 'error' and error?: string alongside its records. Gate skeleton states on status.

For an end-to-end walkthrough including UI patterns, see the messaging guide.

useChannels()#

function useChannels(): {
  channels: RecordData<Channel>[]
  status: 'loading' | 'ready' | 'error'
  error?: string
  create:  (input: { name: string; type: Channel['type']; description?: string }) => Promise<string>
  update:  (channelId: string, patch: Partial<Pick<Channel, 'name' | 'description'>>) => void
  archive: (channelId: string) => void
  remove:  (channelId: string) => Promise<void>
}

Only create and remove are async - update and archive dispatch optimistically and return void.

create({ name, type }) requires both fields. Passing only { name } returns a channel with type: undefined and silently breaks downstream queries.

const { create } = useChannels()

const channelId = await create({
  name: 'general',
  type: 'public',
  description: 'Company-wide announcements',
})
Channel types: 'public' (any signed-in user), 'private' (members only), 'dm' (two-user direct message).
update patches the channel's name and/or description. Uses merge semantics. Fire-and-forget - returns void.

const { update } = useChannels()

update(channelId, { description: 'Updated description' })

Hides the channel from default lists without deleting messages. Fire-and-forget - returns void. To unarchive, write archived: false through a custom put call.

const { archive } = useChannels()

archive(channelId)

Hard delete. The channel record and all associated messages remain in the DO until the messages are also deleted. Prefer archive for user-facing flows.

const { remove } = useChannels()

await remove(channelId)

useMessages(channelId, options?)#

function useMessages(
  channelId: string | undefined,
  options?: { parentMessageId?: string },
): {
  messages: RecordData<Message>[]
  status: 'loading' | 'ready' | 'error'
  error?: string
  send:        (content: string, parentMessageId?: string) => Promise<string> | undefined
  edit:        (messageId: string, newContent: string) => void
  softDelete:  (messageId: string) => void
  remove:      (messageId: string) => void
}

Pass options.parentMessageId to scope the query to a single reply thread.

Posts a new message to the channel. Returns the new messageId (or undefined if there's no signed-in user / no channelId). Identity (author) is derived from the verified JWT - you don't pass an authorId.

const { send } = useMessages(channelId)

const messageId = await send('Hello, world')

// Reply to a parent message:
await send('Replying inline', parentMessageId)

Replaces the message body and marks it as edited. Fire-and-forget - returns void. The collection's RBAC determines who can edit; the default MESSAGES_SCHEMA allows authors to edit their own.

const { edit } = useMessages(channelId)

edit(messageId, 'Updated text')

Sets deleted: true on the row instead of dropping it. Fire-and-forget - returns void. Preferred over remove - preserves thread continuity and lets the UI render a "[deleted]" placeholder.

const { softDelete } = useMessages(channelId)

softDelete(messageId)

Permanently removes the row. Fire-and-forget - returns void. Use sparingly - soft delete is almost always the right choice for chat history.

const { remove } = useMessages(channelId)

remove(messageId)

useReactions(channelId)#

type GroupedReaction = {
  emoji: string
  count: number
  currentUserReacted: boolean
  userIds: string[]
}

function useReactions(channelId: string | undefined): {
  reactions: RecordData<Reaction>[]
  status: 'loading' | 'ready' | 'error'
  error?: string
  getReactionsForMessage: (messageId: string) => GroupedReaction[]
  toggle: (messageId: string, emoji: string) => void
}
getReactionsForMessage returns one row per distinct emoji on the message, already aggregated with count, the full userIds list, and a currentUserReacted flag. toggle adds the caller's reaction or removes it if it already exists. Fire-and-forget - toggle returns void.

const { getReactionsForMessage, toggle } = useReactions(channelId)

for (const r of getReactionsForMessage(messageId)) {
  // r = { emoji, count, currentUserReacted, userIds }
}

<button onClick={() => toggle(messageId, 'πŸ‘')}>πŸ‘</button>

useChannelMembers(channelId)#

function useChannelMembers(channelId: string | undefined): {
  members: RecordData<ChannelMember>[]
  status: 'loading' | 'ready' | 'error'
  error?: string
  join:     () => void
  leave:    () => void
  /** True when the current signed-in user has a membership row for this channel. */
  isMember: boolean
}
isMember is a derived boolean property (not a function) that reflects whether the current signed-in user is in this channel. join and leave are fire-and-forget - they return void.

useReadReceipts()#

function useReadReceipts(): {
  receipts: RecordData<ReadReceipt>[]
  status: 'loading' | 'ready' | 'error'
  error?: string
  markAsRead:     (channelId: string) => void
  getUnreadCount: (channelId: string, messages: RecordData<Message>[]) => number
}
markAsRead records the current timestamp as the user's last-read marker for the given channel (no messageId argument - the timestamp is what matters). getUnreadCount takes the channel's messages array (typically from useMessages) and counts how many landed after the stored timestamp.

const { messages } = useMessages(channelId)
const { markAsRead, getUnreadCount } = useReadReceipts()

const unread = getUnreadCount(channelId, messages)
useEffect(() => { markAsRead(channelId) }, [channelId, markAsRead])

useConversation(options?)#

For DM/conversation Durable Objects (scope conv:<id>) backed by conv_messages / conv_reactions / conv_members collections. Mount inside a <RecordScope roomId="conv:..." schemas={CONVERSATION_SCHEMAS}>.

function useConversation(options?: {
  onMessageSent?: (content: string, parentMessageId?: string) => void
}): {
  messages: MessageRecord[]
  reactions: ReactionRecord[]
  members: MemberRecord[]
  status: 'connecting' | 'connected'
  send: (
    content: string,
    parentMessageId?: string,
    messageType?: string,
    metadata?: Record<string, unknown>,
  ) => void
  edit:           (recordId: string, content: string) => void
  remove:         (recordId: string) => void
  toggleReaction: (messageId: string, emoji: string) => void
}

All mutation methods are fire-and-forget - they return void and dispatch through the underlying record store. Pass onMessageSent to useConversation() if you need a hook into successful sends (e.g., to scroll to the bottom).

Different from useMessages / useReactions / useChannelMembers - those target the channel-style collections within your app's main RecordRoom. useConversation targets a dedicated DM Durable Object on a conv:<id> scope.

Record types#

type Channel = Envelope<{
  name: string
  type: 'public' | 'private' | 'dm'
  description?: string
  archived?: boolean
}>

type Message = Envelope<{
  channelId: string
  authorId: string
  content: string
  metadata?: object
  deleted?: boolean
}>

type Reaction = Envelope<{
  messageId: string
  authorId: string
  emoji: string
}>

type ChannelMember = Envelope<{
  channelId: string
  userId: string
  joinedAt: string
}>

type ReadReceipt = Envelope<{
  channelId: string
  userId: string
  lastReadMessageId: string
}>

The envelope shape (recordId, data, createdBy, createdAt, updatedAt) wraps every record.

Helper functions#

Helper Signature
formatMessageTime(dateStr) Returns '3:42 PM'
formatFullTimestamp(dateStr) Returns 'Today at 3:42 PM'
shouldGroupMessages(current, previous, options?) True if consecutive messages from the same author within a window
getThreadCounts(messages) Map of parent messageId β†’ reply count
groupReactionsForMessage(reactions, messageId, currentUserId) Aggregates reactions by emoji
parseMessageMetadata(raw) Safe JSON parse of metadata field

Conversation helpers#

Helper Signature
getConversationDisplayName(conv) Resolves a display string from a conversation record
getConversationParticipantIds(conv) Returns the array of participant userIds
isDMConversation(type) True if type === 'dm'

Directory conversations#

For cross-app conversations (the platform's shared dir:<appHandle> DO), use the directory hooks instead of useChannels / useMessages. These hooks support discoverable conversations, communities, and posts spanning multiple DeepSpace apps. See the worker schemas reference for DIRECTORY_SCHEMAS.

See also#