Chat¶
Purpose¶
The chat domain powers every messaging surface: per-event group chats (one room auto-created per event), 1:1 direct messages, freeform group chats, and the system messages that announce member joins / leaves / setting changes. Service owns persistence (PostgreSQL via sqlc/pgx), the Redis cache + pub/sub layer, WebSocket broadcast (via a BroadcastFunc callback so it doesn't import the hub), reactions, edit/delete, search, and the disappearing-messages feature.
Package chat (backend/internal/services/chat/) also holds two thin siblings: MessagePurger (message_purger.go), the deletion helper the event domain uses to clear an event chat's messages, and ClientHubHandler (client_hub_handler.go), which upgrades the user-targeted /ws/client socket.
Two WebSocket hubs¶
Chat sits across both realtime buses (see Realtime):
/ws/chats/:id — room-scoped
/ws/client — per-user, all devices
- SessionHub (
/ws/chats/:id): the room socket.ChatHandler.HandleWebSocketupgrades afterCheckParticipation, then pumps inbound frames. Frame types:send_message,ping,mark_read,add_reaction,edit_message,delete_message. Server broadcasts backnew_message,message_updated,message_deleted,reaction_update,mark_read,user_joined,user_left,pong,error,messages_expired. - ClientHub (
/ws/client): the user bus.ChatServicepusheschat:new_messageandchat:mark_readhere (via the notification service'sEmitClient) so previews and badges stay live without the room socket open.
Both are mounted in backend/internal/wiring/router.go under the /ws group with middleware.JWTAuth.
HTTP endpoints¶
All under /api/v1/chats, JWT-verified. Room id and event id are interchangeable where a room lookup is involved: CheckParticipation resolves both.
| Method | Path | Description |
|---|---|---|
| GET | /chats |
List the caller's rooms with last-message preview. Optional limit (max 50) + offset; limit=0 returns all. Response {rooms, has_more}. |
| GET | /chats/unread_total |
{unread_total} across all non-muted rooms. Drives the nav chat badge. |
| GET | /chats/search |
Global message search across the caller's rooms. q (min 2 chars), cursor, messages_per_room (default 3). |
| GET | /chats/:id |
Single room metadata. |
| GET | /chats/dm/:userId |
Get-or-create the 1:1 room with userId (friends only). |
| POST | /chats/group |
Create a group chat from {name, member_ids}. 201. |
| GET | /chats/:id/settings |
Settings + participants (nicknames, mute, disappearing config). |
| PATCH | /chats/:id |
Update name and/or disappearing_messages; notable changes broadcast a system message. |
| POST | /chats/:id/members |
Add a member; broadcasts a system message. |
| DELETE | /chats/:id/members/:userId |
Leave the group (self only); broadcasts a system message. |
| PATCH | /chats/:id/nickname |
Set/clear the caller's own nickname in the room. |
| PATCH | /chats/:id/mute |
Mute/unmute the room for the caller. |
| PUT | /chats/:id/avatar |
Set the group avatar to a freshly-uploaded key; old avatar enqueued for async cleanup; broadcasts a system message. |
| GET | /chats/:id/messages |
Cursor-paginated history. limit (max 100, default 50), cursor, direction (next older / prev newer). |
| GET | /chats/:id/messages/search |
Full-text search within the room. q (min 2), cursor, limit (default 20). |
| POST | /chats/:id/messages |
HTTP send (mirror of the WS send_message, used for forwarding). Broadcasts new_message. |
| POST | /chats/:id/read |
Mark the room read; broadcasts mark_read. |
| POST | /chats/:id/messages/:messageId/react |
Toggle an emoji reaction; broadcasts reaction_update. |
Chat image attachments are uploaded through the media domain (POST /api/v1/uploads/chat-image and .../chat-image/batch), not through a chat route; the resulting S3 keys ride the send_message payload as image_keys.
Key types¶
// backend/internal/services/chat/service.go
func NewService(pool *pgxpool.Pool, redisService cache.Cache, userRepo user.UserStore,
friendRepo friend.FriendStore, assets assets.Service, notifier notify.Notifier) *Service
// Injected after the hub is up (avoids an import cycle).
type BroadcastFunc func(chatID uuid.UUID, msgType string, data interface{})
// Canonical send input; SendMessage / SendMessageWithImages build one of these.
type SendMessageInput struct {
UserID, ChatID uuid.UUID
Content string
ReplyToID string
ReplyToImageIndex *int
ImageKeys []string // 1..MaxChatImagesPerMessage (10), each under chat-images/
Mentions []models.Mention
TagNotice *models.TagNotice
}
ChatResponse, ChatUser, and LastMessageInfo are the room DTOs (image-bearing previews live in Metadata.Images).
Data model¶
| Table | Notes |
|---|---|
chats |
type (direct / group), event_id (set for event rooms), disappearing_messages + disappearing_since (1:1 only), read_receipts_enabled (DEFAULT false, matching user_profiles.chat_pref_read_receipts), last-message denorm columns (last_message_content, last_message_sender_id, last_message_at, last_message_metadata), asset_key (group avatar). |
chat_participants |
Unique (chat_id, user_id), nickname, is_muted, last_read_at, unread_count (denormalized badge). |
chat_messages |
chat_id, user_id (nullable for anonymised users), content, reply_to_id, reply_to_image_index, edited_at, deleted_at, expires_at (indexed), metadata (JSONB envelope). |
message_reactions |
(message_id, user_id, emoji) uniqueness. |
Dependencies¶
cache.Cache— per-room message cache, pub/sub channel, per-room online presence.assets.Service— group avatars and chat-image key validation.user.UserStore/friend.FriendStore— sender lookup and the friends-only gate on DM creation.notify.Notifier— the ClientHub emitter and push fan-out forchat.message/chat.mention/chat.reaction.
Notable behavior¶
Three room flavours, one table
Event chats, 1:1 DMs, and freeform groups all live in chats. Event rooms carry event_id; DMs are type='direct'; groups are type='group' with no event_id. A WS subscriber may pass either an event id or a room UUID; CheckParticipation handles both.
Chat-list avatars
GET /chats builds each room's avatar in the list. Plain groups return a participants mosaic that includes the caller (a group always has the viewer plus at least one other, so a host-only room is never blank). Event rooms skip the mosaic: avatar_url falls back to the event cover when the room has no custom avatar, and event_category is returned so the client renders a generative category cover (or a generic event glyph) when there's no cover at all. A room's own uploaded asset_key always wins over the event cover.
Forward-only disappearing messages
Toggling disappearing_messages (off / seen / 24h / 7d) only affects future messages. disappearing_since records when it was last enabled. calcExpiresAt(createdAt, setting) stamps expires_at for the 24h and 7d settings at send time; off and seen stamp nothing. Historic messages without an expires_at are never expired retroactively.
Two deletion paths
message_expirycron (@every 30s) ->ChatService.CleanupExpiredMessageshard-deletes messages pastexpires_at, thenPublishExpiredMessagesfans a batchedmessages_expiredpayload per room over pub/sub so open clients drop them live.- Event-chat retention is driven by the event domain:
EventLifecycleServicecallsMessagePurger.PurgeMessagesForEvent(s)during orphan cleanup and archive (thepurgecron,@every 24h). Friend and group DM rooms are never purged this way; their messages live until user deletion.
The last-message denorm has to be repaired with the row
chats.last_message_content / _sender_id / _at / _metadata are what the chat list renders, so a message that vanishes from chat_messages but stays in the denorm keeps rendering (and last_message_metadata keeps naming S3 keys the same sweep deleted). Both deletion paths call repairChatLastMessage, which repoints the room at the newest surviving message or clears the columns when none remain. The expiry sweep is batched across rooms, so it repairs once per affected room, and only when the room's last_message_at is at or before its newest expired message — a later system message must survive the sweep untouched.
Read receipts are opt-in, and the client gate is not yet enforced server-side
chats.read_receipts_enabled defaults to false per room and ChatWindow only calls markRead when the user's read_receipts_enabled pref is exactly true. The suppression lives entirely on the client: MarkRead still records last_read_at for any caller, and the room payload builders still surface other_last_read_at, so a direct API call reveals read state regardless of the setting. Enforcing it in MarkRead and gating otherLastReadAt is outstanding.
Everything a message id reaches must be re-scoped to the room
A message id is a bearer token that anyone who was ever in the room keeps. ToggleReaction and the reply_to_id resolution in sendMessage both re-check msg.ChatID against the room the caller was authorized against, because both copy or attach to a message the caller named. Reply is the sharper case: the parent's content and author name are projected into ReplyToInfo, persisted, and broadcast, so an unscoped quote would republish another room's message into this one. EditMessage and DeleteMessage gate on sender identity instead, which is strictly narrower. Attached image keys go through validateImageKey, which rejects a key nested under a different room.
Activity status gates presence, mute is caller-only
is_online and last_active_at are surfaced on GET /chats, GET /chats/{id}, and GET /chats/{id}/settings only when the subject user's chat_pref_activity_status is on, matching Friends and Discovery. The subject's own preference governs, never the viewer's. The column defaults to false, so a user who has never touched the toggle reads as offline to others. The one exception is the caller's own row in the settings participant list: a member always sees their own online and last-active state regardless of the toggle. ChatParticipantInfo.is_muted is populated only for the calling user: whether another member muted the room is their business, not the room's.
Anonymised senders survive user deletion
When a user is hard-deleted (see User), user_id on their messages is set NULL so other members can still read the thread. The frontend renders a null sender as "Deleted user".
MessageMetadata envelope
chat_messages.metadata and the denormalised chats.last_message_metadata are JSONB envelopes for typed per-message data. Sibling fields today: mentions, tag_notice, images, reply_moment. reply_moment is set when a message replies to a moment rather than to a chat message: it carries a self-contained moment ref (id, author_name, caption, asset_url) so reply_to_id stays null and the reply preview renders without a message parent. On send, ReplyToMoment on the send_message payload persists here and projects a ReplyToInfo of type moment; history loads re-project it via resolveReplyToInfo (moment ref wins over the parent-message projection). The persisted shape carries only server-controlled values (e.g. images.keys, immutable S3 keys under chat-images/<chat_id>/); the wire shape (MessageMetadataResponse, composed by HydrateResponse) adds derived fields like images.urls (via storage.PublicURL) and images.count. The frontend uses images.keys[i] as the stable image cacheKey so cache entries survive base-url / CDN changes.
Mention rewriting
rewriteMentions scans content for @handle tokens and /u/<handle> URLs (https://, tomoda://, and bare-path forms), resolves each to a user id, and rewrites content to @@u:<slot>@@ placeholders. Resolved ids land in metadata.mentions; each carries a kind (user for @-handles, user_url for URLs) plus, for user_url, the URL variant and host url_prefix so the frontend rebuilds the link with the live handle. Composer-typeahead picks ride alongside on the send_message payload and merge with anything the rewriter finds. Embed payloads ([share:...], [image:...], [klipy:...]) inline in content skip the rewriter.
Tag-notice DMs¶
SendTagNotice(ctx, fromID, toID, kind, targetID, shareURL) get-or-creates the 1:1 room, sends a message whose content is the share URL and whose metadata.tag_notice = {kind, target_id}. The FE chat row detects tag_notice, drops the bubble background, and renders a small "Tagged you in <kind>" label above a link-preview card (display-only for the checkin kind). One DM per tagged user, fired from each tagging path (moment, stamp, check-in). Best-effort: a chat-write hiccup never fails the publish. Service implements the TagDMSender port; tagging domains depend on that port, not on *Service directly.
Where to look¶
backend/internal/services/chat/service.go— send path, mention rewriter, disappearing-message expiry, searchbackend/internal/services/chat/handler.go— SessionHub upgrade + REST handlersbackend/internal/services/chat/client_hub_handler.go—/ws/clientupgradebackend/internal/services/chat/message_purger.go— event-chat retention deletionbackend/internal/models/chat.go,chat_message.gobackend/internal/platform/ws/— SessionHub, ClientHub, broadcast helpersbackend/internal/async/handlers/cron.go—HandleMessageExpiryfrontend/services/chatService.ts