# Chat (hub & item threads)

Source: https://docs.externa.qiick.io/docs/chat

Externa’s **team chat** is separate from [AI chat](/docs/ai-chat). One `chats` model backs **collection item threads** and **direct (private) threads**. Operators use a dedicated hub at `/chat`; editors also open the same thread from an item form drawer.

<!-- screenshot omitted -->

> **Related**
> Realtime: [Reverb & Echo](/docs/reverb-and-echo). Unread vs bell: [Activity & notifications](/docs/activity-and-notifications). Privacy / plaintext bodies: [Threat model & hosting](/docs/threat-model). Upload cap: project setting `chat_max_upload_bytes` on `/settings/project` (null = unlimited aside from PHP). Permissions: [Effective permissions](/docs/permissions).

> `/ai` and the FAB talk to `AppAssistant` over **SSE**. Team chat uses **JSON + Echo presence** on channel `chat.{uuid}`. Do not confuse the two.

## Product surfaces

| Surface         | Route / UI                                                                    | Who                                                          |
| --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **Chat hub**    | Inertia `GET /chat`, `GET /chat/{uuid}` — `resources/js/pages/chat/index.tsx` | Users with `can-show-chat`                                   |
| **Item drawer** | Chat panel on collection item form (`item-chat-drawer.tsx`)                   | Users who can **read** that collection item (collection ACL) |
| **Sidebar**     | Chat nav entry + unread badge from Inertia `page.props.chat`                  | `can-show-chat`                                              |

Both surfaces talk to the same message/attachment APIs. Item routes under `collections/.../chat` are **aliases** that resolve/create the item thread, then delegate to `ChatMessageController` / `ChatHubController`.

## Data model

