Skip to content
Secure Mesh Docs
Esc
navigateopen⌘Jpreview
On this page

Chat Experience

Chat Experience

The Chat Experience is the end-to-end encrypted private messaging surface of the streaming frontend. It is implemented as a set of React (Next.js) client components — ChatWindow, ChatHeader, MessageList, and ChatFooter — that render a peer-to-peer conversation, encrypt messages before they leave the browser, decrypt them on arrival, page history through a cursor-based API, and stay in sync with real-time CustomEvent broadcasts without a websocket dependency.

Purpose and Scope

This page documents the private (1) chat experience in the streaming-frontend Next.js application:

  • The ChatWindow container component and its state/lifecycle model
  • The composition of ChatHeader, MessageList, and ChatFooter
  • The end-to-end encryption (E2EE) contract with @/lib/crypto/e2ee and how plaintext is kept out of the wire payload
  • Cursor-based message pagination through apiService (GET /messages)
  • Real-time message delivery through the new_chat_message window event
  • Empty states, loading guards, and the NewChatModal entry point

Related topics intentionally left to sibling pages:

  • Group chatGroupChatWindow is a separate conversation surface and is not covered here.
  • Backend chat API — the REST/API and gRPC protocol behind /messages (see streaming-backend/proto/chat.proto and streaming-backend/src/grpc/chat_service.rs) belong to the backend catalog page.
  • Legacy streaming/ frontend — the older streaming/src/components/ tree contains an earlier chat implementation and styling (chat-styles.ts); this page documents the current streaming-frontend/ experience.

Overview

The chat experience is built around a single active peer model: the UI shows exactly one conversation at a time, identified by an activePeer (PublicUser), and all chat state lives in ChatWindow. The component owns the message list, the pagination cursor, and the send/loading state, and composes three presentational children:

  1. ChatHeader — renders the active peer’s identity.
  2. MessageList — renders the scrollable history and triggers “load more” when the user scrolls to the top.
  3. ChatFooter — the composer: text input, emoji picker, and send button.

Two design decisions define the experience:

  • Encryption is a client-side boundary, not a transport detail. Every message is passed through encryptMessage(text, peerId) before POST /messages, and every received payload is passed through decryptMessage(payload, peerId) before it is rendered. The backend only ever sees ciphertext, and the UI re-broadcasts the encrypted payload on the local new_chat_message event so other windows decrypt it themselves.
  • Real-time updates ride on browser events, not a websocket. Incoming messages are delivered through window.dispatchEvent(new CustomEvent('new_chat_message', ...)). ChatWindow subscribes with a native addEventListener, filters by peer identity, de-duplicates by message id, and appends the decrypted message. This keeps the component decoupled from any specific transport.

Architecture

ChatWindow is the hub: it receives initialMessages and initialNextCursor (server-provided initial history), decrypts them on mount, owns the live message array, and forwards render data down to MessageList while accepting composer input from ChatFooter. All network I/O flows through apiService, all cryptographic transforms flow through e2ee, and all cross-window real-time updates flow through the new_chat_message window event.

Source: ChatWindow.tsx, ChatFooter.tsx

Main Content

Message data model: MessageItem

All chat state flows through a single flattened shape, MessageItem, which deliberately merges server fields with client-computed fields:

export interface MessageItem {
  id: string; sender: string; text: string; time: string; isSelf: boolean;
  sender_id?: string; receiver_id?: string; peer_id?: string; peer_name?: string;
}

Source: ChatWindow.tsx

The fields tell the whole story of how the component works:

  • id is the server-assigned identity and the de-duplication key for real-time events (prev.some((m) => m.id === msg.id)).
  • text is the plaintext in the UI at all times — the component never stores ciphertext in React state. Ciphertext only exists transiently in the wire payload and in the re-broadcast event detail.
  • isSelf marks messages sent from this client, used by the UI to align them (e.g., right-aligned bubbles).
  • sender_id / receiver_id come from the backend envelope and are used to determine whether an incoming real-time event belongs to the active conversation.
  • peer_id / peer_name are computed client-side to bind a message to the active peer and display their username.

Initial render: decrypt-on-mount

ChatWindow is a client component ('use client') that receives the first page of history from its parent. Because the backend only stores ciphertext, the initial batch must be decrypted before it is usable. The first useEffect performs this in a single Promise.all pass, then reverses the array because the API returns newest-first while the UI renders oldest-first:

