Chat Conversation List Block
ChatConversationList is a responsive, highly customizable inbox listing view built on MUI Typography and Avatar elements. It supports a built-in search bar, subcomponent-level debounce control, unread badge counters, lazy-loading/scroll-to-bottom callback hooks, and a responsive two-column split-pane layout.
Installation
- npm
- yarn
- pnpm
- bun
npm install @nodeblocks/frontend-chat-conversation-list-block
yarn add @nodeblocks/frontend-chat-conversation-list-block
pnpm add @nodeblocks/frontend-chat-conversation-list-block
bun add @nodeblocks/frontend-chat-conversation-list-block
What You Need
| Item | Why it matters |
|---|---|
conversations | Array of inbox conversation entries (id, title, content, dateTime, unreadCount, etc.) |
onNavigate | Callback triggered when clicking a conversation item |
labels (optional) | Text mapping for list headings and empty states |
search (optional) | Stateful search configurations containing callbacks (onChange) and default values |
renderDesktopPane (optional) | Render prop to inject a right-hand detail pane (like ChatConversation) on md and wider viewports |
ChatConversationList is a controlled, presentational component and does not manage its own active thread state, search filtering, or pagination lists internally. Keep the active conversations array and the selected thread index/ID in your parent component's state, filter items in response to search.onChange, and manage loading indicators with the isLoading prop.
Code Examples
- Quick Start
- Labels and URLs
- Compound Components
- Block Override
function Example() { const conversations = [ { id: '1', title: 'Customer Support', content: 'Thank you for contacting us. How can we help you?', dateTime: new Date(Date.now() - 3 * 60 * 1000).toISOString(), titleLines: 1, unreadCount: 3, href: '#support', avatar: {children: 'CS', sx: {bgcolor: 'primary.main'}}, }, { id: '2', title: 'Project Discussion Group', content: 'The review slides are ready for feedback.', dateTime: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), titleLines: 2, unreadCount: 0, href: '#team', avatar: {children: 'PD', sx: {bgcolor: 'secondary.main'}}, }, ]; const [searchValue, setSearchValue] = React.useState(''); const [lastEvent, setLastEvent] = React.useState('Ready'); const visibleConversations = conversations.filter((conversation) => { const haystack = `${conversation.title} ${conversation.content ?? ''}`.toLowerCase(); return !searchValue || haystack.includes(searchValue.toLowerCase()); }); return ( <div style={{height: 400, border: '1px solid #e0e0e0', borderRadius: 8, overflow: 'hidden'}}> <ChatConversationList conversations={visibleConversations} labels={{ heading: 'Conversations', emptyState: 'No chats found', }} placeholders={{ search: 'Search conversations...', }} search={{ defaultValue: '', onChange: val => { setSearchValue(val); setLastEvent(`Search key: ${val}`); }, }} onNavigate={url => setLastEvent(`Routing to: ${url}`)} /> <div style={{marginTop: 8, fontSize: 12, color: '#666'}}>{lastEvent}</div> </div> ); }
Customize heading text, search placeholders, item navigation routing, and stateful side-pane details using URLs. When a user selects an entry, click handlers capture the link navigation to synchronize the split-pane.
function Example() { const [selectedId, setSelectedId] = React.useState('1'); const [messages, setMessages] = React.useState([ {id: 'm-1', title: 'John', content: 'Did you check the mockups?', createdAt: new Date().toISOString()}, ]); const [searchValue, setSearchValue] = React.useState(''); const [lastEvent, setLastEvent] = React.useState('Ready'); const conversations = [ { id: '1', title: 'Design Review Thread', content: 'The mockups look great! Just minor adjustments needed.', dateTime: new Date(Date.now() - 5 * 60 * 1000).toISOString(), titleLines: 1, unreadCount: 1, href: '#1', isSelected: selectedId === '1', avatar: {children: 'DR', sx: {bgcolor: 'primary.main'}}, }, { id: '2', title: 'Dev Sync Channel', content: 'Sprint planning starts tomorrow morning.', dateTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), titleLines: 1, unreadCount: 0, href: '#2', isSelected: selectedId === '2', avatar: {children: 'DS', sx: {bgcolor: 'secondary.main'}}, }, ]; const handleMessageSubmit = text => { setMessages(prev => [ ...prev, { id: String(Date.now()), title: 'You', content: text, createdAt: new Date().toISOString(), isOwnMessage: true, }, ]); }; return ( <div style={{height: 500, border: '1px solid #e0e0e0', borderRadius: 8, overflow: 'hidden'}}> <ChatConversationList conversations={conversations} labels={{heading: 'Support Inbox', emptyState: 'No conversations found'}} placeholders={{search: 'Filter threads...'}} search={{ defaultValue: searchValue, onChange: val => { setSearchValue(val); setLastEvent(`Searching: ${val}`); }, }} onNavigate={url => { setSelectedId(url.replace('#', '')); setLastEvent(`Navigate: ${url}`); }} // Split-pane layout for md and above viewports renderDesktopPane={() => ( <div style={{height: '100%', display: 'flex', flexDirection: 'column'}}> {selectedId ? ( <ChatConversation chatView={{ heading: { avatar: {children: selectedId === '1' ? 'DR' : 'DS'}, buttonHref: '#conversations', }, }} labels={{ chatViewHeadingButtonText: 'Exit Detail', chatViewHeadingText: selectedId === '1' ? 'Design Review' : 'Dev Sync', }} placeholders={{commentInput: 'Send response...'}} messages={messages} onMessageSubmit={handleMessageSubmit} onNavigate={url => { setSelectedId(''); setLastEvent(`Detail navigate: ${url}`); }} /> ) : ( <div style={{padding: 32, textAlign: 'center', color: '#888'}}> Select a conversation thread to view the detail stream. </div> )} </div> )} /> <div style={{marginTop: 8, fontSize: 12, color: '#666'}}>{lastEvent}</div> </div> ); }
Keep the active thread index or ID in state (such as selectedId). Generate the selection highlighting on conversations using isSelected: c.id === selectedId, and mount your detail subcomponent (such as ChatConversation) inside the renderDesktopPane render prop.
Compose the layout using fine-grained compound child blocks. To prioritize explicit prop passing, you can feed data configurations directly to subcomponents (like passing conversations, search, and labels directly to ScrollPanel, SearchBar, and Heading).
function Example() { const conversations = [ { id: '1', title: 'Customer Support', content: 'We are processing your ticket.', dateTime: new Date().toISOString(), titleLines: 1, unreadCount: 1, href: '#support', avatar: {children: 'CS'}, }, ]; const [lastEvent, setLastEvent] = React.useState('Ready'); return ( <div style={{height: 400, border: '1px solid #e0e0e0', borderRadius: 8, overflow: 'hidden'}}> <ChatConversationList conversations={conversations} onNavigate={url => setLastEvent(`Navigating: ${url}`)}> <div style={{ padding: '12px 24px', backgroundColor: '#fafafa', borderBottom: '1px solid #e8e8e8', display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }} > <ChatConversationList.Heading labels={{heading: 'Support Center'}} /> <div style={{width: 280}}> <ChatConversationList.SearchBar placeholders={{search: 'Filter tickets...'}} search={{onChange: val => setLastEvent(`Searching: ${val}`)}} /> </div> </div> <ChatConversationList.ScrollPanel conversations={conversations} isLoading={false} labels={{emptyState: 'No tickets in this folder'}} /> </ChatConversationList> <div style={{marginTop: 8, fontSize: 12, color: '#666'}}>{lastEvent}</div> </div> ); }
Prepend blocks or inject information panels using a child function override mapping.
function Example() { const conversations = [ { id: '1', title: 'General Support', content: 'Online status check.', dateTime: new Date().toISOString(), titleLines: 1, href: '#support', }, ]; const [lastEvent, setLastEvent] = React.useState('Ready'); return ( <div style={{height: 400, border: '1px solid #e0e0e0', borderRadius: 8, overflow: 'hidden'}}> <ChatConversationList conversations={conversations} labels={{heading: 'Workspace Chats'}} onNavigate={to => setLastEvent(`To: ${to}`)} > {({defaultBlocks, defaultBlockOrder}) => ({ blocks: { ...defaultBlocks, systemAlert: ( <div style={{ background: '#f6ffed', borderBottom: '1px solid #b7eb8f', padding: '8px 16px', fontSize: '12px', color: '#389e0d', textAlign: 'center', }} > Connected to the secure server. Inbox synchronization is active. </div> ), }, blockOrder: ['systemAlert', 'header', 'scrollPanel'], })} </ChatConversationList> <div style={{marginTop: 8, fontSize: 12, color: '#666'}}>{lastEvent}</div> </div> ); }
When to use block overrides
Use overrides to prepend inbox notice banners, offline alerts, or pinned announcements. defaultBlockOrder is ['heading', 'searchBar', 'header', 'scrollPanel', 'item']; default root rendering filters standalone heading, searchBar, and item, then renders header and scrollPanel.
Important Props
Core Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
conversations | Array<{ id: string; title: string; titleLines: 1 | 2; content?: string; dateTime?: string; href?: string; isSelected?: boolean; unreadCount?: number; avatar?: Partial<AvatarProps> & { isLoading?: boolean } }> | Yes | - | Array of inbox conversation thread records rendered inside the list |
onNavigate | (url: string) => void | Yes | - | Navigation callback triggered when a conversation with href is clicked |
isLoading | boolean | No | false | Renders a loading spinner inside the scroll panel when true |
onScrollBottom | () => void | No | undefined | Callback triggered when scrolling to bottom of list (used for lazy-load list appending) |
Conversation Object
Configure properties inside each conversation object in the conversations array:
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | - | Unique conversation thread record identifier |
title | string | Yes | - | Heading label title of the conversation thread |
titleLines | 1 | 2 | Yes | - | Maximum title lines displayed before truncation and ellipsis |
content | string | No | undefined | Main message preview body (cut off after 1 line) |
dateTime | string | No | undefined | ISO datetime string formatted by formatRelativeDateText |
href | string | No | undefined | Routing anchor link passed to onNavigate callback on row click |
isSelected | boolean | No | false | Highlights active selection when true |
unreadCount | number | No | undefined | Displays unread count badge indicator (values >99 display as 99...) |
avatar | Partial<AvatarProps> & { isLoading?: boolean } | No | undefined | Native MUI Avatar props + loading state indicator placeholder; linked avatars can be configured via component="a" |
Content Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
labels | { heading?: string; emptyState?: string } | No | undefined | Custom inbox heading text and empty inbox indicator text |
placeholders | { search?: string } | No | undefined | Search text input placeholders |
search | { defaultValue?: string; onChange?: (value: string) => void } | No | undefined | Stateful search controls containing initial values and change callbacks |
renderDesktopPane | () => ReactNode | No | undefined | Render prop to load a split-pane layout to the right on md and wider viewports |
formatRelativeDateText | (dateTime: string) => string | No | (dateTime) => DateTime.fromISO(dateTime).toRelative() ?? '' | Custom formatter for each row's ISO dateTime preview |
Layout and Composition Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
children | BlocksOverride | ReactNode | No | undefined | Compound child elements or override function child |
className | string | No | undefined | Styling class applied on the outer container |
sx | SxProps | No | undefined | MUI SX styling overrides passed to the outer Stack |
ChatConversationList inherits all StackProps (except children). defaultBlockOrder is ['heading', 'searchBar', 'header', 'scrollPanel', 'item']; default root rendering filters standalone heading, searchBar, and item, then renders header and scrollPanel.
Default UI Blocks
| Block | Built on | Notes |
|---|---|---|
ChatConversationList (root) | Stack | Stacks the default header and scroll panel vertically |
ChatConversationList.Header | Stack | Wrapper holding Heading and SearchBar child sections |
ChatConversationList.Heading | Typography | Presents the main listing header |
ChatConversationList.SearchBar | TextField | Search input with debounceTime defaulting to 500 ms and an icon adornment |
ChatConversationList.ScrollPanel | Stack | Scrolling viewport mapping list items, loading spinners, and empty states |
ChatConversationList.ConversationItem | Link + Avatar | Presents titles, content previews, unread badges, and dates |
TypeScript
import {ChatConversationList} from '@nodeblocks/frontend-chat-conversation-list-block';
import type {AvatarProps} from '@mui/material';
type Conversation = {
id: string;
title: string;
titleLines: 1 | 2;
content?: string;
dateTime?: string;
href?: string;
isSelected?: boolean;
unreadCount?: number;
avatar?: Partial<AvatarProps> & {
isLoading?: boolean;
};
};
const conversations: Conversation[] = [
{
id: 'thread-1',
title: 'Customer support',
content: 'Ticket resolved successfully.',
dateTime: '2026-05-27T02:00:00Z',
titleLines: 1,
unreadCount: 1,
avatar: {children: 'CS', sx: {bgcolor: 'primary.main'}},
},
];
<ChatConversationList
conversations={conversations}
labels={{
heading: 'Inbox',
emptyState: 'No threads active',
}}
placeholders={{
search: 'Filter threads...',
}}
search={{
onChange: val => void val,
}}
onNavigate={url => void url}
/>;