| Table / model                                       | Role                                                                                                                                                              |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chats` (`App\Models\Chat`)                         | UUID PK. `kind`: `item` \| `direct`. Item chats link `collection_id` + `collection_item_id`. Soft deletes.                                                        |
| `chat_participants`                                 | Direct threads: users and/or groups. Optional per-user `archived_at` (hide from inbox). Item threads do not require an explicit participant row for every reader. |
| `chat_messages` (`CollectionItemChatMessage`)       | Body, mentions JSON, optional `reply_to_id`, pin timestamp, soft deletes.                                                                                         |
| `chat_attachments` (`CollectionItemChatAttachment`) | Files on a message (or pending until message create). Optional reduced **preview** for images.                                                                    |
| `chat_reactions`                                    | Emoji reactions per user/message.                                                                                                                                 |
| `chat_reads`                                        | Per-user last-read cursor for unread math.                                                                                                                        |

Migrations: create-only chat schema in `database/migrations/2026_08_18_000030_create_chats_tables.php` (and related upload/reads tables). Fresh installs do not need separate “unify” alters.

### Thread kinds

**Item (`kind=item`)**

- One shared thread per collection item (`ChatService::findOrCreateItemChat`).
- Access = collection **item read** ACL (`CollectionPermissionEnforcer` / guard), not only `can-show-chat`.
- Hub lists item threads the user can see; drawer opens from the item editor.

**Direct (`kind=direct`)**

- Private thread among selected users and/or groups (`ChatService::findOrCreateDirectChat`).
- Creator is always included. Deduped by sorted participant set.
- Access = membership (participant sync via `ChatParticipantSync`).
- Creating a DM broadcasts `ChatThreadUpserted` to peers so their hub list updates live.

## Permissions

| Concern                    | Gate                                                                                 |
| -------------------------- | ------------------------------------------------------------------------------------ |
| Hub pages + `/chat/*` JSON | `permission:can-show-chat` (`PermissionEnum::CanShowChat`) on `routes/chat.php`      |
| Item alias routes          | Collection item read (same as opening the item); defined in `routes/collections.php` |
| Inertia shared unread      | Only computed when the user has `can-show-chat` (`HandleInertiaRequests`)            |

A user can participate in an **item** thread via collection ACL without hub access; they will not get the `/chat` shell or sidebar chat entry until `can-show-chat` is granted.

## Hub UI

Controller: `App\Http\Controllers\Chat\ChatPageController`.

| Route              | Name         | Behavior                                              |
| ------------------ | ------------ | ----------------------------------------------------- |
| `GET /chat`        | `chat.index` | Split shell; tabs `collection` \| `private` (`?tab=`) |
| `GET /chat/{chat}` | `chat.show`  | Same shell with selected thread (UUID); marks read    |

Thread list / create / options:

| Route                           | Name                       | Purpose                       |
| ------------------------------- | -------------------------- | ----------------------------- |
| `GET /chat/threads`             | `chat.threads.index`       | Paginated hub threads         |
| `POST /chat/threads`            | `chat.threads.store`       | Start item or direct thread   |
| `GET /chat/unread-count`        | `chat.unread-count`        | Unread totals                 |
| `GET /chat/options/users`       | `chat.options.users`       | DM user picker                |
| `GET /chat/options/groups`      | `chat.options.groups`      | DM group picker               |
| `GET /chat/options/directory`   | `chat.options.directory`   | Combined directory            |
| `GET /chat/options/collections` | `chat.options.collections` | Item-thread collection filter |
| `GET /chat/options/items`       | `chat.options.items`       | Items in a collection         |
| `GET /chat/options/item-picker` | `chat.options.item-picker` | Combined item picker          |

Open thread actions (UUID chat): messages CRUD, pin, react, mentions, notify toggle (item), read / stop-viewing, attachments (single + chunked), save-to-files / add-to-field, participants (DM), **archive / unarchive** (direct only), leave/delete thread.

### Private archive (per-user)

Archive is **not** a global soft-delete of the chat. It hides the thread from **your** private inbox while you remain a participant. Collection/item threads have **no** archive action (API returns `422`).

| Concern          | Detail                                                                                                                                      |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Storage          | `chat_participants.archived_at` for the viewing user                                                                                        |
| Hide list        | Default private hub list excludes archived rows                                                                                             |
| Toggle UI        | Icon-only control next to hub search (private tab): show archived ↔ show inbox (`data-test="chat-filter-archived"`)                         |
| Thread menu      | **Archive** / **Unarchive** on direct threads (`ChatDirectThreadMenu`)                                                                      |
| API              | `POST /chat/{chat}/archive`, `POST /chat/{chat}/unarchive` (`chat.archive` / `chat.unarchive`)                                              |
| List query       | `GET /chat/threads?archived=1` (private tab) lists only archived DMs                                                                        |
| Inbound message  | A new message to an archived DM **clears** `archived_at` for recipients so the thread resurfaces                                            |
| Leave vs archive | **Leave/Delete** removes your participant row (and may soft-delete the chat when no user participants remain). **Archive** keeps membership |

## Messages

Controller: `App\Http\Controllers\Chat\ChatMessageController` (also used by `ItemChatController` aliases).

| Capability      | Detail                                                                                                                         |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Create          | Body (optional if attachments), `@` mentions (item + multi-party DM only; **not** 1:1), optional `reply_to_id`, attachment IDs |
| Edit / delete   | Author only                                                                                                                    |
| Pin             | Toggle `pinned_at`                                                                                                             |
| Reactions       | Toggle emoji; broadcast `ReactionToggled`                                                                                      |
| Pagination      | Cursor / page load older; FE merges without wiping history on soft refresh                                                     |
| Optimistic send | Pending bubbles with local previews + upload progress; retry on failure                                                        |

### Mentions

- Mentions resolved against users (and collection mention syntax where applicable).
- `mentioned_user_ids` stored on the message.
- **Bell notification** (`ItemChatNotification`): recipients = **@mentioned** users **or** users who opted into collection notify (see below). Not every participant on every message.

### Collection “notify me”

- Per-user setting group `item_chat` / key `notify` — list of collection IDs.
- Toggle: `PUT .../chat/notify` (hub or item alias).
- Opt-in → unread hub updates + optional bell “sent a message”; **default campana already mention-first**.

## Attachments

Service: `App\Services\Chat\ChatAttachmentUploadService`.

| Path                            | When                                             |
| ------------------------------- | ------------------------------------------------ |
| Single POST                     | Smaller files                                    |
| Chunked init → chunk → complete | Large files (bypasses single-request PHP limits) |

Limits:

| Setting                          | Scope                                                       |
| -------------------------------- | ----------------------------------------------------------- |
| `project.chat_max_upload_bytes`  | Nullable = unlimited (still capped by PHP / chunk pipeline) |
| `project.files_max_upload_bytes` | File manager pool (separate)                                |

**Types:** chat accepts **any** file type (no mime/extension allowlist or File Manager denylist). AI chat attachments remain a separate, stricter pipeline. **Save to Files** runs through `FileService::uploadFile`, so the File Manager extension denylist applies at that step — [File manager — Upload security](/docs/file-manager#upload-security).

**Images:** server may store `preview_path` / `preview_mime` for list thumbnails; lightbox loads full resolution (with download progress UI).

**UX:**

- Full-thread dropzone (message list + composer), not only the textarea.
- Send-attachments dialog (Telegram-style): media album grid or file list + caption (`@` + emoji).
- Shared collage component `ChatMediaAlbum` for modal, optimistic pending, and sent bubbles (flush tiles, no parallel CSS grids).
- Incoming bubbles with author label use a minimum width so multi-image albums do not collapse.

**Downstream:** save attachment into Files, or add to an item file field (item threads).

## Realtime (Echo)

Requires full stack broadcasting — [Reverb & Echo](/docs/reverb-and-echo).

| Channel                        | Auth                            | Events (broadcastAs)                                                             |
| ------------------------------ | ------------------------------- | -------------------------------------------------------------------------------- |
| Presence `chat.{chatId}`       | `ChatService::assertAccessible` | `MessageCreated`, `MessageUpdated`, `MessageDeleted`, `ReactionToggled`          |
| Private `App.Models.User.{id}` | Same user                       | `ChatUnreadUpdated`, `ThreadUpserted` (new DM for peer), plus bell notifications |

Typing / presence members come from the presence channel authorization payload (`id`, `name`).

Frontend store: Zustand chat modules under `resources/js/stores/chat/`; Echo wiring in the drawer / hub page.

## Unread & sounds

Service: `App\Services\Chat\ChatUnreadService`.

| Kind   | Who gets unread                                                                   |
| ------ | --------------------------------------------------------------------------------- |
| Direct | Other participants (not author)                                                   |
| Item   | Mentions + collection notify subscribers + users who already posted in the thread |

If the user is **actively viewing** that thread (short cache window), unread stays 0 but the payload may still set `play_sound`.

Profile prefs (default off): `sound_chat_enabled`, `sound_notifications_enabled`.

Shared Inertia prop shape (when permitted): `chat.unread_*` for sidebar badge.

## Activity log

On message **create** / **delete**, Spatie activity (`log_name=chat`):

| Event                  | Properties (meta)                                                                 |
| ---------------------- | --------------------------------------------------------------------------------- |
| `chat_message`         | `chat_id`, `body_preview` (≤ 120 chars), attachment/mention counts, `reply_to_id` |
| `chat_message_deleted` | `chat_id`, `message_id`, `body_preview`, `attachment_count`                       |

Bodies are **not** encrypted. Admin UI expands `properties.meta` when Spatie `attribute_changes` is empty — [Activity & notifications](/docs/activity-and-notifications) · [Threat model](/docs/threat-model).

## Source map

| Concern            | Location                                                                       |
| ------------------ | ------------------------------------------------------------------------------ |
| Hub routes         | `routes/chat.php`                                                              |
| Item aliases       | `routes/collections.php` (chat group)                                          |
| Presence auth      | `routes/channels.php` → `chat.{chatId}`                                        |
| Pages              | `ChatPageController`, `resources/js/pages/chat/index.tsx`                      |
| Messages / uploads | `ChatMessageController`, `ChatAttachmentUploadService`                         |
| Item facade        | `ItemChatController`                                                           |
| ACL / create       | `ChatService`, `ChatParticipantSync`                                           |
| Unread             | `ChatUnreadService`                                                            |
| UI shell           | `item-chat-drawer.tsx`, `components/chat/*`                                    |
| Events             | `app/Events/ItemChatMessage*.php`, `ChatUnreadUpdated`, `ChatThreadUpserted`   |
| Tests              | `tests/Feature/Chat/*`, `tests/Feature/Collections/CollectionItemChatTest.php` |

## Operator checklist

- [ ] Grant `can-show-chat` to roles that need the hub / sidebar.
- [ ] Run migrations (`chat_*` create tables on fresh install).
- [ ] Enable Reverb + matching `VITE_REVERB_*` for live messages / typing / unread push.
- [ ] Know archive is private-DM only; item threads stay visible while the collection item exists.
- [ ] Set `chat_max_upload_bytes` in project settings if you want a soft cap (null = unlimited aside from PHP).
- [ ] Scheduler/queue healthy if you rely on related file jobs after “save to files”.
- [ ] Restrict `can-show-activity-logs` — chat previews appear in the audit UI.
