Skip to main content
Documentation

Messaging

Build public real-time chat with channels, reactions, and read receipts.

On this page

DeepSpace ships a small public-chat layer as drop-in schemas plus React hooks. Add the schemas to your app-owned RecordRoom, then compose the UI from useChannels, useMessages, and useReactions.

Add the schemas#

Import schemas from the runtime-neutral deepspace/schema entry point whenever browser or shared code also imports the schema array.

// src/schemas.ts
import type { CollectionSchema } from 'deepspace/schema'
import {
  CHANNELS_SCHEMA,
  MESSAGES_SCHEMA,
  REACTIONS_SCHEMA,
  CHANNEL_MEMBERS_SCHEMA,
  READ_RECEIPTS_SCHEMA,
} from 'deepspace/schema'

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

export const schemas: CollectionSchema[] = [
  usersSchema,
  CHANNELS_SCHEMA,
  MESSAGES_SCHEMA,
  REACTIONS_SCHEMA,
  CHANNEL_MEMBERS_SCHEMA,
  READ_RECEIPTS_SCHEMA,
]
ts

The five collections are channels, messages, reactions, channel-members, and read-receipts. They live in your app's room; DeepSpace does not create a shared global messaging database.

Build the chat surface#

import { useState } from 'react'
import { useChannels, useMessages, useReactions } from 'deepspace'

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

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

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

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

  return (
    <>
      <ul>
        {messages.map((message) => (
          <li key={message.recordId}>
            <span>{message.data.authorId}</span>
            <time>{new Date(message.createdAt).toLocaleTimeString()}</time>
            <p>{message.data.deleted ? '[deleted]' : message.data.content}</p>
            {getReactionsForMessage(message.recordId).map((reaction) => (
              <button
                key={reaction.emoji}
                onClick={() => toggle(message.recordId, reaction.emoji)}
              >
                {reaction.emoji} {reaction.count}
              </button>
            ))}
            <button onClick={() => softDelete(message.recordId)}>Delete</button>
          </li>
        ))}
      </ul>
      <form
        onSubmit={(event) => {
          event.preventDefault()
          if (!draft.trim()) return
          send(draft)
          setDraft('')
        }}
      >
        <input value={draft} onChange={(event) => setDraft(event.target.value)} />
        <button>Send</button>
      </form>
    </>
  )
}
tsx

The author is derived from the verified session. Sends, edits, reactions, and deletes are fire-and-forget: query state changes only after the room accepts the write and broadcasts it. Handle rejected writes with RecordProvider.onWriteError. Prefer softDelete(messageId) over remove(messageId) so thread links remain coherent.

Create a channel#

All bundled channels are public, so create only needs a name and optional description.

const { create } = useChannels()

const channelId = await create({
  name: 'general',
  description: 'Company-wide announcements',
})
tsx

Membership#

Membership is useful for public opt-in UX. join and leave use confirmed mutations, so their promises settle only after the room accepts or rejects the write.

import { useState } from 'react'
import { useChannelMembers } from 'deepspace'

function MembershipButton({ channelId }: { channelId: string }) {
  const { isMember, join, leave } = useChannelMembers(channelId)
  const [pending, setPending] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const changeMembership = async () => {
    setPending(true)
    setError(null)
    try {
      if (isMember) await leave()
      else await join()
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Membership update failed')
    } finally {
      setPending(false)
    }
  }

  return (
    <div>
      <button disabled={pending} onClick={() => void changeMembership()}>
        {pending ? 'Saving…' : isMember ? 'Leave' : 'Join'}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  )
}
tsx

Membership does not make a channel private. The bundled message schema remains readable to every room member.

Read receipts#

import { useEffect } from 'react'
import { useMessages, useReadReceipts } from 'deepspace'

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

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

  return <span>{getUnreadCount(channelId, messages)} unread</span>
}
tsx

markAsRead stores the current timestamp for the signed-in user. Receipt rows are private to that user under the bundled schema.

App-specific private messaging#

Keep private messaging inside the app that owns it. Define a collection with immutable participant IDs, set it as collaboratorsField, and use read: 'shared' so the worker—not UI filtering—enforces access. You can give each thread an app-owned room ID such as chat:${threadId} when separate Durable Objects are useful. Expose an authenticated API if another app needs access.

Next steps#