PVR Tech Studio

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.

9 min read
Updated July 15, 2026

Overview

The chat system presents three surfaces that all read and write the same conversation store:

  1. 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.
  2. The chat bubble (ChatWidget) — a floating bottom-right launcher opening a panel with a conversation list and a thread view. Gated by config.chatBubble. Works on mobile.
  3. 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 by config.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

FileResponsibility
src/pages/apps/ChatPage.tsxThe full /apps/chat screen.
src/components/chat/ChatProvider.tsxuseChat() — the single source of truth (see API).

Chat surfaces (src/components/chat/)

FileResponsibility
ChatWidget.tsxFloating bubble launcher + panel (config.chatBubble).
ChatRail.tsxRight-pinned avatar rail (config.chatRail).
ContactCard.tsxRail avatar → contact card (Message/Call/Mail/Video).
ChatWindows.tsxContainer for the rail's floating windows.
ChatWindow.tsxA single floating chat window (minimize/close, per-window Esc).

Shared components (src/components/chat/)

FileResponsibility
ThreadPane.tsxA self-contained thread column used on the Chat page.
Messages.tsxMessageBubble, TypingIndicator, useMessageText (shared).
StatusDot.tsxPresence dot + PresenceStatus type.
GroupAvatar.tsxFixed-box member-cluster avatar for groups.
EmojiMartPicker.tsxemoji-mart picker (lazy, own chunk).
ChatEmpty.tsxToken-var SVG empty states (thread/search/files/notes).

Hooks (src/components/chat/)

FileResponsibility
useChatPanes.tsChat-page multi-pane manager (open/close + focus).
useListMode.tsPersisted list density (comfortable/compact/grid).

Data

FileResponsibility
src/data/chat.tsTypes, 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).
  • Conversationid, name, roleKey/presenceKey/departmentKey (i18n keys), avatar, status ('online' | 'away' | 'offline'), email, phone, unread, pinned, muted, and for groups isGroup: true + members: ChatMember[]; messages: ChatMessage[].
  • ChatMessage — carries a real epoch at: number (every display label — bubble time, day separators, grouping — is derived from it). key (i18n) for seeded text, text for user-typed; kind: 'image' + imageUrl for attachments; senderId for group attribution; reactions; replyTo (quoted message id).
  • Groups are seeded g1..g4 and are Chat-page-onlyChatWidget / ChatRail filter out isGroup, and the launcher badge (totalUnread) excludes groups and muted conversations.
  • nextMessageId(c) generates the next m<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 within GROUP_WINDOW_MS).

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 via useListMode), and collapsible Direct Messages / Group Chats sections;
  • a thread area hosting up to MAX_PANES (3) ThreadPanes side-by-side, managed by useChatPanes.

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 in AppLayout).

API / Props

useChat() — the ChatProvider context

MemberTypePurpose
convosConversation[]Sorted for display: pinned first, then most-recent message.
byId(id)(id) => Conversation | undefinedLookup.
totalUnreadnumberLauncher/rail badge — excludes muted and group conversations.
typingIdsSet<string>Conversations currently showing a simulated typing indicator.
panelOpen / activeId / activeBubble-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 / minimizedstring[] / 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

ComponentSourceProps / notes
ChatProviderChatProvider.tsxWraps the app subtree; supplies useChat(). Mounted in AppLayout.
ChatWidgetChatWidget.tsxNo props; reads useChat(). Rendered when config.chatBubble.
ChatRailChatRail.tsxNo props; reads useChat(). Rendered when config.chatRail.
ChatWindowsChatWindows.tsxContainer for the rail's floating windows.
ThreadPaneThreadPane.tsx{convo, onClose, onBack?, menu?} — a self-contained thread column.
MessageBubble / TypingIndicator / useMessageTextMessages.tsxShared bubble (image, reactions, reply, read receipt), typing dots, message-text resolver.
StatusDotStatusDot.tsx{status: PresenceStatus, className?}. PresenceStatus = ChatStatus | 'busy'.
GroupAvatarGroupAvatar.tsx{members, size?: 'sm'|'md'|'lg'} — member cluster.
EmojiMartPickerEmojiMartPicker.tsxdefault export; React.lazy-loaded from ThreadPane (own chunk). Uses emoji-mart core (not @emoji-mart/react).

Hooks

HookReturnsPurpose
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 selectionconfig.chatBubble / config.chatRail in LayoutContext (mutually exclusive), toggled in the Customizer/Layout Settings.
  • Seed conversations — edit the conversations array in src/data/chat.ts. Text fields use i18n keys in the chat namespace (role_*, dept_*, presence_*, message keys); names/phones are literal.
  • Reply behaviorqueueReply() in ChatProvider simulates typing then a canned reply from cannedReplyKeys. Replace this with a real API call to wire live messaging.
  • Max side-by-side panes / windowsMAX_PANES in useChatPanes.ts and MAX_WINDOWS in ChatProvider.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 panel

Render 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 in AppLayout, so chat surfaces work only within the shell — not on /auth/*.
  • Use markRead (not openConversation) 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 EmojiMartPicker lazy — it pulls in emoji-mart and should stay in its own chunk.
  • Never add @emoji-mart/react (its React peer range stops at 18) — use the framework-agnostic emoji-mart core the way EmojiMartPicker does.

Troubleshooting

SymptomCause / 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 showsBoth are off, or you're on /auth/*, or the route sets hideWidgets. Toggle in Customizer → Widgets.
Groups missing from the bubble/railIntentional — groups are Chat-page-only; the widget and rail filter isGroup.
Threads reset on reloadchat-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 openIt'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 chat and nav i18n namespaces. Edit src/locales/en/chat.json to 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.

Was this page helpful?