Chat & Messaging
Real-time text messaging with channels, direct messages, and typing indicators.
8 min readChannels
GroundWave organizes all messaging into channels. Every message belongs to exactly one channel. Three channel types are supported, each serving a distinct communication pattern:
| Type | Visibility | Description |
|---|---|---|
| Global | All users | The default "General" channel. All connected users are members. Cannot be deleted. |
| Group | Invited members | User-created channels for sub-teams or topic-specific coordination. Membership is managed by the creator. |
| Direct | Two users only | Private 1:1 direct message channel. Created on demand between any two users. |
Channel API
Channels are managed through the REST API:
name in the request body.
When a new channel is created, the server emits a chat:channel_added Socket.IO
event to all connected clients so their channel lists update in real time without requiring
a page reload or polling.
Sending Messages
Messages are sent via Socket.IO rather than REST, keeping latency low and ensuring delivery to all channel members through the server's room-based broadcast mechanism.
The send flow:
-
The client emits a
chat:messageevent with the targetchannel_idandcontentstring. - The server validates the payload, confirms the sender is a member of the channel, and writes the message to PostgreSQL with a server-assigned timestamp.
-
The server broadcasts the persisted message object to all members of the channel's
Socket.IO room. The broadcast payload includes the sender's callsign, message ID,
channel ID, content, and
created_attimestamp. - All clients in the room receive the broadcast and append the message to their local chat view.
// Socket.IO event: chat:message (client → server)
{
"channel_id": "a1b2c3d4-...",
"content": "Moving to grid 4412, ETA 10 mikes."
}
All messages are persisted to PostgreSQL before being broadcast. If a client reconnects after a brief disconnect, it can retrieve any missed messages via the message history API using a cursor from the last received message.
Message History
The chat UI loads historical messages when a channel is opened and supports infinite scroll to retrieve older messages on demand. History is fetched via the REST API using cursor-based pagination, which is stable under concurrent inserts (unlike offset-based pagination).
limit=N (default: 50) | before=<message_id> — cursor for the next page
When the user scrolls to the top of the message list, the client issues another request
with before set to the ID of the oldest currently displayed message.
This retrieves the next batch of older messages without duplicates or gaps.
Direct Messages
Direct messages (DMs) are private 1:1 conversations between two users. A DM channel is created on demand — if a channel already exists between the two users, the existing channel is returned rather than creating a duplicate.
{ "target_user_id": "..." }
When a DM channel is created for the first time, the server emits a
chat:channel_added event specifically to the recipient's Socket.IO connection,
so the new conversation appears in their channel list immediately — even if they are not
the initiating user.
Typing Indicators
GroundWave provides real-time typing indicators so users know when a teammate is composing a message. The implementation uses a lightweight Socket.IO event rather than REST to keep latency below perceptible thresholds.
Flow:
- When a user begins typing in the message input, the client emits
chat:typingwith thechannel_id. - The server broadcasts a
chat:typingevent to the rest of the channel room, excluding the sender. - Recipients display an animated "... is typing" indicator attributed to the sender's callsign.
- The indicator automatically clears after a short inactivity timeout (default: 3 seconds) if no further typing events are received.
// Socket.IO event: chat:typing (client → server)
{
"channel_id": "a1b2c3d4-..."
}
// Socket.IO event: chat:typing (server → room, excluding sender)
{
"channel_id": "a1b2c3d4-...",
"callsign": "ALPHA-1"
}
Unread Badges
The slide-out chat panel displays an unread message count badge on each channel tab that has received new messages since the user last viewed it. Badges update in real time as new messages arrive via Socket.IO, including messages in channels the user is not currently viewing.
When the user switches to a channel, its unread count resets to zero. The count is tracked client-side in component state and is not persisted to the server, so it resets on page reload.
Offline Queueing
GroundWave's offline resilience layer ensures that messages composed during a connectivity interruption are not lost. When the client is disconnected from the server (Socket.IO transport down), outgoing messages are written to a persistent operation queue in IndexedDB rather than being dropped.
Queue behavior:
- Messages are queued in IndexedDB in FIFO order with their target channel ID and content.
- The UI displays the queued message locally as "pending" to give the user immediate visual feedback.
- When the Socket.IO connection is restored, the client automatically replays the queue in order, sending each pending message to the server.
- An amber status banner in the UI shows the current offline state and the number of queued operations waiting for replay.
The offline queue also handles marker CRUD operations, not just chat messages. See Offline-First Design for a complete description of the cache and queue architecture.