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'
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>
}
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 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
}
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}`) }
}
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
}
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>
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
}
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 })
See also#
- Messaging guide - UI patterns and worked examples.
- Permissions - how
'private'/'dm'channel types map to RBAC. - Worker schemas reference -
CHANNELS_SCHEMA,MESSAGES_SCHEMA, etc.