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.

Chat hub
Chat hub for collection and private threads

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

SurfaceRoute / UIWho
Chat hubInertia GET /chat, GET /chat/{uuid}resources/js/pages/chat/index.tsxUsers with can-show-chat
Item drawerChat panel on collection item form (item-chat-drawer.tsx)Users who can read that collection item (collection ACL)
SidebarChat nav entry + unread badge from Inertia page.props.chatcan-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 / modelRole
chats (App\Models\Chat)UUID PK. kind: item | direct. Item chats link collection_id + collection_item_id. Soft deletes.
chat_participantsDirect 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_reactionsEmoji reactions per user/message.
chat_readsPer-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

ConcernGate
Hub pages + /chat/* JSONpermission:can-show-chat (PermissionEnum::CanShowChat) on routes/chat.php
Item alias routesCollection item read (same as opening the item); defined in routes/collections.php
Inertia shared unreadOnly 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.

RouteNameBehavior
GET /chatchat.indexSplit shell; tabs collection | private (?tab=)
GET /chat/{chat}chat.showSame shell with selected thread (UUID); marks read

Thread list / create / options:

RouteNamePurpose
GET /chat/threadschat.threads.indexPaginated hub threads
POST /chat/threadschat.threads.storeStart item or direct thread
GET /chat/unread-countchat.unread-countUnread totals
GET /chat/options/userschat.options.usersDM user picker
GET /chat/options/groupschat.options.groupsDM group picker
GET /chat/options/directorychat.options.directoryCombined directory
GET /chat/options/collectionschat.options.collectionsItem-thread collection filter
GET /chat/options/itemschat.options.itemsItems in a collection
GET /chat/options/item-pickerchat.options.item-pickerCombined 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).

ConcernDetail
Storagechat_participants.archived_at for the viewing user
Hide listDefault private hub list excludes archived rows
Toggle UIIcon-only control next to hub search (private tab): show archived ↔ show inbox (data-test="chat-filter-archived")
Thread menuArchive / Unarchive on direct threads (ChatDirectThreadMenu)
APIPOST /chat/{chat}/archive, POST /chat/{chat}/unarchive (chat.archive / chat.unarchive)
List queryGET /chat/threads?archived=1 (private tab) lists only archived DMs
Inbound messageA new message to an archived DM clears archived_at for recipients so the thread resurfaces
Leave vs archiveLeave/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).

CapabilityDetail
CreateBody (optional if attachments), @ mentions (item + multi-party DM only; not 1:1), optional reply_to_id, attachment IDs
Edit / deleteAuthor only
PinToggle pinned_at
ReactionsToggle emoji; broadcast ReactionToggled
PaginationCursor / page load older; FE merges without wiping history on soft refresh
Optimistic sendPending 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.

PathWhen
Single POSTSmaller files
Chunked init → chunk → completeLarge files (bypasses single-request PHP limits)

Limits:

SettingScope
project.chat_max_upload_bytesNullable = unlimited (still capped by PHP / chunk pipeline)
project.files_max_upload_bytesFile 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 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.

ChannelAuthEvents (broadcastAs)
Presence chat.{chatId}ChatService::assertAccessibleMessageCreated, MessageUpdated, MessageDeleted, ReactionToggled
Private App.Models.User.{id}Same userChatUnreadUpdated, 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.

KindWho gets unread
DirectOther participants (not author)
ItemMentions + 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):

EventProperties (meta)
chat_messagechat_id, body_preview (≤ 120 chars), attachment/mention counts, reply_to_id
chat_message_deletedchat_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

ConcernLocation
Hub routesroutes/chat.php
Item aliasesroutes/collections.php (chat group)
Presence authroutes/channels.phpchat.{chatId}
PagesChatPageController, resources/js/pages/chat/index.tsx
Messages / uploadsChatMessageController, ChatAttachmentUploadService
Item facadeItemChatController
ACL / createChatService, ChatParticipantSync
UnreadChatUnreadService
UI shellitem-chat-drawer.tsx, components/chat/*
Eventsapp/Events/ItemChatMessage*.php, ChatUnreadUpdated, ChatThreadUpserted
Teststests/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.
Previous
AI chat