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
ChatWindowcontainer component and its state/lifecycle model - The composition of
ChatHeader,MessageList, andChatFooter - The end-to-end encryption (E2EE) contract with
@/lib/crypto/e2eeand 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_messagewindow event - Empty states, loading guards, and the
NewChatModalentry point
Related topics intentionally left to sibling pages:
- Group chat —
GroupChatWindowis a separate conversation surface and is not covered here. - Backend chat API — the REST/API and gRPC protocol behind
/messages(seestreaming-backend/proto/chat.protoandstreaming-backend/src/grpc/chat_service.rs) belong to the backend catalog page. - Legacy
streaming/frontend — the olderstreaming/src/components/tree contains an earlier chat implementation and styling (chat-styles.ts); this page documents the currentstreaming-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:
ChatHeader— renders the active peer’s identity.MessageList— renders the scrollable history and triggers “load more” when the user scrolls to the top.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)beforePOST /messages, and every received payload is passed throughdecryptMessage(payload, peerId)before it is rendered. The backend only ever sees ciphertext, and the UI re-broadcasts the encrypted payload on the localnew_chat_messageevent 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', ...)).ChatWindowsubscribes with a nativeaddEventListener, filters by peer identity, de-duplicates by messageid, 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:
idis the server-assigned identity and the de-duplication key for real-time events (prev.some((m) => m.id === msg.id)).textis 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.isSelfmarks messages sent from this client, used by the UI to align them (e.g., right-aligned bubbles).sender_id/receiver_idcome from the backend envelope and are used to determine whether an incoming real-time event belongs to the active conversation.peer_id/peer_nameare 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:
- The
isCancelledflag 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. - Decryption is per-peer —
decryptMessage(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
loadingMoreguard makes the function idempotent under rapid scroll-triggered calls; thefinallyblock guarantees the flag is released even if the request rejects. nextCursorbeingnull(or falsy) signals “no more history”, so the guard short-circuits andMessageListstops 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:
- Composer —
ChatFooter.handleSubmittrims the input, clears it, and awaitsonSendMessage(text). The button stays disabled until the promise resolves. - Encrypt —
ChatWindow.sendMessagecallsencryptMessage(text, String(activePeer.id)). The wire payload is ciphertext from this point on. - Persist —
apiService.post<MessageItem>('/messages', { receiver_id, payload })sends the message. The backend returns a server-assignedid, which is the authoritative identity. - Optimistic render — on a confirmed
id, the message enters local state as plaintext withisSelf: trueandpeer_id/peer_namefilled, thenonNewMessageSentnotifies the parent (e.g., to update a sidebar preview). - Broadcast — the same message is re-dispatched on
windowasnew_chat_messagewith the encrypted text, so any other chat surface can decrypt it with its own peer context. - 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, theprev.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;nullmeans no more history.activePeer(PublicUser | null, optional): The conversation partner.PublicUseris exported fromNewChatModal. Whennull/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 openNewChatModal.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): Whentrue, the submit handler short-circuits and the send button shows aLoader2spinner (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).
ChatFooterignores submits when!input.trim() || loadingand disables the send button whileloadingis true, so a user cannot fire two sends from one keystroke burst.ChatWindow.sendMessageindependently guards withif (!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
isCancelledflag set by the effect cleanup. If the user switches conversations or unmounts the window whilePromise.alldecryption 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
activePeerchanges (it is in the dependency array), so a message arriving for the previous peer after a switch is filtered out by thepeer_id/sender_id/receiver_idmatch 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.
loadMoreMessagesshort-circuits whennextCursorisnull(no more pages) and whenloadingMoreis already true. Thefinallyblock 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.postrejects,loadingis still reset byfinally, the composer re-enables, and the input text was already cleared byChatFooterbefore the await. The message is not appended because theif (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 viaonNewMessageSent, which is only called on success).
Encryption/decryption failure.
- Any
decryptMessagerejection insidePromise.allrejects 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
activePeernull, 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 = nullterminates pagination naturally;loadingMoreprevents duplicate fetches from scroll thrash. - Event-driven fan-out, no websocket in the component —
ChatWindowhas zero transport coupling: real-time delivery is awindowevent. 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 children —
ChatHeader/MessageList/ChatFooterare presentational; all state re-renders originate inChatWindow, giving a single source of truth and predictable re-render scope.
Extension Points
- Real-time transport — any producer can dispatch
new_chat_messagewith aMessageItemdetail;ChatWindowwill decrypt and render it if it matches the active peer. Adapters for websockets, SSE, orBroadcastChannel(cross-tab) can be added without modifying the chat UI. onNewMessageSentcallback — parents can hook confirmed sends to update sidebar previews, unread counts, or optimistic lists.onOpenNewChatcallback — the empty state’s “Start New Chat” action is fully pluggable; it is wired toNewChatModalin the app, but any modal or navigation handler can be injected.PublicUsercontract —NewChatModalexports thePublicUsertype that defines what a selectable peer looks like; extending peer metadata flows through this type.- Peer-scoped crypto keys —
encryptMessage/decryptMessagetakepeerIdas the key selector, so per-peer key material (e.g., derived keys per conversation) can be layered into@/lib/crypto/e2eewithout 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.
Related Links
- ChatWindow.tsx — the container component and all chat state logic
- ChatFooter.tsx — the composer (input, emoji, send)
- ChatHeader.tsx — active-peer header surface (rendered by
ChatWindow) - MessageList.tsx — history list and load-more trigger (rendered by
ChatWindow) - NewChatModal.tsx — exports
PublicUser; the “Start New Chat” entry point - GroupChatWindow.tsx — the sibling group-chat surface, documented on its own catalog page
- Backend protocol — chat.proto and chat_service.rs describe the server-side chat contract (backend catalog page)
- Legacy frontend — chat-styles.ts belongs to the older
streaming/implementation