Chat & Messaging

Real-time text messaging with channels, direct messages, and typing indicators.

8 min read

Channels

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:

POST /api/channels
Create a new group channel. Requires name in the request body.
GET /api/channels
List all channels the authenticated user is a member of, including unread counts.

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:

  1. The client emits a chat:message event with the target channel_id and content string.
  2. 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.
  3. 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_at timestamp.
  4. 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).

GET /api/channels/:id/messages
Returns a page of messages for the specified channel in reverse chronological order.
Query: 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.

POST /api/channels/dm
Find or create a DM channel between the authenticated user and the specified target user.
Body: { "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:

  1. When a user begins typing in the message input, the client emits chat:typing with the channel_id.
  2. The server broadcasts a chat:typing event to the rest of the channel room, excluding the sender.
  3. Recipients display an animated "... is typing" indicator attributed to the sender's callsign.
  4. 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.