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.

DeepSpace ships a complete messaging layer as drop-in schemas plus a small set of React hooks. Wire the schemas into your app’s RecordRoom, mount the providers (already done in the scaffold), and compose a chat surface from useChannels, useMessages, and useReactions. This guide walks the end-to-end flow. For the exhaustive hook signatures, options, and return shapes, see the messaging reference.

Add the schemas

Pick the schemas your UI actually uses. CHANNELS_SCHEMA + MESSAGES_SCHEMA is the minimum; the rest are additive.
// src/schemas.ts
import type { CollectionSchema } from 'deepspace/worker'
import {
  CHANNELS_SCHEMA,
  MESSAGES_SCHEMA,
  REACTIONS_SCHEMA,
  CHANNEL_MEMBERS_SCHEMA,
  READ_RECEIPTS_SCHEMA,
} from 'deepspace/worker'

import { usersSchema } from './schemas/users-schema'
import { settingsSchema } from './schemas/admin-schema'

export const schemas: CollectionSchema[] = [
  usersSchema,
  settingsSchema,
  CHANNELS_SCHEMA,
  MESSAGES_SCHEMA,
  REACTIONS_SCHEMA,
  CHANNEL_MEMBERS_SCHEMA,
  READ_RECEIPTS_SCHEMA,
]
The drop-in schemas include sensible RBAC defaults: signed-in users can read channels and post messages, and only the author can edit or delete a message. Private-channel gating is an app-level concern - enforce it with useChannelMembers (see Channel membership).

Build the chat surface

A minimal chat surface. useChannels lists the sidebar, useMessages drives the transcript, and useReactions gives every message a toggleable emoji. Identity (author) is derived from the verified JWT - you never pass an authorId.
import { useState } from 'react'
import {
  useChannels,
  useMessages,
  useReactions,
  formatMessageTime,
} from 'deepspace'

export default function ChatApp() {
  const { channels, status } = useChannels()
  const [activeId, setActiveId] = useState<string | null>(null)

  if (status === 'loading') return <SkeletonList />

  return (
    <div className="grid grid-cols-[240px_1fr] h-screen">
      <aside>
        {channels.map((ch) => (
          <button key={ch.recordId} onClick={() => setActiveId(ch.recordId)}>
            #{ch.data.name}
          </button>
        ))}
      </aside>
      <main>{activeId && <ChannelView channelId={activeId} />}</main>
    </div>
  )
}

function ChannelView({ channelId }: { channelId: string }) {
  const { messages, send } = useMessages(channelId)
  const { toggle, getReactionsForMessage } = useReactions(channelId)
  const [draft, setDraft] = useState('')

  return (
    <>
      <ul>
        {messages.map((m) => (
          <li key={m.recordId}>
            <span>{m.data.authorId}</span>
            <span>{formatMessageTime(m.createdAt)}</span>
            <p>{m.data.content}</p>
            {getReactionsForMessage(m.recordId).map((r) => (
              <button key={r.emoji} onClick={() => toggle(m.recordId, r.emoji)}>
                {r.emoji} {r.count}
              </button>
            ))}
            <button onClick={() => toggle(m.recordId, '❤️')}>+❤️</button>
          </li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (!draft.trim()) return
          send(draft)
          setDraft('')
        }}
      >
        <input value={draft} onChange={(e) => setDraft(e.target.value)} />
        <button>Send</button>
      </form>
    </>
  )
}
Sends, edits, reactions, and deletes all dispatch optimistically and reconcile through the room. Prefer softDelete(messageId) over remove(messageId) for user-initiated deletes - it keeps thread replies and read receipts coherent. See useChannels, useMessages options, and useReactions for the full API surface.

Create a channel

Channel type is one of 'public', 'private', or 'dm'.
const { create } = useChannels()

const channelId = await create({
  name: 'general',
  type: 'public',
  description: 'Company-wide announcements',
})
type is required. Passing only { name } produces a channel with type: undefined, which silently breaks downstream queries.

Direct messages

DMs are just channels with type: 'dm'. By convention, encode the participant pair in the channel name so you can dedupe locally before calling create.
const { create } = useChannels()

async function startDM(otherUserId: string, currentUserId: string) {
  const channelId = await create({
    name: `dm:${[currentUserId, otherUserId].sort().join(':')}`,
    type: 'dm',
  })
  navigate(`/messages/${channelId}`)
}
The same useMessages(channelId) and useReactions(channelId) hooks drive the DM thread - no separate API. For cross-app DMs that live on a dedicated Durable Object scope, use useConversation instead.

Channel membership

For private channels, gate posting on membership. isMember is a derived boolean for the current signed-in user.
import { useChannelMembers } from 'deepspace'

function JoinGate({ channelId }: { channelId: string }) {
  const { isMember, join } = useChannelMembers(channelId)
  if (!isMember) return <button onClick={() => join()}>Join channel</button>
  return <MessageComposer channelId={channelId} />
}
See useChannelMembers for the full membership surface.

Read receipts

Stamp the channel as read whenever the user has it open, then derive unread counts from the message list.
import { useEffect } from 'react'
import { useMessages, useReadReceipts } from 'deepspace'

function ChannelView({ channelId }: { channelId: string }) {
  const { messages } = useMessages(channelId)
  const { markAsRead, getUnreadCount } = useReadReceipts()

  useEffect(() => {
    markAsRead(channelId)
  }, [channelId, messages.length, markAsRead])

  const unread = getUnreadCount(channelId, messages)
  // ...
}
markAsRead writes the current timestamp; getUnreadCount counts messages that landed after it. The receipt is per-user, scoped by the verified JWT. Full signatures live in useReadReceipts.

Next steps