useEffect(() => {
  let isCancelled = false;
  const decryptInitial = async () => {
    const decrypted = await Promise.all(initialMessages.map(async (m) => ({ ...m, text: await decryptMessage(m.text, activePeer?.id) })));
    if (!isCancelled) { setMessages([...decrypted].reverse()); setNextCursor(initialNextCursor); }
  };
  decryptInitial();
  return () => { isCancelled = true; };
}, [initialMessages, initialNextCursor, activePeer]);

Source: ChatWindow.tsx

Two details matter here:

  1. The isCancelled flag prevents a state update on an unmounted component if the user navigates away while decryption is in flight. This is a classic React 18 strict-mode/unmount safety pattern.
  2. Decryption is per-peerdecryptMessage(m.text, activePeer?.id) binds the decryption key to the peer identity, meaning message keys are peer-scoped rather than global.

Real-time delivery: the new_chat_message event

Live incoming messages never touch useState directly from an async callback; instead the app uses the native browser event system. ChatWindow registers a listener on window for the new_chat_message custom event, filters the payload by the active peer’s identity, decrypts it, de-duplicates it, and appends it:

useEffect(() => {
  const handleNewMessage = async (e: Event) => {
    const msg = (e as CustomEvent<MessageItem>).detail;
    if (!msg || !activePeer) return;
    const peerIdStr = String(activePeer.id);
    if (msg.peer_id === peerIdStr || msg.sender_id === peerIdStr || msg.receiver_id === peerIdStr) {
      const decryptedText = await decryptMessage(msg.text, activePeer.id);
      setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, { ...msg, text: decryptedText }]));
    }
  };
  window.addEventListener('new_chat_message', handleNewMessage);
  return () => window.removeEventListener('new_chat_message', handleNewMessage);
}, [activePeer]);

Source: ChatWindow.tsx

Why this design? By using CustomEvent on window, the chat window can be notified by any producer in the app — a polling layer, another tab via BroadcastChannel-style bridges, the sender’s own optimistic append, or a future websocket adapter — without ChatWindow depending on any of them. The event contract (CustomEvent<MessageItem>) is the only coupling.

The matching predicate is deliberately permissive: a message belongs to the conversation if it mentions the active peer as peer_id, sender_id, or receiver_id, because the backend envelope can populate these fields differently depending on direction.

Cursor-based pagination

History is loaded in batches of 30 (the limit query parameter) using an opaque next_cursor token. loadMoreMessages guards against redundant calls, decrypts the fetched batch, and prepends it (again reversing because the API is newest-first) so older messages appear above the current list:

const loadMoreMessages = async () => {
  if (!nextCursor || loadingMore || !activePeer) return;
  try {
    setLoadingMore(true);
    const res = await apiService.get<{ items: MessageItem[]; next_cursor: number | null }>('/messages', { cursor: String(nextCursor), limit: '30', peer_id: String(activePeer.id) });
    if (res?.items) {
      const decrypted = await Promise.all(res.items.map(async (m) => ({ ...m, text: await decryptMessage(m.text, activePeer.id) })));
      setMessages((prev) => [...([...decrypted].reverse()), ...prev]); setNextCursor(res.next_cursor || null);
    }
  } finally { setLoadingMore(false); }
};

Source: ChatWindow.tsx

Key behaviors:

  • The loadingMore guard makes the function idempotent under rapid scroll-triggered calls; the finally block guarantees the flag is released even if the request rejects.
  • nextCursor being null (or falsy) signals “no more history”, so the guard short-circuits and MessageList stops asking.
  • The batch is decrypted before being merged into state, so the list never renders ciphertext, even transiently.
  • Each batch reverses the API’s newest-first order, then prepends to the existing oldest-first list — the ordering invariant is maintained across pages.

Sending: encrypt first, broadcast after

sendMessage is the only place where the component writes to the backend. The plaintext is encrypted with the peer’s key, the ciphertext is sent as payload, and only after the server confirms with an id does the message enter local state as plaintext — then the encrypted form is re-broadcast locally:

