Skip to main content
The messaging system provides real-time communication with message status tracking, typing indicators, and support for multiple content types.

Message lifecycle

Messages go through multiple status stages from creation to delivery. The system automatically tracks and updates the status of each message.

Message status flow

Messages transition through these statuses:
1

Queued

Message is created and queued for sending
2

Sending

Message is being transmitted to the server
3

Sent

Message successfully sent to server
4

Delivered

Message delivered to recipient’s device
5

Read

Message opened and read by recipient

Message interface

The Message type defines the structure of all messages in the application:
lib/chat/types.ts
export type MessageStatus = "queued" | "sending" | "sent" | "delivered" | "read" | "error"

export type MessageContentType = "text" | "image" | "audio"

export interface Message {
  id: string
  chatId: string
  authorId: string
  contentType: MessageContentType
  text?: string
  media?: MediaAttachment
  status: MessageStatus
  createdAt: string
  updatedAt?: string
  replyToId?: string
  isForwarded?: boolean
  reactions?: Reaction[]
}

Sending messages

Use the sendComposerPayload method from the chat store to send messages:
const sendMessage = useChatStore((state) => state.sendComposerPayload)

// Send text message
const messageId = sendMessage(chatId, {
  text: "Hello, how are you?"
})

// Send message with media
sendMessage(chatId, {
  text: "Check out this photo!",
  media: mediaAttachment
})

Status lifecycle implementation

The system automatically schedules status updates after sending a message:
lib/chat/state.ts
function scheduleLifecycle(
  messageId: string,
  get: () => ChatStore,
  set: (updater: (state: ChatStore) => ChatStore | Partial<ChatStore>) => void,
  initialDelay = 350
) {
  const steps: MessageStatus[] = ["queued", "sending", "sent", "delivered", "read"]

  steps.forEach((status, idx) => {
    const delay = initialDelay + idx * 450
    const timer = setTimeout(() => {
      set((state) => {
        const existing = state.messages[messageId]
        if (!existing || existing.status === "error") {
          return state
        }

        const nextMessage: Message = {
          ...existing,
          status,
          updatedAt: new Date().toISOString(),
        }

        return {
          ...state,
          messages: { ...state.messages, [messageId]: nextMessage },
        }
      })
    }, delay)
  })
}
Status updates occur at ~450ms intervals, creating a realistic progression from “queued” to “read”.

Delivery status indicators

The MessageBubble component displays visual indicators for message status:
components/chat/message-bubble.tsx
const statusIcon = {
  queued: ClockCountdown,
  sending: Clock,
  sent: Check,
  delivered: Checks,
  read: Checks,
  error: WarningCircle,
} as const

const statusColor: Record<Message["status"], string> = {
  queued: "text-muted-foreground",
  sending: "text-muted-foreground",
  sent: "text-muted-foreground",
  delivered: "text-status-delivered",
  read: "text-status-read",
  error: "text-destructive",
}

Status indicator rendering

For outgoing messages, the status icon appears next to the timestamp:
components/chat/message-bubble.tsx
<footer className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
  <span>{timeLabel}</span>
  {isOutgoing ? (
    <StatusIcon
      className={cn(
        "h-3.5 w-3.5",
        message.status === "read" ? "text-status-read" : statusColor[message.status]
      )}
      weight={message.status === "read" || message.status === "delivered" ? "fill" : "regular"}
      aria-label={`Message ${message.status}`}
    />
  ) : null}
</footer>
Single check mark indicates “sent”, double check marks indicate “delivered” or “read” with color differentiation.

Typing indicators

The typing indicator shows when a contact is composing a message.

Managing typing state

Update typing status using the setTypingIndicator method:
lib/chat/state.ts
setTypingIndicator: (chatId, authorId, isTyping) => {
  set((state) => {
    const exists = state.typingIndicators.find(
      (indicator) => indicator.chatId === chatId && indicator.authorId === authorId
    )

    if (isTyping && !exists) {
      const indicator: TypingIndicator = {
        chatId,
        authorId,
        startedAt: new Date().toISOString(),
      }
      return { ...state, typingIndicators: [...state.typingIndicators, indicator] }
    }

    if (!isTyping && exists) {
      return {
        ...state,
        typingIndicators: state.typingIndicators.filter(
          (indicator) => !(indicator.chatId === chatId && indicator.authorId === authorId)
        ),
      }
    }

    return state
  })
}

Typing indicator component

The TypingIndicator component displays an animated “typing” state:
components/chat/typing-indicator.tsx
const TypingIndicatorComponent = ({ label = "typing" }: TypingIndicatorProps) => {
  const dots = [0, 1, 2]
  return (
    <div
      className="inline-flex items-center gap-2 rounded-full bg-black/5 px-3 py-1 text-xs font-medium text-muted-foreground"
      aria-live="polite"
      aria-label={`${label} indicator`}
    >
      <span className="sr-only">{label}</span>
      <div className="flex items-center gap-1">
        {dots.map((dot) => (
          <span
            key={dot}
            className="block h-1.5 w-1.5 animate-bounce rounded-full bg-accent"
            style={{ animationDelay: `${dot * 0.12}s` }}
          />
        ))}
      </div>
    </div>
  )
}
The typing indicator uses staggered animations on three dots to create a wave effect.

Receiving messages

Incoming messages are added to the chat using the receiveMessage method:
lib/chat/state.ts
receiveMessage: (chatId, message) => {
  set((state) => {
    const nextMessages = { ...state.messages, [message.id]: message }
    const chat = state.chats[chatId]
    if (!chat) return state

    const nextChat: Chat = {
      ...chat,
      messageIds: [...chat.messageIds, message.id],
      unreadCount:
        state.activeChatId === chatId ? 0 : Math.min(chat.unreadCount + 1, 99),
      lastActivityAt: message.createdAt,
      lastMessagePreview: derivePreview(message),
    }

    return {
      ...state,
      messages: nextMessages,
      chats: { ...state.chats, [chatId]: nextChat },
    }
  })
}
Unread count is capped at 99 to prevent excessive badge numbers.

Message reactions

Messages can include emoji reactions from participants:
lib/chat/types.ts
export interface Reaction {
  emoji: string
  authorId: string
  createdAt: string
}
Reactions are displayed inline with the message bubble:
components/chat/message-bubble.tsx
{message.reactions?.length ? (
  <div className="mt-2 inline-flex items-center gap-1 rounded-full bg-black/10 px-2 py-0.5 text-xs text-foreground/80">
    {message.reactions.map((reaction) => (
      <span key={reaction.authorId + reaction.emoji}>{reaction.emoji}</span>
    ))}
  </div>
) : null}

Helper utilities

Generate message IDs

Create unique identifiers for new messages:
lib/chat/helpers.ts
export function generateMessageId(prefix = "msg") {
  try {
    const uuid = typeof globalThis !== "undefined" && "crypto" in globalThis
      ? globalThis.crypto?.randomUUID?.()
      : undefined
    if (uuid) return `${prefix}-${uuid}`
    throw new Error("no-crypto")
  } catch {
    return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
  }
}

Derive message preview

Generate preview text for chat list:
lib/chat/helpers.ts
export function derivePreview(message: Message) {
  if (message.contentType === "image") {
    return message.media?.caption ?? "📷 Photo"
  }
  if (message.contentType === "audio") {
    return "🎧 Audio message"
  }
  return message.text ?? "New message"
}

Build docs developers (and LLMs) love