Features

Markdown

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 → Files & Media (/settings/project/files; 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

In the thread (1.4.0)

The human chat is rebuilt on shadcn/ui (core #558, #575, #576, #578, #582):

PieceBehaviour
ReactionsOne button per emoji with its count, pressed when you reacted. Its tooltip lists who reacted. Toggle from the button, the ⋯ menu or the right-click menu.
Message menuA visible ⋯ button beside each bubble (on hover and keyboard focus) and the right-click menu share one list: reactions, reply, copy, pin, edit, delete.
User hover cardAuthor names and @ mentions open a card with the user's avatar, name, role and online status (on hover or keyboard focus). Mentions render as badges.
Reply quotesA quoted row with a left rule inside the bubble; click it to jump to the original message. Above the composer it shows what you are replying to, with Cancel.
Pinned messagesA bar above the thread with one row per pinned message (author and text); click a row to jump to the message.
PhotosImages fill their bubble with the caption wrapping inside it; albums use a collage. Click to open the lightbox carousel (swipe, arrow keys, Previous / Next).
FilesEach file is an attachment card with icon, name and size; click to download, or use its menu to download, Save to Files or Add to field (item threads).
UploadsPending files show a spinner and progress, an error, or done, with a remove action.
DeleteDeleting a message or a direct chat asks for confirmation first (core #522).

Day separators and the typing indicator are inline markers; reactions under the last message stay visible.

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.php → chat.{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.