12 KiB
Chat Domain
Text messaging, reactions, custom emoji, GIF search, typing indicators, and the user list. All UI is under feature/; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
Module map
chat/
├── application/
│ └── services/
│ ├── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
│ └── link-metadata.service.ts Link preview metadata fetching
│
├── domain/
│ └── rules/
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ ├── message-integrity.rules.ts headHash, inventory refresh, revision merge predicates
│ ├── message-revision.builder.rules.ts buildMessageRevision, materializeMessageFromRevision
│ ├── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
│ └── auto-scroll.rules.ts resolveAutoScrollBehavior (instant on channel switch, smooth for live msgs) + isStuckToBottom predicate
│
├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
│ │ ├── chat-messages.component.ts Root component: replies, emoji/GIF picker, reactions, drag-drop
│ │ ├── components/
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
│ │ ├── models/ View models for messages
│ │ └── services/
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
│ │
│ ├── chat-image-proxy-fallback.directive.ts Image proxy fallback for broken URLs
│ ├── klipy-gif-picker/ GIF search/browse picker panel
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
│ └── user-list/ Online user sidebar
│
└── index.ts Barrel exports
Component composition
ChatMessagesComponent is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting emoji/GIF content.
graph TD
Chat[ChatMessagesComponent]
List[MessageListComponent]
Composer[MessageComposerComponent]
Overlays[MessageOverlays]
Item[MessageItemComponent]
GIF[KlipyGifPickerComponent]
Typing[TypingIndicatorComponent]
Users[UserListComponent]
Chat --> List
Chat --> Composer
Chat --> Overlays
Chat --> GIF
List --> Item
Item --> Overlays
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
click Typing "feature/typing-indicator/" "Typing indicator" _blank
click Users "feature/user-list/" "Online user sidebar" _blank
Message lifecycle
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Live room chat also emits a narrow chat_message signaling fallback so peers can receive text while the data channel is unavailable. Editing and deletion are sender-only operations.
sequenceDiagram
participant User
participant Composer as MessageComposer
participant Store as NgRx Store
participant DC as Data Channel
participant Peer as Remote Peer
User->>Composer: Type + send
Composer->>Store: dispatch addMessage
Composer->>DC: broadcastMessage(chat-message)
DC->>Peer: chat-message event
Note over User: Edit
User->>Store: dispatch editMessage
User->>DC: broadcastMessage(edit-message)
Note over User: Delete
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
User->>DC: broadcastMessage(delete-message)
Message integrity
Outgoing creates/edits/deletes also emit signed message-revision events and persist revision audit rows locally. Sync inventories include revision and headHash; merge prefers a verified higher revision over legacy timestamp comparison. See agents-docs/features/message-integrity.md and MessageRevisionService.
Text channel scoping
ChatMessagesComponent renders only the active text channel selected in store/rooms. Legacy messages without an explicit channelId are treated as general for backward compatibility, while new sends and typing events attach the active channelId so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.
If a room has no text channels, the room shell in features/room/chat-room/ renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists.
Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
sequenceDiagram
participant A as Peer A
participant B as Peer B
A->>B: inventory (up to 1000 msg IDs + timestamps)
B->>B: findMissingIds(remote, local)
B->>A: request missing message IDs
A->>B: message payloads (chunked, 200/batch)
findMissingIds compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
GIF integration
KlipyService checks availability on the active server, then proxies search requests through the server API. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer.
graph LR
Picker[KlipyGifPickerComponent]
Klipy[KlipyService]
SD[ServerDirectoryFacade]
API[Server API]
Picker --> Klipy
Klipy --> SD
Klipy --> API
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
click Klipy "application/services/klipy.service.ts" "GIF search via KLIPY API" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
Custom emoji
The chat domain consumes the custom emoji picker from domains/custom-emoji. Message action rows show seven shortcuts ranked by the current user's local usage, followed by an eighth button that opens the full selector for reactions. The composer has a muted emoji-only button beside GIF; hovering it randomizes the displayed Unicode face and selecting an entry inserts that emoji into the draft.
Custom image emoji are stored locally through DatabaseService and sync peer-to-peer with custom-emoji-summary, custom-emoji-request, custom-emoji-full, and custom-emoji-chunk data-channel events. Uploads use the same image types as profile avatars (.webp, .gif, .jpg, .jpeg) and are capped at 1 MB. The composer inserts saved custom emoji as readable inline aliases such as :party:, so they can sit in the middle of text like This is :party: cool; sending rewrites known aliases to stable :emoji[id](name) tokens and proactively pushes the referenced assets to connected peers alongside the outgoing message, edit, or reaction. Rendering resolves stable tokens against synced known assets and shows a sized placeholder image until the asset arrives; deferred markdown placeholders use readable :name: aliases instead of raw tokens. A repair request is still sent if a token is seen without a local asset. Seen remote emoji do not enter the picker automatically; right-click a custom emoji in chat or on a custom emoji reaction and choose Add to emoji library from the context menu. Right-click a saved custom emoji inside the picker to remove it from the local library. The full picker includes search that filters Unicode emoji by common terms and saved custom emoji by name.
Slash commands
The composer renders a Discord-style autocomplete menu when the user types /. Results merge first-party built-in commands with plugin-registered commands (PluginUiRegistryService.slashCommandRecords), filtered by surface (commandSurface input: server exposes global + server commands, direct exposes only global) and by query. Built-in commands live in chat-builtin-slash-commands.rules.ts; each defines fixed text that is sent as a normal chat message through the composer's messageSubmitted output. The default built-in is /lenny, which posts ( ͡° ͜ʖ ͡°). Plugin commands run their own run callback instead. Picking a command with no options runs it immediately; a command with options pre-fills /name for argument entry. Slash input is intercepted and never posted verbatim; /text that matches no command falls through as a normal message. See the plugins domain README for the api.commands registration contract.
Domain rules
| Function | Purpose |
|---|---|
canEditMessage(msg, userId) |
Only the sender can edit their own message |
normaliseDeletedMessage(msg) |
Strips content and reactions from deleted messages |
getMessageTimestamp(msg) |
Returns editedAt if present, otherwise timestamp |
getLatestTimestamp(msgs) |
Max timestamp across a batch, used for sync ordering |
chunkArray(items, size) |
Splits arrays into fixed-size chunks for batched transfer |
findMissingIds(remote, local) |
Compares inventories and returns IDs to request |
resolveAutoScrollBehavior(input) |
Decides instant / smooth / none when the message count changes |
isStuckToBottom(distance, threshold?) |
True while the list is close enough to the bottom to keep auto-pinning (default 300px) |
Auto-scroll
Opening a conversation must land on the newest message even though images, link/media embeds, and plugin-rendered content load asynchronously and change the list height after the first render. MessageListComponent keeps a stickToBottom flag (set on channel switch and whenever the user scrolls within STICKY_BOTTOM_THRESHOLD of the bottom) and observes the rendered message content with a ResizeObserver. While stuck, every content height change re-pins to the bottom — with no arbitrary timeout — so late-loading content can never leave the user mid-scroll. The flag clears as soon as the user scrolls up to read history, at which point a New messages indicator is shown instead. resolveAutoScrollBehavior chooses an instant jump during the post-switch settle window (and for the user's own sends) and a smooth animation for live messages afterwards.
Typing indicator
TypingIndicatorComponent listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit isTyping: false event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".