Skip to main content
Documentation

Messaging reference

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

On this page

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,
  useConversations, useCommunities, usePosts,
  formatMessageTime, formatFullTimestamp, shouldGroupMessages,
  getThreadCounts, groupReactionsForMessage, parseMessageMetadata,
  getConversationDisplayName, getConversationParticipantIds,
  isDMConversation,
} from 'deepspace'
ts

Every channel-scoped messaging hook returns status: 'loading' | 'ready' | 'error' and error?: string alongside its records - gate skeleton states on status. The directory hooks return a ready: boolean instead.

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>
}
ts

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',
})
tsx

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
}
ts

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

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
}
ts

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>
tsx

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
}
ts

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
}
ts

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])
tsx

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
}
ts

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
}>
ts

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 hooks#

The directory hooks target the platform's shared directory DO (dir: + your app id) rather than your app's own RecordRoom. They back inbox-style conversation lists, communities, and feeds. Mount the directory scope (a { type: 'dir', instanceId: APP_ID } shared connection / sharedScopes entry backed by DIRECTORY_SCHEMAS), then call the hooks inside the RecordProvider. Each returns ready: boolean (true once its queries are ready) instead of a status string.

useConversations()#

Conversation directory backed by the conversations and conversation_state collections.

function useConversations(): {
  conversations: RecordData<DirectoryConversationData>[]
  ready: boolean
  // creation
  createChannel:  (name: string, description?: string) => Promise<string>
  createDM:       (otherUserId: string) => Promise<string | null>
  createGroupDM:  (participantIds: string[]) => Promise<string | null>
  lookupByName:   (name: string) => string | null
  updateLastMessage: (conversationId: string, preview: string, authorId?: string) => void
  // per-user conversation state (read markers, stars, archive, trash, labels, folders)
  readStateMap: Map<string, string>          // ConversationId โ†’ LastReadAt
  readMessageCountMap: Map<string, number>   // ConversationId โ†’ LastReadMessageCount
  starredSet: Set<string>
  archivedSet: Set<string>
  getConversationState: (conversationId: string) => ConversationStateData | undefined
  upsertState: (conversationId: string, partial: Partial<ConversationStateData>) => void
  markRead:    (conversationId: string) => void
  toggleStar:  (conversationId: string) => void
  setArchived: (conversationId: string, archived: boolean) => void
  setTrashed:  (conversationId: string, trashed: boolean) => void
  setLabels:   (conversationId: string, labels: string[]) => void
  setFolder:   (conversationId: string, folder: string) => void
}
ts

The row types (DirectoryConversationData, ConversationStateData, and the community/membership/post shapes below) are exported from deepspace/worker; on the client, read the fields off record.data as shown.

Conversation records use capitalized fields: Name, Description, Type ('public' | 'dm' | 'group'), Visibility ('public' | 'private' | 'restricted'), CreatedBy, ParticipantHash, ParticipantIds (JSON string of user ids), Status, LastMessageAt, LastMessagePreview, LastMessageAuthor, MessageCount.

Creation methods dedupe before creating: createChannel returns the existing record id when a non-DM conversation with the same Name exists; createDM / createGroupDM dedupe on the sorted participant hash. createDM and createGroupDM return null when there's no signed-in user (and createGroupDM also for fewer than 3 total participants - use createDM for two).

Don't confuse this with the app-scoped useChannels() surface: useChannels().create takes lowercase { name, type } against your app's own channels collection, while the directory's conversation records use the capitalized Visibility / Type / ParticipantIds shape above.

const { conversations, ready, createDM, markRead } = useConversations()

const openDM = async (userId: string) => {
  const id = await createDM(userId)
  if (id) { markRead(id); navigate(`/inbox/${id}`) }
}
tsx

useCommunities()#

Communities and memberships (communities + memberships collections).

function useCommunities(): {
  communities: RecordData<DirectoryCommunityData>[]
  memberships: RecordData<DirectoryMembershipData>[]
  ready: boolean
  myMemberships: DirectoryMembershipData[]
  createCommunity: (
    name: string,
    opts?: { description?: string; type?: string; visibility?: string; rules?: string },
  ) => Promise<string>
  updateCommunity: (communityId: string, updates: Partial<DirectoryCommunityData>) => void
  joinCommunity:   (communityId: string, userName: string) => Promise<string | null>
  leaveCommunity:  (communityId: string) => void
  getMembersOf:    (communityId: string) => DirectoryMembershipData[]
  lookupByName:    (name: string) => string | null
}
ts

createCommunity dedupes on Name and defaults Visibility to 'public' and Type to 'community'. joinCommunity returns the existing membership id if the caller already joined, or null when signed out. Community records carry Name, Description, CreatedBy, Type, Visibility, MemberCount, Rules, IconUrl, CoverUrl; membership records carry CommunityId, UserId, UserName, Role, JoinedAt.

const { communities, joinCommunity } = useCommunities()

<button onClick={() => joinCommunity(community.recordId, myName)}>Join</button>
tsx

usePosts(options?)#

Feed / Q&A / thread-style posts (posts collection), optionally filtered to one community.

function usePosts(opts?: { communityId?: string }): {
  posts: RecordData<DirectoryPostData>[]
  ready: boolean
  createPost: (data: {
    title: string
    content: string
    type?: string
    communityId?: string
    parentId?: string
    tags?: string[]
    linkUrl?: string
  }) => Promise<string>
  updatePost:        (postId: string, updates: Partial<DirectoryPostData>) => void
  deletePost:        (postId: string) => void
  setConversationId: (postId: string, conversationId: string) => void
}
ts

createPost stamps the caller as AuthorId, defaults Type to 'post' and Status to 'published', and stores tags as a JSON string. setConversationId links a post to a directory conversation (e.g., a discussion thread opened from the post). Post records carry Title, Content, AuthorId, Type, CommunityId, ParentId, ConversationId, Status, Tags, LinkUrl.

const { posts, ready, createPost } = usePosts({ communityId })

if (!ready) return <FeedSkeleton />
await createPost({ title: 'Show and tell', content: 'We shipped!', communityId })
tsx

See also#