Features
Chat (hub & item threads)
Externa’s team chat is separate from 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.

Related
Realtime: Reverb & Echo. Unread vs bell: Activity & notifications. Privacy / plaintext bodies: Threat model & hosting. Upload cap: project setting chat_max_upload_bytes on /settings/project (null = unlimited aside from PHP). Permissions: Effective permissions.
Not AI chat
/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 onlycan-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
ChatThreadUpsertedto 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_idsstored 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/ keynotify— 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.
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
ChatMediaAlbumfor 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.
| 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 · 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-chatto 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_bytesin 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.