const sendMessage = async (text: string) => {
  if (!activePeer || loading) return;
  try {
    setLoading(true);
    const encryptedPayload = await encryptMessage(text, String(activePeer.id));
    const sentMsg = await apiService.post<MessageItem>('/messages', { receiver_id: String(activePeer.id), payload: encryptedPayload });
    if (sentMsg?.id) {
      const newMsg: MessageItem = { ...sentMsg, text, peer_id: String(activePeer.id), peer_name: activePeer.username, isSelf: true };
      setMessages((prev) => [...prev, newMsg]); onNewMessageSent?.(newMsg);
      window.dispatchEvent(new CustomEvent('new_chat_message', { detail: { ...newMsg, text: encryptedPayload } }));
    }
  } finally { setLoading(false); }
};

Source: ChatWindow.tsx

Why the re-broadcast with the encrypted payload? The local ChatWindow already holds the plaintext in state, but any other listener (e.g., a second tab or a group-window that filters by peer) must decrypt the payload itself with its own key context. Broadcasting the encrypted form keeps a single event contract for both local and cross-window consumers, and ChatFooter disables the send button while loading is true so no double-send can occur.

Empty state and entry points

When no peer is selected, ChatWindow renders a full-height placeholder with a “Start New Chat” action instead of a chat surface. This is the component’s “no conversation” state, and it is also the hook for NewChatModal via the onOpenNewChat callback:

if (!activePeer) {
  return (
    <main className="hidden md:flex flex-1 flex-col items-center justify-center bg-[#0b0f17] text-center p-6 select-none">
      <div className="w-16 h-16 rounded-2xl bg-[#10b981]/10 border border-[#10b981]/30 ..."><MessageSquare className="w-8 h-8 text-[#4edea3]" /></div>
      <h3 className="text-xl font-bold text-[#e2e1eb] mb-2">No Active Chat Selected</h3>
      <p className="text-xs text-[#bbcabf]/60 max-w-sm mb-6">Select a channel from the sidebar to start an end-to-end encrypted session.</p>
      {onOpenNewChat && (<button onClick={onOpenNewChat} className="..."><Plus className="w-4 h-4" /> Start New Chat</button>)}
    </main>
  );
}

Source: ChatWindow.tsx

Note the hidden md:flex class: on small screens the empty state is intentionally hidden so the sidebar takes over, while the desktop layout shows a branded empty state with the accent color (#4edea3) and a soft glow shadow consistent with the rest of the theme.

The composer: ChatFooter

ChatFooter is a controlled, presentational composer. It keeps its own input state, trims whitespace, clears itself only after a successful submit (the await in handleSubmit), and renders a disabled send button with a spinner while a message is in flight:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!input.trim() || loading) return;
  const text = input.trim();
  setInput('');
  await onSendMessage(text);
};

const handleEmojiSelect = (emoji: string) => {
  setInput((prev) => prev + emoji);
};

Source: ChatFooter.tsx

The send button is disabled when the input is empty or loading is set, and swaps its icon for a spinning Loader2 while sending — a synchronous visual acknowledgement of the async send path. The emoji picker is an EmojiPickerPopover that toggles via local showEmojiPicker state and appends the selected emoji to the input cursor position (append-at-end in this implementation). The placeholder is personalized: Message ${peerName}....

Core Flow

The end-to-end lifecycle of a message — from composer keystroke to rendered bubble in a second window — is shown below. The critical invariant: plaintext exists only inside the browser; everything crossing the network is ciphertext.

Sequence walkthrough:

  1. ComposerChatFooter.handleSubmit trims the input, clears it, and awaits onSendMessage(text). The button stays disabled until the promise resolves.
  2. EncryptChatWindow.sendMessage calls encryptMessage(text, String(activePeer.id)). The wire payload is ciphertext from this point on.
  3. PersistapiService.post<MessageItem>('/messages', { receiver_id, payload }) sends the message. The backend returns a server-assigned id, which is the authoritative identity.
  4. Optimistic render — on a confirmed id, the message enters local state as plaintext with isSelf: true and peer_id/peer_name filled, then onNewMessageSent notifies the parent (e.g., to update a sidebar preview).
  5. Broadcast — the same message is re-dispatched on window as new_chat_message with the encrypted text, so any other chat surface can decrypt it with its own peer context.
  6. Receive path — a listener filters by the active peer’s identity, decrypts, de-duplicates by id, and appends. Because the sender already appended the message locally, the prev.some((m) => m.id === msg.id) check prevents the broadcast from duplicating it.

