Skip to content

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):

SessionHub
/ws/chats/:id — room-scoped
ClientHub
/ws/client — per-user, all devices
SessionHub carries live messages/reactions/typing inside one open room. ClientHub carries chat-list previews, unread counts, and notifications to every device the user has, whether or not a room is open.
  • SessionHub (/ws/chats/:id): the room socket. ChatHandler.HandleWebSocket upgrades after CheckParticipation, then pumps inbound frames. Frame types: send_message, ping, mark_read, add_reaction, edit_message, delete_message. Server broadcasts back new_message, message_updated, message_deleted, reaction_update, mark_read, user_joined, user_left, pong, error, messages_expired.
  • ClientHub (/ws/client): the user bus. ChatService pushes chat:new_message and chat:mark_read here (via the notification service's EmitClient) 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 for chat.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_expiry cron (@every 30s) -> ChatService.CleanupExpiredMessages hard-deletes messages past expires_at, then PublishExpiredMessages fans a batched messages_expired payload per room over pub/sub so open clients drop them live.
  • Event-chat retention is driven by the event domain: EventLifecycleService calls MessagePurger.PurgeMessagesForEvent(s) during orphan cleanup and archive (the purge cron, @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