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, 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.
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.
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. 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