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:
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:
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:
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:
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:
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:
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:
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:
type StoreBaseState = ChatStateSnapshot & {
profile: Profile
searchQuery: string
showArchived: boolean
}
const initialState: StoreBaseState = {
...mockChatState,
profile: mockProfile,
searchQuery: "",
showArchived: false,
}
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:
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:
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}