Overview
The ChatSidebar component provides the conversation list interface with search, filtering, and user profile display. It handles chat selection, search history, and displays typing indicators.
Usage
import { ChatSidebar } from "@/components/chat/sidebar"
function SidebarContainer() {
return (
<ChatSidebar
chats={chatList}
contacts={contactsMap}
profile={currentUserProfile}
activeChatId={activeChat?.id}
searchQuery={searchText}
searchHistory={recentSearches}
typingChatIds={currentlyTypingSet}
onSelectChat={handleChatSelection}
onSearchChange={handleSearchInput}
onSearchSubmit={handleSearchSubmit}
onStartNewChat={handleNewChat}
/>
)
}
Props
Array of chat conversations to display in the sidebar
contacts
Record<string, Contact>
required
Dictionary mapping contact IDs to contact objects
The current user’s profile information (name, avatar, about)
The ID of the currently selected/active chat
The current search query text
searchHistory
ChatSearchHistoryItem[]
required
Array of recent search queries
Set of chat IDs where contacts are currently typing
onSelectChat
(chatId: string) => void
required
Callback when a chat is selected from the list
onSearchChange
(value: string) => void
required
Callback when search input changes
onSearchSubmit
(value: string) => void
required
Callback when user submits a search (presses Enter)
Callback when the new chat button is clicked
Type definitions
interface ChatSidebarProps {
chats: Chat[]
contacts: Record<string, Contact>
profile: Profile
activeChatId?: string
searchQuery: string
searchHistory: ChatSearchHistoryItem[]
typingChatIds: Set<string>
onSelectChat: (chatId: string) => void
onSearchChange: (value: string) => void
onSearchSubmit: (value: string) => void
onStartNewChat: () => void
}
interface Profile {
id: string
name: string
phoneNumber: string
avatarUrl: string
about: string
}
interface ChatSearchHistoryItem {
id: string
value: string
lastUsedAt: string
}
Layout structure
The sidebar consists of three main sections:
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar/80 backdrop-blur-xl">
{/* Header with profile */}
<header className="flex items-center justify-between px-5 pb-4 pt-5">
<Avatar /> {/* User profile */}
<Button /> {/* New chat button */}
</header>
{/* Search input and history */}
<div className="px-5 pb-3">
<Input /> {/* Search field */}
<SearchHistory /> {/* Recent searches */}
</div>
{/* Chat list */}
<div className="overflow-y-auto px-1">
{filtered.map((chat) => (
<ChatListItem key={chat.id} />
))}
</div>
</aside>
Search functionality
The sidebar provides real-time chat filtering based on search queries:
const filtered = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!query) {
return chats
}
return chats.filter((chat) => {
const contact = contacts[chat.contactId]
return (
contact?.name.toLowerCase().includes(query) ||
chat.lastMessagePreview?.toLowerCase().includes(query)
)
})
}, [chats, contacts, searchQuery])
Search filters by both contact name and last message preview text.
Search history
When the search field is empty, recent searches are displayed as clickable chips:
{searchQuery.length === 0 && searchHistory.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{searchHistory.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSearchChange(item.value)}
className="rounded-full bg-sidebar-accent px-3 py-1 text-xs font-medium text-sidebar-accent-foreground shadow-sm transition hover:bg-sidebar-accent/80"
>
{item.value}
</button>
))}
</div>
) : null}
Chat list item
Each chat in the list displays:
- Contact avatar with fallback initials
- Contact name
- Last message preview or “Typing…” indicator
- Last activity timestamp
- Unread message count badge
<button
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left transition",
isActive
? "bg-sidebar-accent/70 text-sidebar-foreground shadow-sm"
: "hover:bg-sidebar-accent/40"
)}
onClick={() => onSelectChat(chat.id)}
>
<Avatar className="h-12 w-12 border border-border/60">
<AvatarImage src={contact.avatarUrl} alt={contact.name} />
<AvatarFallback>{initials(contact.name)}</AvatarFallback>
</Avatar>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-sidebar-foreground">
{contact.name}
</p>
<span className="text-xs text-muted-foreground">
{formatActivity(chat.lastActivityAt)}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<p className="truncate text-xs text-muted-foreground">
{isTyping ? "Typing…" : chat.lastMessagePreview ?? "No messages yet"}
</p>
{chat.unreadCount ? (
<Badge className="rounded-full bg-accent text-accent-foreground">
{chat.unreadCount}
</Badge>
) : null}
</div>
</div>
</button>
Timestamps are formatted based on recency:
function formatActivity(dateIso: string) {
const date = new Date(dateIso)
if (isToday(date)) {
return format(date, "HH:mm") // "14:30"
}
return formatDistanceToNow(date, { addSuffix: true }) // "2 days ago"
}
Today’s messages show time only (HH:mm), while older messages show relative time (e.g., “2 days ago”).
Empty state
When no chats match the search query:
{filtered.length === 0 ? (
<div className="px-6 py-8 text-center text-sm text-muted-foreground">
No chats match your search.
</div>
) : null}
Initials generation
Contact initials are computed for avatar fallbacks:
function initials(value: string) {
return value
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
}
Styling
The sidebar uses a frosted glass effect with backdrop blur:
className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar/80 backdrop-blur-xl"