Chat
A complete messaging system — a full-page workspace messenger plus a floating bubble and a right-pinned rail, all sharing one persisted conversation store.
Overview
The chat system presents three surfaces that all read and write the same conversation store:
- The full Chat page (
/apps/chat) — a full-bleed workspace messenger with an in-page icon rail, a collapsible conversations panel, and up to three conversations open side-by-side (each an independent thread pane). This is the only surface that shows group conversations. - The chat bubble (
ChatWidget) — a floating bottom-right launcher opening a panel with a conversation list and a thread view. Gated byconfig.chatBubble. Works on mobile. - The chat rail (
ChatRail) — a slim right-pinned 4rem rail of avatars; clicking one opens a contact card, whose Message action opens a floating, stackable chat window. Gated byconfig.chatRail. Desktop-only.
The bubble and rail are mutually exclusive in the customizer (chatRail is on by default). Both
mount inside AppLayout (wrapped in <ChatProvider>) and never appear on /auth/*. Because all three
surfaces consume useChat(), sending a message or clearing unread in one is reflected everywhere.
Feature highlights: 12 direct conversations + 4 group chats (seed), simulated typing + canned replies,
per-message emoji reactions and replies, image attachments (data URL), per-conversation
drafts, pin / mute / mark-unread / clear-history, and state persistence to localStorage so a
reload restores your threads.
Architecture & files
The feature is split into the full-page screen, the provider that owns the store, the three chat surfaces, a set of shared components, two page-level hooks, and the data module.
Screen & provider
| File | Responsibility |
|---|---|
src/pages/apps/ChatPage.tsx | The full /apps/chat screen. |
src/components/chat/ChatProvider.tsx | useChat() — the single source of truth (see API). |
Chat surfaces (src/components/chat/)
| File | Responsibility |
|---|---|
ChatWidget.tsx | Floating bubble launcher + panel (config.chatBubble). |
ChatRail.tsx | Right-pinned avatar rail (config.chatRail). |
ContactCard.tsx | Rail avatar → contact card (Message/Call/Mail/Video). |
ChatWindows.tsx | Container for the rail's floating windows. |
ChatWindow.tsx | A single floating chat window (minimize/close, per-window Esc). |
Shared components (src/components/chat/)
| File | Responsibility |
|---|---|
ThreadPane.tsx | A self-contained thread column used on the Chat page. |
Messages.tsx | MessageBubble, TypingIndicator, useMessageText (shared). |
StatusDot.tsx | Presence dot + PresenceStatus type. |
GroupAvatar.tsx | Fixed-box member-cluster avatar for groups. |
EmojiMartPicker.tsx | emoji-mart picker (lazy, own chunk). |
ChatEmpty.tsx | Token-var SVG empty states (thread/search/files/notes). |
Hooks (src/components/chat/)
| File | Responsibility |
|---|---|
useChatPanes.ts | Chat-page multi-pane manager (open/close + focus). |
useListMode.ts | Persisted list density (comfortable/compact/grid). |
Data
| File | Responsibility |
|---|---|
src/data/chat.ts | Types, seed, persistence, derived display helpers. |
Data & persistence (src/data/chat.ts)
State persists to localStorage under STORAGE_KEY = 'chat-state-v2' via
loadChatState() / saveChatState() / clearChatState(). ChatProvider hydrates once on mount and
saves (debounced 300 ms) on any conversation/draft change.
ChatState={conversations: Conversation[]; drafts: Record<string, string>}(empty drafts pruned on save).Conversation—id,name,roleKey/presenceKey/departmentKey(i18n keys),avatar,status('online' | 'away' | 'offline'),email,phone,unread,pinned,muted, and for groupsisGroup: true+members: ChatMember[];messages: ChatMessage[].ChatMessage— carries a real epochat: number(every display label — bubble time, day separators, grouping — is derived from it).key(i18n) for seeded text,textfor user-typed;kind: 'image'+imageUrlfor attachments;senderIdfor group attribution;reactions;replyTo(quoted message id).- Groups are seeded
g1..g4and are Chat-page-only —ChatWidget/ChatRailfilter outisGroup, and the launcher badge (totalUnread) excludes groups and muted conversations. nextMessageId(c)generates the nextm<seq>id (max suffix + 1) so ids don't collide after a "clear history".- Display helpers (native
Date, locale-aware):formatMessageTime,formatListTime,dayLabelFor,buildThreadItems(day separators + same-sender grouping withinGROUP_WINDOW_MS).
Tip
The key chat-state-v2 (and chat-list-mode, emoji-mart.frequently, emoji-mart.last) are all
registered in src/lib/appStorage.ts so "Reset to defaults" wipes them. See
Architecture & Routing for the persistence and reset model.
The Chat page (src/pages/apps/ChatPage.tsx)
A full-bleed route (/apps/chat) that also forces header-only + fixed-footer presentation via its
appRoutes entry ({fullBleed: true, headerOnly: true, fixedFooter: true}) — this is
presentation-only and doesn't touch the saved customizer config. Structure:
- an in-page icon rail (Chats w/ unread badge · Groups · Pinned · Files · Notes; a Calendar button
navigates to
/apps/calendar; bottom: collapse-panel toggle + decorative Settings); - a collapsible list panel (
md:w-72) with search, a segmented All / Unread / Online control with live counts, list display modes (comfortable / compact / grid viauseListMode), and collapsible Direct Messages / Group Chats sections; - a thread area hosting up to
MAX_PANES(3)ThreadPanes side-by-side, managed byuseChatPanes.
useChatPanes(convos, markRead) opens the three most-recent conversations on desktop first load,
caps panes at 3 (dropping the oldest), and keeps keyboard focus sensible (focuses the adjacent pane's
composer on close — the ChatWindow idiom, so Esc closes panes in sequence).
Usage
- Turn a widget on/off in the Layout Customizer's Widgets section (bubble vs. rail, mutually
exclusive) or the Layout Settings page. Defaults:
chatRail: true,chatBubble: false. - Open the full page at
/apps/chat(sidebar: Pages → Chat Widget, or the apps waffle menu). - Consume the store anywhere via
useChat()— but only inside<ChatProvider>(mounted inAppLayout).
API / Props
useChat() — the ChatProvider context
| Member | Type | Purpose |
|---|---|---|
convos | Conversation[] | Sorted for display: pinned first, then most-recent message. |
byId(id) | (id) => Conversation | undefined | Lookup. |
totalUnread | number | Launcher/rail badge — excludes muted and group conversations. |
typingIds | Set<string> | Conversations currently showing a simulated typing indicator. |
panelOpen / activeId / active | — | Bubble-panel state. |
togglePanel() / closePanel() | — | Bubble-panel open/close. |
openConversation(id) | — | Open the bubble panel into a conversation (clears its unread). |
setActiveId(id) | — | Set the bubble panel's active conversation. |
markRead(id) | — | Clear a conversation's unread without opening any panel (used by the Chat page). |
windows / minimized | string[] / Set<string> | Rail floating-window manager. |
openWindow(id) / closeWindow(id) / toggleMinimize(id) | — | Rail windows (max 3, oldest drops). |
railCollapsed / toggleRail() | — | Whether the right rail is collapsed (persisted chat-rail-collapsed). |
sendMessage(id, text, replyTo?) | — | Append a text message + trigger a simulated reply. |
sendImage(id, dataUrl, name) | — | Append an image message (data URL) + simulated reply. |
togglePin(id) / toggleMute(id) | — | Conversation flags. |
markUnread(id) | — | Re-flag a conversation unread (≥ 1). |
clearHistory(id) | — | Empty a conversation's messages. |
toggleReaction(convoId, messageId, emoji) | — | Toggle the current user's reaction (me-toggle semantics). |
deleteMessage(convoId, messageId) | — | Remove a message. |
drafts / setDraft(id, text) | Record<string,string> / — | Per-conversation unsent composer drafts (persisted). |
Components
| Component | Source | Props / notes |
|---|---|---|
ChatProvider | ChatProvider.tsx | Wraps the app subtree; supplies useChat(). Mounted in AppLayout. |
ChatWidget | ChatWidget.tsx | No props; reads useChat(). Rendered when config.chatBubble. |
ChatRail | ChatRail.tsx | No props; reads useChat(). Rendered when config.chatRail. |
ChatWindows | ChatWindows.tsx | Container for the rail's floating windows. |
ThreadPane | ThreadPane.tsx | {convo, onClose, onBack?, menu?} — a self-contained thread column. |
MessageBubble / TypingIndicator / useMessageText | Messages.tsx | Shared bubble (image, reactions, reply, read receipt), typing dots, message-text resolver. |
StatusDot | StatusDot.tsx | {status: PresenceStatus, className?}. PresenceStatus = ChatStatus | 'busy'. |
GroupAvatar | GroupAvatar.tsx | {members, size?: 'sm'|'md'|'lg'} — member cluster. |
EmojiMartPicker | EmojiMartPicker.tsx | default export; React.lazy-loaded from ThreadPane (own chunk). Uses emoji-mart core (not @emoji-mart/react). |
Hooks
| Hook | Returns | Purpose |
|---|---|---|
useChatPanes(convos, markRead) | {openIds, openConvo, closeConvo} | Chat-page multi-pane manager (max 3, focus-preserving). |
useListMode() | [ListMode, (m) => void] | Persisted list density ('comfortable' | 'compact' | 'grid', key chat-list-mode). |
Configuration & customization
- Widget selection —
config.chatBubble/config.chatRailinLayoutContext(mutually exclusive), toggled in the Customizer/Layout Settings. - Seed conversations — edit the
conversationsarray insrc/data/chat.ts. Text fields use i18n keys in thechatnamespace (role_*,dept_*,presence_*, messagekeys); names/phones are literal. - Reply behavior —
queueReply()inChatProvidersimulates typing then a canned reply fromcannedReplyKeys. Replace this with a real API call to wire live messaging. - Max side-by-side panes / windows —
MAX_PANESinuseChatPanes.tsandMAX_WINDOWSinChatProvider.tsx(both 3). - Colors — everything is token-based; never introduce a hex.
Examples
Consume the store to render a launcher badge and open a conversation:
import {useChat} from '@/components/chat/ChatProvider'
function ChatLauncher() {
const {totalUnread, convos, openConversation} = useChat()
return (
<button onClick={() => openConversation(convos[0].id)}>
Messages {totalUnread > 0 && <span className="badge">{totalUnread}</span>}
</button>
)
}Send a message (with an optional quoted reply) and mark a conversation read:
const {sendMessage, markRead, toggleReaction} = useChat()
sendMessage('c1', 'On it — pushing the fix now') // plain message
sendMessage('c1', 'Agreed', 'm2') // reply quoting message m2
toggleReaction('c1', 'm2', emoji) // toggle my reaction (emoji = a picked reaction string)
markRead('c1') // clear unread, no panelRender a thread pane on a custom screen:
import {ThreadPane} from '@/components/chat/ThreadPane'
import {useChat} from '@/components/chat/ChatProvider'
function MiniThread({id}: {id: string}) {
const {byId} = useChat()
const convo = byId(id)
if (!convo) return null
return <ThreadPane convo={convo} onClose={() => {}} />
}Best practices
- Only call
useChat()inside<ChatProvider>(it throws otherwise). The provider lives inAppLayout, so chat surfaces work only within the shell — not on/auth/*. - Use
markRead(notopenConversation) when you want to clear unread without popping a panel. - Store seeded/canned text as i18n keys, user-typed text as literal
text— mirror the seed's split so language switches re-translate seeded content. - Register any new persisted chat key in
src/lib/appStorage.ts. - Keep
EmojiMartPickerlazy — it pulls inemoji-martand should stay in its own chunk. - Never add
@emoji-mart/react(its React peer range stops at 18) — use the framework-agnosticemoji-martcore the wayEmojiMartPickerdoes.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
useChat must be used within <ChatProvider> | You called useChat() outside the provider (e.g. on an auth route). Move it inside AppLayout. |
| Neither bubble nor rail shows | Both are off, or you're on /auth/*, or the route sets hideWidgets. Toggle in Customizer → Widgets. |
| Groups missing from the bubble/rail | Intentional — groups are Chat-page-only; the widget and rail filter isGroup. |
| Threads reset on reload | chat-state-v2 failed to persist (private mode / quota) — the app still works unpersisted. |
| Old seed came back after "Reset" | Reset clears chat-state-v2 and re-hydrates from the seed — expected. |
| Emoji picker doesn't open | It's lazy-loaded on first use; a slow chunk fetch delays the first open. |
FAQ
Is this wired to a backend? No — it's a self-contained demo store with a simulated reply. Replace
queueReply / sendMessage in ChatProvider with your API to go live.
Why do the bubble and rail badges differ from the Chat page count? The launcher totalUnread
excludes muted and group conversations; the Chat page derives its own pageUnread that includes
groups (still excluding muted) because its Chats view shows them.
How many conversations can be open at once on the page? Up to MAX_PANES (3) side-by-side; opening
a fourth drops the oldest.
Do images survive a reload? Yes — they're stored as data URLs inside the persisted state (demo level; watch localStorage quota).
Notes for designers & content editors
- All chat chrome text (filters, section labels, menus, empty states, roles, departments, presence
labels) lives in the
chatandnavi18n namespaces. Editsrc/locales/en/chat.jsonto reword. - Conversation names, emails, and phone numbers in the seed are literal demo content.
- Avatars are self-hosted in
public/avatars/— reference absolute paths like/avatars/avatar-3.png. - Empty states (
ChatEmpty.tsx) are token-var SVGs, so they adapt to theme and skin automatically.
Related
Was this page helpful?
