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

# Messaging reference

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

The messaging API ships as drop-in [schemas](/sdk-reference/worker/schemas) and React hooks. Add the schemas to your app's `RecordRoom`, mount the providers (already wired in the scaffold), and call the hooks.

```ts theme={null}
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](/guides/messaging).

## `useChannels()`

```ts theme={null}
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`.

<Tabs>
  <Tab title="Create">
    `create({ name, type })` requires both fields. Passing only `{ name }` returns a channel with `type: undefined` and silently breaks downstream queries.

    ```tsx theme={null}
    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).
  </Tab>

  <Tab title="Update">
    `update` patches the channel's `name` and/or `description`. Uses [merge semantics](/sdk-reference/client/records#usemutations-t-collection). Fire-and-forget - returns `void`.

    ```tsx theme={null}
    const { update } = useChannels()

    update(channelId, { description: 'Updated description' })
    ```
  </Tab>

  <Tab title="Archive">
    Hides the channel from default lists without deleting messages. Fire-and-forget - returns `void`. To unarchive, write `archived: false` through a custom `put` call.

    ```tsx theme={null}
    const { archive } = useChannels()

    archive(channelId)
    ```
  </Tab>

  <Tab title="Delete">
    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.

    ```tsx theme={null}
    const { remove } = useChannels()

    await remove(channelId)
    ```
  </Tab>
</Tabs>

## `useMessages(channelId, options?)`

```ts theme={null}
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.

<Tabs>
  <Tab title="Send">
    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](/concepts/architecture#security-model--websocket-identity) - you don't pass an `authorId`.

    ```tsx theme={null}
    const { send } = useMessages(channelId)

    const messageId = await send('Hello, world')

    // Reply to a parent message:
    await send('Replying inline', parentMessageId)
    ```
  </Tab>

  <Tab title="Edit">
    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.

    ```tsx theme={null}
    const { edit } = useMessages(channelId)

    edit(messageId, 'Updated text')
    ```
  </Tab>

  <Tab title="Soft delete">
    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.

    ```tsx theme={null}
    const { softDelete } = useMessages(channelId)

    softDelete(messageId)
    ```
  </Tab>

  <Tab title="Hard delete">
    Permanently removes the row. Fire-and-forget - returns `void`. Use sparingly - soft delete is almost always the right choice for chat history.

    ```tsx theme={null}
    const { remove } = useMessages(channelId)

    remove(messageId)
    ```
  </Tab>
</Tabs>

## `useReactions(channelId)`

```ts theme={null}
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`.

```tsx theme={null}
const { getReactionsForMessage, toggle } = useReactions(channelId)

for (const r of getReactionsForMessage(messageId)) {
  // r = { emoji, count, currentUserReacted, userIds }
}

<button onClick={() => toggle(messageId, '👍')}>👍</button>
```

## `useChannelMembers(channelId)`

```ts theme={null}
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()`

```ts theme={null}
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.

```tsx theme={null}
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}>`.

```ts theme={null}
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](/concepts/architecture#scopes).

## Record types

```ts theme={null}
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](/concepts/data-model#collections-and-records) 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 `userId`s           |
| `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](/sdk-reference/worker/schemas#drop-in-schemas--directory-cross-app) for `DIRECTORY_SCHEMAS`.

## See also

* [Messaging guide](/guides/messaging) - UI patterns and worked examples.
* [Permissions](/concepts/permissions) - how `'private'` / `'dm'` channel types map to RBAC.
* [Worker schemas reference](/sdk-reference/worker/schemas) - `CHANNELS_SCHEMA`, `MESSAGES_SCHEMA`, etc.
