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
Update
Archive
Delete
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.
Send
Edit
Soft delete
Hard delete
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