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

```mermaid
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.

```mermaid
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.

```mermaid
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.

```mermaid
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".
