# Messaging > **Area:** messaging > **Status:** Active > **Last updated:** 2026-05-25 ## Overview Messaging in Toju covers two transports that share a single domain model. Server-channel chat is broadcast by the signaling server over WebSocket — fire-and-forget, no server-side persistence today. Direct messages (1:1 and group DMs) are peer-to-peer over the WebRTC chat data channel, with a signaling-server fallback when no data channel is open and an offline queue for when neither path is available. On both transports the client maintains a **monotonic delivery state machine** per message and a **chunked inventory-sync protocol** that lets two peers reconcile missing history without flooding the link. This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage decisions. The product-client domain READMEs at `toju-app/src/app/domains/chat/README.md` and `toju-app/src/app/domains/direct-message/README.md` cover internal NgRx state and effects; the wire shapes live in [websocket-envelopes](./websocket-envelopes.md). ## Responsibilities - Send, edit, and delete server-channel chat messages over WebSocket (`chat_message`, `edit_message`, `delete_message`). - Send, edit, and delete direct messages over the WebRTC data channel with signaling fallback. - Carry typing indicators on server-channel chat (`user_typing`). - Reconcile peer history via the inventory-sync protocol (chunked, capped backfill). - Drive a monotonic delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`. - Persist direct messages locally; the server keeps no message store. This area does **not** own: - Attachment payloads or the chunked file-transfer protocol → [attachments](./attachments.md). - RTC negotiation that brings the data channel up → [voice-signaling](./voice-signaling.md). - Permission to send a message (`writeMessages`, `manageMessages`, channel overrides, slowmode hint) → [access-control](./access-control.md). - The wire shape of every envelope used here → [websocket-envelopes](./websocket-envelopes.md). ## Key concepts - **Server-channel message** — broadcast through the signaling server to every connected peer of a server. No server-side persistence in the current build. - **Direct message** — point-to-point or group P2P message, persisted locally per-user via Electron CQRS (and via `localStorage` on browser fallback today — TODO confirm). - **Conversation** — 1:1 or group DM thread. Group DMs can be created by adding a third participant to a 1:1, which spawns a new conversation while preserving the original. - **Inventory event** — peer-to-peer announcement of "here is what I have for this conversation/channel"; the receiver replies with a request for missing pieces. - **Sync batch** — chunked response carrying up to 200 messages per envelope, capped at 1000 messages of backfill per inventory exchange. - **Delivery state** — monotonic enum on a direct message: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Defined in the chat-events shared kernel; advanced via `advanceDirectMessageStatus`. - **Peer-delivery service** — `PeerDeliveryService` (`toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts`) — the dispatcher that tries the data channel first, then the signaling forward, then the offline queue. --- ## Transports ### Server-channel chat (WebSocket) - Client sends `chat_message` to the signaling server; `handleChatMessage` (`server/src/websocket/handler.ts:274`) validates the user is in the target server and broadcasts to every other connection. - `edit_message` and `delete_message` follow the same fan-out path. - `user_typing` (`handleTyping`, `handler.ts:309`) is broadcast as a transient signal — no persistence, no sync, no delivery state. - The server **does not persist** these envelopes. Late joiners do not see chat history older than their join — they can request it from peers via the inventory protocol if any peer present has it stored locally. ### Direct messages (WebRTC data channel) - DMs ride the `chat`-labelled data channel established alongside each voice peer connection (see [voice-signaling](./voice-signaling.md)). - `PeerDeliveryService` is the dispatcher. For each outgoing event it: 1. Tries every open data channel to a `recipients`-listed peer. 2. If no data channel is available to a recipient, falls back to a signaling-server `forwardPeerMessage` envelope so the server forwards it to that peer's connection. 3. If neither path is open, enqueues the event in `OfflineMessageQueueService` and replays on `peerConnected$` / `networkRestored$`. - The server **forwards** DM envelopes opaquely — no inspection, no persistence. ### Storage - **Direct messages** persist via Electron CQRS — see [ipc-bridge](./ipc-bridge.md). Each user has their own local TypeORM database; messages are written via `save-direct-message` / equivalent commands. - **Server-channel chat** persists via `DatabaseService` (Electron CQRS) when running on desktop, or in IndexedDB when running purely in browser. TODO: confirm the IndexedDB code path. - The signaling server holds **zero** message bytes at rest today. Re-deploys lose nothing because there is nothing to lose. --- ## Inventory / sync protocol Both transports share the same inventory shape, defined in `toju-app/src/app/shared-kernel/chat-events.ts`: - `ChatInventoryEvent` — sender broadcasts "for conversation X I have messages with these ids and last-modified timestamps" (capped at 1000 entries). - `ChatSyncBatchEvent` — receiver replies with a chunked batch of full message payloads, **up to 200 per envelope**, repeated until the requested set is satisfied or 1000 messages have been returned. Rules: - Inventory is **additive** — `mergeIncomingMessage` / `upsertDirectMessage` only insert or update; a sparser peer never wipes a richer peer's history. - Reactions and attachment-link changes are reconciled by comparing per-message `lastModifiedAt`; the higher wins. - The 1000-message ceiling is per inventory exchange, not per conversation lifetime; an older history can be filled in piecewise across multiple inventory cycles. The same protocol is reused for the chat domain (server channels) and the direct-message domain. The implementation lives in `toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts` and is invoked from the direct-message effects via `DirectMessageService.requestSync()`. --- ## Delivery state machine For DMs, every outgoing message has a `status`: | Value | Numeric | Meaning | |-------|---------|---------| | `QUEUED` | 0 | Composed locally; no transport attempt has succeeded yet. | | `SENT` | 1 | At least one transport (data channel or signaling forward) has accepted the payload. | | `DELIVERED` | 2 | At least one recipient has acknowledged receipt at the application layer. | | `ACKNOWLEDGED` | 3 | The full recipient set has acknowledged (1:1: the one recipient; group: every participant). | Transitions are advanced via `advanceDirectMessageStatus`, which **only advances** — a higher value is never replaced by a lower one. A retried message that succeeds after a queue replay can therefore move `QUEUED → SENT` but never `DELIVERED → SENT`. Server-channel messages do not carry an application-level delivery state today (the server broadcast is fire-and-forget); the UI treats them as `SENT` once the WebSocket accepts the frame. --- ## Edit and delete - `edit-message` / `delete-message` events carry the original `messageId`. On both transports, the receiver locates the existing row (by id) and applies the mutation via `applyMutation`. - DMs use the same envelope types but ride the data channel / signaling-forward fabric. - Edits are last-writer-wins by `editedAt`. A delete removes the message body but keeps a tombstone with `deletedAt` so peers that haven't yet seen the delete can converge on the next inventory sync. TODO: `applyMutation` does not currently verify the mutation originated from the original author. A non-cooperating client could send `edit-message` for someone else's message and a receiver would accept it. Confirm and either harden client-side or document the trust model. --- ## Typing indicators - Server-channel only. DMs do not have a typing indicator today. - Sent as `user_typing`; broadcast to a server scope. - Transient: no persistence, no sync replay, no delivery state. --- ## Business rules and invariants - The signaling server is **not authoritative** for messaging. `handleChatMessage` only broadcasts; there is no server-side message log. - The delivery state machine is **monotonic** — `advanceDirectMessageStatus` never moves status backwards. - DM envelopes are **ignored** unless the local user appears in `participants` / `recipients` (or has an existing local conversation matching the id). - Inventory merges are **additive** — `mergeIncomingMessage` / `upsertDirectMessage` never delete or downgrade a richer local row. - A 1:1 → group upgrade **preserves** the original 1:1 history; the group is a new conversation. - Edits / deletes are reconciled by `lastModifiedAt` / `editedAt` / `deletedAt`. --- ## Technical implementation ### Server - WS handlers — `server/src/websocket/handler.ts`: `handleChatMessage` (line 274), `handleTyping` (309); DM forwarding via `forwardPeerMessage` / `forwardRtcMessage`. - No CQRS, no entities, no migrations: server messaging is broadcast-only. ### Product client - Chat domain — `toju-app/src/app/domains/chat/`: services, effects, sync rules at `domain/rules/message-sync.rules.ts`. - Direct-message domain — `toju-app/src/app/domains/direct-message/`: `DirectMessageService`, `PeerDeliveryService` (`application/services/peer-delivery.service.ts`), offline queue. - Shared kernel — `toju-app/src/app/shared-kernel/chat-events.ts` (`ChatInventoryEvent`, `ChatSyncBatchEvent`, `chat_message`, `edit-message`, `delete-message`, `direct-message-sync`, `direct-message-sync-request`). - Persistence — Electron CQRS commands for DMs (see [ipc-bridge](./ipc-bridge.md)); `DatabaseService` for server-channel chat. ### Electron - DM persistence — TypeORM entity (likely `MessageEntity` and a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface. --- ## Testing - TODO: chat domain has zero `*.spec.ts` files at time of writing. - TODO: no dedicated server-side spec for `handleChatMessage`, `handleTyping`, or `forwardRtcMessage`. - TODO: confirm specs for `PeerDeliveryService` and `OfflineMessageQueueService`. - E2E: `e2e/tests/chat/chat-message-features.spec.ts` covers happy-path chat and attachment sync between users. --- ## Performance considerations - Inventory batch cap: **200 messages per envelope**. - Inventory backfill cap: **1000 messages per inventory exchange**. - `chat_message` broadcast is O(N) over connected peers of the server; no fan-out batching. - DMs incur O(recipients) data-channel writes (or signaling forwards) per send; large group DMs amplify per-message cost linearly. --- ## Security considerations - **No end-to-end encryption.** P2P traffic over the data channel is DTLS-encrypted by WebRTC; signaling-forwarded fallback is plain WebSocket; either way the local TypeORM database stores plaintext. - **`applyMutation` does not verify authorship** on incoming `edit-message` / `delete-message` events. TODO above. - **No server-side rate limiting** on `chat_message`. A non-cooperating client can flood a server's broadcast. --- ## Known issues and limitations - **No server-side chat history.** Late joiners depend on peers having local history to replay via inventory sync. - **No spec coverage** for the chat domain. - **DM authorship is not verified** by `applyMutation`. - **No DM typing indicator.** - **`OfflineMessageQueueService` retry policy** is currently driven by `peerConnected$` / `networkRestored$` events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects. --- ## Related features - **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope here. - **[attachments](./attachments.md)** — file payloads ride alongside chat events on the data channel. - **[voice-signaling](./voice-signaling.md)** — establishes the data channel DMs ride on. - **[ipc-bridge](./ipc-bridge.md)** — exposes the CQRS persistence DMs and server chat use. - **[access-control](./access-control.md)** — gates write permissions and slowmode. ## Changelog | Date | Change | |------|--------| | 2026-05-25 | Initial documentation |