Skip to main content

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

chats
Chat[]
required
Array of chat conversations to display in the sidebar
contacts
Record<string, Contact>
required
Dictionary mapping contact IDs to contact objects
profile
Profile
required
The current user’s profile information (name, avatar, about)
activeChatId
string
The ID of the currently selected/active chat
searchQuery
string
required
The current search query text
searchHistory
ChatSearchHistoryItem[]
required
Array of recent search queries
typingChatIds
Set<string>
required
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)
onStartNewChat
() => void
required
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>

Activity timestamp formatting

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"

Build docs developers (and LLMs) love