Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.deep.space/llms.txt

Use this file to discover all available pages before exploring further.

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

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)

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

HelperSignature
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

HelperSignature
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