A second, simpler flow covers history loading: MessageList scrolls to the top → onLoadMore()loadMoreMessages fetches GET /messages?cursor=...&limit=30&peer_id=... → batch is decrypted, reversed, and prepended → next_cursor updated or set to null to signal the end of history.

Usage Examples

Example 1: ChatWindow composition and prop contract

The container passes the active peer to ChatHeader, the decrypted list plus pagination controls to MessageList, and the composer callback to ChatFooter. All three children are controlled components — ChatWindow owns the truth:

return (
  <main className="flex-1 flex flex-col relative bg-[#0b0f17] h-screen">
    <ChatHeader activePeer={activePeer} />
    <MessageList messages={messages} nextCursor={nextCursor} loadingMore={loadingMore} onLoadMore={loadMoreMessages} />
    <ChatFooter peerName={activePeer.username} loading={loading} onSendMessage={sendMessage} />
  </main>
);

Source: ChatWindow.tsx

Example 2: Sending an E2EE message through the API client

This is the canonical “send” path — encrypt locally, POST ciphertext, render plaintext only after server confirmation, and fan out the encrypted form to other windows:

const encryptedPayload = await encryptMessage(text, String(activePeer.id));
const sentMsg = await apiService.post<MessageItem>('/messages', { receiver_id: String(activePeer.id), payload: encryptedPayload });
if (sentMsg?.id) {
  const newMsg: MessageItem = { ...sentMsg, text, peer_id: String(activePeer.id), peer_name: activePeer.username, isSelf: true };
  setMessages((prev) => [...prev, newMsg]); onNewMessageSent?.(newMsg);
  window.dispatchEvent(new CustomEvent('new_chat_message', { detail: { ...newMsg, text: encryptedPayload } }));
}

Source: ChatWindow.tsx

Example 3: ChatFooter composer with emoji support

The composer is a controlled form: it owns input, blocks empty/loading submits, and supports emoji insertion through EmojiPickerPopover:

export default function ChatFooter({ peerName, loading, onSendMessage }: ChatFooterProps) {
  const [input, setInput] = useState('');
  const [showEmojiPicker, setShowEmojiPicker] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || loading) return;
    const text = input.trim();
    setInput('');
    await onSendMessage(text);
  };

  const handleEmojiSelect = (emoji: string) => {
    setInput((prev) => prev + emoji);
  };
  // ... renders form with EmojiPickerPopover, text input, emoji toggle, send button
}

Source: ChatFooter.tsx

Example 4: Subscribing to real-time chat events

Any component inside the app can participate in the chat stream by listening for new_chat_message on window. ChatWindow demonstrates the full pattern: filter by peer, decrypt, de-dupe, append, and clean up the listener on unmount:

const handleNewMessage = async (e: Event) => {
  const msg = (e as CustomEvent<MessageItem>).detail;
  if (!msg || !activePeer) return;
  const peerIdStr = String(activePeer.id);
  if (msg.peer_id === peerIdStr || msg.sender_id === peerIdStr || msg.receiver_id === peerIdStr) {
    const decryptedText = await decryptMessage(msg.text, activePeer.id);
    setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, { ...msg, text: decryptedText }]));
  }
};
window.addEventListener('new_chat_message', handleNewMessage);
return () => window.removeEventListener('new_chat_message', handleNewMessage);

Source: ChatWindow.tsx

API Reference

Component: ChatWindow

The container component for a 1 encrypted conversation. Rendered with server-provided initial history; owns all chat state.

export default function ChatWindow({ initialMessages, initialNextCursor, activePeer, onOpenNewChat, onNewMessageSent }: {
  initialMessages: MessageItem[];
  initialNextCursor: number | null;
  activePeer?: PublicUser | null;
  onOpenNewChat?: () => void;
  onNewMessageSent?: (msg: MessageItem) => void;
})

Source: ChatWindow.tsx

