Chat
Channels, DMs, voice, reactions, and read receipts, over a shared Socket.IO gateway.
Channel types#
ChatChannel.type is one of ORGANIZATION (persistent, organized into categories, e.g. #general), REPOSITORY (exactly one per repository, created lazily on first access), DIRECT, or GROUP. Direct channels are deduplicated by a canonical dmKey (the two participants' user IDs, sorted and joined) so re-opening a DM with the same person always resolves to the same channel rather than creating a duplicate.
Transport#
Real-time delivery runs over a Socket.IO gateway at /api/socket.io, authenticated during the WebSocket handshake using the same khub_access cookie (or a bearer token for non-browser clients) as REST requests — there's no separate chat-specific credential. Clients join per-resource rooms:
| Room | Joined for |
|---|---|
channel:<channelId> | Receiving new messages, edits, deletes, reactions, and read-receipt updates for one conversation. |
repo:<repositoryId> | Live "this repository was updated" notifications (e.g. to refresh a file browser after a push), independent of that repo's chat channel. |
voice:<channelId> | Presence and WebRTC signaling relay for a voice channel or an ad-hoc audio/video call — the server only relays signaling payloads between participants' socket IDs; media itself is peer-to-peer. |
Messages#
Beyond plain text (Markdown-free by design, to keep chat fast and low-ceremony), a message can carry:
- A reply reference (
replyToId) — rendered as a quoted snippet of the original linking back to it; replying to a message from another channel is rejected server-side. - Reactions — a
(messageId, userId, emoji)row per reaction, grouped by emoji for display with a count and whether the current viewer has reacted, so toggling a reaction is idempotent from the client's perspective (react/unreact, not add/remove-by-index). @mentions(@username) and inline org/project/repo references, extracted server-side by regex at send time to drive notification fan-out.
Read receipts#
There is no per-message read-receipt table. Instead, each ChatChannelMember carries a single lastReadAt timestamp, updated the moment a member opens the channel. A message is considered "read" by a given member if that member's lastReadAt is at or after the message's createdAt — which is what powers the single-check/double-check indicator on a sent message without needing to fan out a write per recipient per message.
Notification integration#
Posting a message notifies every other channel member who wasn't already notified via an @mention (mentions and general channel-activity notifications are deduplicated, not stacked). Opening a channel doesn't just advance lastReadAt — it also marks any notification pointing at that channel's link as read in the same request, which is what makes the chat and notification-bell unread counts converge the instant you actually look at the conversation rather than requiring two separate "mark read" actions. See Notifications.
