Skip to main content
The chat list provides an organized view of all conversations with support for sorting, search, unread indicators, and pinned chats.

Chat interface

Each chat maintains metadata about the conversation:
lib/chat/types.ts
export interface Chat {
  id: string
  contactId: string
  messageIds: string[]
  unreadCount: number
  archived?: boolean
  muted?: boolean
  lastActivityAt: string
  lastMessagePreview?: string
}

Chat sorting

Chats are automatically sorted by lastActivityAt to show the most recent conversations first:
lib/chat/mock-data.ts
const initialState: ChatStateSnapshot = {
  chats: Object.fromEntries(
    Object.entries(chats).sort(([, a], [, b]) => 
      (a.lastActivityAt > b.lastActivityAt ? -1 : 1)
    )
  ),
  // ...
}
The chat list updates dynamically when new messages are sent or received, automatically reordering by activity.

Displaying the chat list

The ChatSidebar component renders the list of conversations:
components/chat/sidebar.tsx
export function ChatSidebar({
  chats,
  contacts,
  profile,
  activeChatId,
  searchQuery,
  searchHistory,
  typingChatIds,
  onSelectChat,
  onSearchChange,
  onSearchSubmit,
  onStartNewChat,
}: ChatSidebarProps) {
  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])

  return (
    <aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar/80 backdrop-blur-xl">
      {/* Sidebar content */}
    </aside>
  )
}

Chat list items

Each chat item displays contact information, message preview, and metadata:
components/chat/sidebar.tsx
{filtered.map((chat) => {
  const contact = contacts[chat.contactId]
  if (!contact) return null
  const isActive = chat.id === activeChatId
  const isTyping = typingChatIds.has(chat.id)

  return (
    <button
      key={chat.id}
      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>
  )
})}

Unread count management

Unread counts are automatically managed by the chat store:

Incrementing unread count

When receiving a message in an inactive chat:
lib/chat/state.ts
receiveMessage: (chatId, message) => {
  set((state) => {
    const chat = state.chats[chatId]
    if (!chat) return state

    const nextChat: Chat = {
      ...chat,
      messageIds: [...chat.messageIds, message.id],
      unreadCount:
        state.activeChatId === chatId ? 0 : Math.min(chat.unreadCount + 1, 99),
      lastActivityAt: message.createdAt,
      lastMessagePreview: derivePreview(message),
    }

    return {
      ...state,
      messages: nextMessages,
      chats: { ...state.chats, [chatId]: nextChat },
    }
  })
}
Unread counts are capped at 99 to prevent displaying excessively large numbers.

Clearing unread count

When a chat becomes active:
lib/chat/state.ts
setActiveChat: (chatId) => {
  set((state) => {
    if (!chatId) {
      return { ...state, activeChatId: undefined }
    }
    if (!state.chats[chatId]) {
      return state
    }
    const nextChats: Record<string, Chat> = {
      ...state.chats,
      [chatId]: {
        ...state.chats[chatId],
        unreadCount: 0,
      },
    }
    return {
      ...state,
      chats: nextChats,
      activeChatId: chatId,
    }
  })
}
Alternatively, explicitly mark a chat as read:
lib/chat/state.ts
markChatAsRead: (chatId) => {
  set((state) => {
    const chat = state.chats[chatId]
    if (!chat) return state

    return {
      ...state,
      chats: {
        ...state.chats,
        [chatId]: {
          ...chat,
          unreadCount: 0,
        },
      },
    }
  })
}

Pinned chats

Contacts can be pinned to keep them at the top of the chat list:
lib/chat/types.ts
export interface Contact {
  id: string
  name: string
  phoneNumber: string
  about: string
  avatarUrl: string
  isOnline: boolean
  lastSeenAt: string
  favorite?: boolean
  pinned?: boolean
}
The pinned property on the Contact interface indicates whether a chat should be prioritized in the list.

Archived chats

Chats can be archived to remove them from the main view:
lib/chat/state.ts
toggleArchive: (chatId) => {
  set((state) => {
    const chat = state.chats[chatId]
    if (!chat) return state
    return {
      ...state,
      chats: {
        ...state.chats,
        [chatId]: { ...chat, archived: !chat.archived },
      },
    }
  })
}

Filtering archived chats

The store maintains a showArchived flag to control visibility:
lib/chat/state.ts
type StoreBaseState = ChatStateSnapshot & {
  profile: Profile
  searchQuery: string
  showArchived: boolean
}

const initialState: StoreBaseState = {
  ...mockChatState,
  profile: mockProfile,
  searchQuery: "",
  showArchived: false,
}

Activity timestamp formatting

Chat list items display relative timestamps:
components/chat/sidebar.tsx
function formatActivity(dateIso: string) {
  const date = new Date(dateIso)
  if (isToday(date)) {
    return format(date, "HH:mm")
  }
  return formatDistanceToNow(date, { addSuffix: true })
}
Messages from today show as time (e.g., “14:30”), while older messages show relative time (e.g., “2 days ago”).

Chat selectors

The chat store provides selectors for accessing chat data:
lib/chat/state.ts
export const chatSelectors = {
  chatList: (state: ChatStore) => Object.values(state.chats),
  activeChat: (state: ChatStore) =>
    state.activeChatId ? state.chats[state.activeChatId] : undefined,
  activeMessages: (state: ChatStore) => {
    if (!state.activeChatId) return []
    const chat = state.chats[state.activeChatId]
    return chat.messageIds.map((id) => state.messages[id]).filter(Boolean)
  },
  contacts: (state: ChatStore) => Object.values(state.contacts),
}

Typing indicators in chat list

The chat list shows when contacts are typing:
components/chat/sidebar.tsx
<p className="truncate text-xs text-muted-foreground">
  {isTyping ? "Typing…" : chat.lastMessagePreview ?? "No messages yet"}
</p>
Typing indicators are tracked globally:
lib/chat/types.ts
export interface TypingIndicator {
  chatId: string
  authorId: string
  startedAt: string
}

Empty state

When no chats match the current filter or search:
components/chat/sidebar.tsx
{filtered.length === 0 ? (
  <div className="px-6 py-8 text-center text-sm text-muted-foreground">
    No chats match your search.
  </div>
) : null}

Build docs developers (and LLMs) love