Parameters:

  • initialMessages (MessageItem[]): First page of history from the server, newest-first, ciphertext-encoded. Decrypted and reversed on mount.
  • initialNextCursor (number | null): Cursor for the next history page; null means no more history.
  • activePeer (PublicUser | null, optional): The conversation partner. PublicUser is exported from NewChatModal. When null/falsy, the component renders the “No Active Chat Selected” empty state.
  • onOpenNewChat (() => void, optional): Invoked from the empty state’s “Start New Chat” button; used to open NewChatModal.
  • onNewMessageSent ((msg: MessageItem) => void, optional): Called after a message is confirmed by the server, allowing parents to update previews/unread state.

Component: ChatFooter

The composer. Controlled by its parent via onSendMessage; owns only its input text and emoji-picker visibility.

interface ChatFooterProps {
  peerName: string;
  loading: boolean;
  onSendMessage: (text: string) => Promise<void>;
}

Source: ChatFooter.tsx

Parameters:

  • peerName (string): Display name used in the input placeholder (Message ${peerName}...).
  • loading (boolean): When true, the submit handler short-circuits and the send button shows a Loader2 spinner (disabled).
  • onSendMessage ((text: string) => Promise<void>): Async send callback; the composer awaits it before re-enabling input.

Interface: MessageItem

The unified chat message shape used by state, events, and API responses.

export interface MessageItem {
  id: string; sender: string; text: string; time: string; isSelf: boolean;
  sender_id?: string; receiver_id?: string; peer_id?: string; peer_name?: string;
}

Source: ChatWindow.tsx

Fields:

  • id (string): Server-assigned identity; used as the de-duplication key.
  • sender (string): Display name of the sender.
  • text (string): Plaintext in UI state; ciphertext on the wire and in event payloads.
  • time (string): Display timestamp.
  • isSelf (boolean): Whether the message originated from this client.
  • sender_id / receiver_id (string, optional): Backend envelope identities used in the real-time event filter.
  • peer_id / peer_name (string, optional): Client-computed conversation binding used for filtering and display.

Window event: new_chat_message

window.dispatchEvent(new CustomEvent<MessageItem>('new_chat_message', { detail: { ...msg, text: encryptedPayload } }));
// Consumers: window.addEventListener('new_chat_message', handler)

Source: ChatWindow.tsx

Detail: A MessageItem whose text is ciphertext (the encrypted payload). Consumers must call decryptMessage(detail.text, activePeer.id) before rendering. The event fires for both locally sent messages (after server confirmation) and incoming messages delivered by the app’s real-time layer.

Client library contracts used by the experience

Module Members used Purpose
@/lib/api/client apiService.get<T>(path, params), apiService.post<T>(path, body) REST calls to /messages with cursor, limit, peer_id query params and receiver_id/payload body
@/lib/crypto/e2ee encryptMessage(plaintext, peerId), decryptMessage(ciphertext, peerId) Peer-scoped E2EE transforms; plaintext never leaves the client
@/components/EmojiPickerPopover isOpen, onClose, onEmojiSelect Emoji insertion into the composer
@/components/NewChatModal PublicUser type, onOpenNewChat wiring New conversation entry point

Configuration Options

The chat experience is configuration-light by design — behavior is driven by props, constants, and the API contract rather than environment settings. The fixed knobs, all verified in source:

Option Type Value Description
History page size number 30 limit query parameter passed to GET /messages in loadMoreMessages
History sort order contract newest-first from API Client reverses each batch to render oldest-first
Real-time event name string new_chat_message Window CustomEvent used for all chat message fan-out
API endpoint (history) string GET /messages Query params: cursor, limit, peer_id
API endpoint (send) string POST /messages Body: { receiver_id, payload } where payload is ciphertext
Empty-state visibility CSS hidden md:flex Empty state only shown on md+ screens
Theme accent color #4edea3 (emerald) Send button, emoji button, empty-state glow

Note: the peer-scoped encryption key material lives behind @/lib/crypto/e2ee; key management and rotation are part of the e2ee library’s contract and are documented on its own page if present in the catalog.

Failure Modes, Edge Cases & Concurrency

The source reveals several deliberate guards against the failure modes inherent to an async, E2EE chat UI:

Double-submit / double-append (concurrency).

  • ChatFooter ignores submits when !input.trim() || loading and disables the send button while loading is true, so a user cannot fire two sends from one keystroke burst.
  • ChatWindow.sendMessage independently guards with if (!activePeer || loading) return, protecting the state even if the callback is invoked from another path.
  • Real-time events are de-duplicated with prev.some((m) => m.id === msg.id), so the sender’s own broadcast (dispatched after append) cannot create a duplicate bubble, and a slow network that delivers the echo twice is harmless.

Race between decryption and unmount.

  • The initial-decrypt effect uses an isCancelled flag set by the effect cleanup. If the user switches conversations or unmounts the window while Promise.all decryption is in flight, the state update is skipped, avoiding a React “set state on unmounted component” warning and stale-message flash.

Stale activePeer in async callbacks.

  • The real-time listener effect re-subscribes whenever activePeer changes (it is in the dependency array), so a message arriving for the previous peer after a switch is filtered out by the peer_id/sender_id/receiver_id match against the current peer. The filter also means events for unrelated peers are silently ignored rather than rendering in the wrong conversation.

End-of-history and repeated load-more.

  • loadMoreMessages short-circuits when nextCursor is null (no more pages) and when loadingMore is already true. The finally block guarantees the flag resets even if the request rejects, so a failed page load does not permanently wedge the scroll handler.

Send failure.

  • If apiService.post rejects, loading is still reset by finally, the composer re-enables, and the input text was already cleared by ChatFooter before the await. The message is not appended because the if (sentMsg?.id) branch never runs — there is no optimistic ghost message in the list, at the cost of the typed text being lost on failure (the parent may persist drafts via onNewMessageSent, which is only called on success).

Encryption/decryption failure.

  • Any decryptMessage rejection inside Promise.all rejects the whole batch. There is no per-message fallback in the current implementation, so a corrupt ciphertext or key mismatch fails the page render of that batch. This is a known sharp edge: batch decryption trades throughput for atomicity.

No active conversation.

  • With activePeer null, the component renders the empty state instead of a broken chat surface, and the sidebar (parent) owns conversation selection.

Performance & Operational Considerations

  • Batch decryption — initial history and each 30-message page are decrypted with a single Promise.all, so ciphertext decryption runs concurrently rather than serially. This keeps page loads snappy but means one bad message rejects the whole batch (see failure modes).
  • Opaque cursor pagination — history is loaded 30 at a time with next_cursor, avoiding full-history fetches on mount. nextCursor = null terminates pagination naturally; loadingMore prevents duplicate fetches from scroll thrash.
  • Event-driven fan-out, no websocket in the componentChatWindow has zero transport coupling: real-time delivery is a window event. This keeps the component lightweight and lets the app swap transports (polling, SSE, websocket, BroadcastChannel) without touching the chat UI.
  • Plaintext discipline — ciphertext is the only form that crosses the API boundary and the only form re-broadcast in events; React state never holds ciphertext for the active conversation. This bounds the cryptographic surface to two library functions and keeps server-side storage opaque.
  • Controlled childrenChatHeader/MessageList/ChatFooter are presentational; all state re-renders originate in ChatWindow, giving a single source of truth and predictable re-render scope.

Extension Points

  • Real-time transport — any producer can dispatch new_chat_message with a MessageItem detail; ChatWindow will decrypt and render it if it matches the active peer. Adapters for websockets, SSE, or BroadcastChannel (cross-tab) can be added without modifying the chat UI.
  • onNewMessageSent callback — parents can hook confirmed sends to update sidebar previews, unread counts, or optimistic lists.
  • onOpenNewChat callback — the empty state’s “Start New Chat” action is fully pluggable; it is wired to NewChatModal in the app, but any modal or navigation handler can be injected.
  • PublicUser contractNewChatModal exports the PublicUser type that defines what a selectable peer looks like; extending peer metadata flows through this type.
  • Peer-scoped crypto keysencryptMessage/decryptMessage take peerId as the key selector, so per-peer key material (e.g., derived keys per conversation) can be layered into @/lib/crypto/e2ee without changing the chat components.

Tests

No dedicated test files for the chat experience components were found in the explored source (streaming-frontend/). The verified guarantees (de-duplication, loading guards, decrypt-on-mount, peer filtering, ciphertext-only wire format) are enforced by the component logic itself, and the interactions above document the expected behavior that a unit test suite should lock in — for example: dispatching new_chat_message twice with the same id must render one bubble; loadMoreMessages with a null cursor must not call the API; sendMessage must never POST plaintext.

Was this page helpful?