Skip to main content
The contact management system tracks user profiles with real-time online status, favorite contacts, and detailed profile information.

Contact interface

Contacts store comprehensive user information:
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
}

Field descriptions

  • id - Unique identifier for the contact
  • name - Display name
  • phoneNumber - Phone number with country code
  • about - Status message or bio
  • avatarUrl - Profile picture URL
  • isOnline - Real-time online status
  • lastSeenAt - ISO timestamp of last activity
  • favorite - Whether contact is favorited
  • pinned - Whether contact chats are pinned to top

Online status tracking

Contacts maintain real-time online status:

Status in chat header

The ChatHeader component displays online status with an indicator:
components/chat/chat-header.tsx
<div className="relative">
  <Avatar className="h-11 w-11 border border-border/60">
    <AvatarImage src={contact.avatarUrl} alt={contact.name} />
    <AvatarFallback>{initials(contact.name)}</AvatarFallback>
  </Avatar>
  <span
    className={cn(
      "absolute -right-0.5 -bottom-0.5 h-2.5 w-2.5 rounded-full border-2 border-background",
      isOnline ? "bg-accent" : "bg-muted"
    )}
  />
</div>
<div>
  <p className="text-sm font-semibold text-foreground">{contact.name}</p>
  <p className="text-xs text-muted-foreground">
    {isOnline ? "Online" : `last seen ${formatDistanceToNow(new Date(contact.lastSeenAt), { addSuffix: true })}`}
  </p>
</div>
The online indicator is a small colored dot: green for online, gray for offline.

Presence tracking

The system maintains a presence map for all contacts:
lib/chat/types.ts
export interface PresenceMap {
  [contactId: string]: {
    isOnline: boolean
    lastSeenAt: string
  }
}

Contact storage

Contacts are stored in a normalized structure:
lib/chat/types.ts
export interface ChatStateSnapshot {
  chats: Record<string, Chat>
  messages: Record<string, Message>
  contacts: Record<string, Contact>
  drafts: Record<string, DraftMessage>
  typingIndicators: TypingIndicator[]
  searchHistory: ChatSearchHistoryItem[]
  activeChatId?: string
}

Accessing contacts

Use the contacts selector to retrieve all contacts:
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),
}
Usage:
const contacts = useChatStore(chatSelectors.contacts)

Avatar display

Contact avatars use the shadcn/ui Avatar component:
components/chat/sidebar.tsx
<Avatar className="h-12 w-12 border border-border/60">
  <AvatarImage src={contact.avatarUrl} alt={contact.name} />
  <AvatarFallback>{initials(contact.name)}</AvatarFallback>
</Avatar>

Generating initials

Fallback initials are generated from the contact name:
components/chat/sidebar.tsx
function initials(value: string) {
  return value
    .split(" ")
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase())
    .join("")
}
Initials are generated from the first letter of the first two words in the name.

Favorite contacts

Mark contacts as favorites for quick access:
lib/chat/types.ts
export interface Contact {
  // ...
  favorite?: boolean
  pinned?: boolean
}

Favorite contact example

lib/chat/mock-data.ts
const contacts: Contact[] = [
  {
    id: "carlos",
    name: "Carlos Mendes",
    phoneNumber: "+55 11 98888-1122",
    about: "Product designer • Rio",
    avatarUrl: "https://i.pravatar.cc/120?img=12",
    isOnline: true,
    lastSeenAt: iso(subMinutes(baseTimestamp, 2)),
    favorite: true,
  },
  // ...
]
Favorite contacts can be filtered or displayed in a dedicated section of the UI.

Pinned contacts

Pinned contacts keep their chats at the top of the chat list:
lib/chat/mock-data.ts
{
  id: "sofia",
  name: "Sofia Patel",
  phoneNumber: "+44 7444 222333",
  about: "Mobile engineer • London",
  avatarUrl: "https://i.pravatar.cc/120?img=33",
  isOnline: false,
  lastSeenAt: iso(subMinutes(baseTimestamp, 12)),
  pinned: true,
}

User profile

The current user’s profile uses the Profile interface:
lib/chat/types.ts
export interface Profile {
  id: string
  name: string
  phoneNumber: string
  avatarUrl: string
  about: string
}

Profile in sidebar

The user’s profile is displayed at the top of the sidebar:
components/chat/sidebar.tsx
<header className="flex items-center justify-between px-5 pb-4 pt-5">
  <div className="flex items-center gap-3">
    <Avatar className="h-11 w-11 border border-border/60">
      <AvatarImage src={profile.avatarUrl} alt={profile.name} />
      <AvatarFallback>{initials(profile.name)}</AvatarFallback>
    </Avatar>
    <div>
      <p className="text-sm font-semibold text-sidebar-foreground">{profile.name}</p>
      <p className="text-xs text-muted-foreground">{profile.about}</p>
    </div>
  </div>
  <Button
    onClick={onStartNewChat}
    size="icon"
    variant="outline"
    className="h-10 w-10 rounded-full border-sidebar-border/80 bg-sidebar"
  >
    <Plus className="h-5 w-5" />
    <span className="sr-only">Start new chat</span>
  </Button>
</header>

Contact lookup

Retrieve contact information from chat data:
components/chat/sidebar.tsx
{filtered.map((chat) => {
  const contact = contacts[chat.contactId]
  if (!contact) return null
  // Render chat item
})}
Always check if a contact exists before rendering to handle edge cases where chat data may reference deleted contacts.

Mock contact data

Example contacts from the mock data:
lib/chat/mock-data.ts
const contacts: Contact[] = [
  {
    id: "carlos",
    name: "Carlos Mendes",
    phoneNumber: "+55 11 98888-1122",
    about: "Product designer • Rio",
    avatarUrl: "https://i.pravatar.cc/120?img=12",
    isOnline: true,
    lastSeenAt: iso(subMinutes(baseTimestamp, 2)),
    favorite: true,
  },
  {
    id: "sofia",
    name: "Sofia Patel",
    phoneNumber: "+44 7444 222333",
    about: "Mobile engineer • London",
    avatarUrl: "https://i.pravatar.cc/120?img=33",
    isOnline: false,
    lastSeenAt: iso(subMinutes(baseTimestamp, 12)),
    pinned: true,
  },
  {
    id: "li",
    name: "Li Wei",
    phoneNumber: "+86 139 8888 1212",
    about: "Traveling in Kyoto 🇯🇵",
    avatarUrl: "https://i.pravatar.cc/120?img=52",
    isOnline: true,
    lastSeenAt: iso(subMinutes(baseTimestamp, 1)),
  },
]
Filter contacts by name in the chat list:
components/chat/sidebar.tsx
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])

Last seen formatting

Display relative time for when a contact was last online:
components/chat/chat-header.tsx
<p className="text-xs text-muted-foreground">
  {isOnline ? "Online" : `last seen ${formatDistanceToNow(new Date(contact.lastSeenAt), { addSuffix: true })}`}
</p>
Examples:
  • “last seen 5 minutes ago”
  • “last seen 2 hours ago”
  • “last seen yesterday”

Normalized storage

Contacts are stored separately from chats and referenced by ID:
lib/chat/mock-data.ts
const initialState: ChatStateSnapshot = {
  chats: Object.fromEntries(
    Object.entries(chats).sort(([, a], [, b]) => (a.lastActivityAt > b.lastActivityAt ? -1 : 1))
  ),
  messages,
  contacts: contacts.reduce<Record<string, Contact>>((acc, contact) => {
    acc[contact.id] = contact
    return acc
  }, {}),
  drafts: {},
  typingIndicators: [],
  searchHistory: [],
  activeChatId: "chat-carlos",
}
This normalized structure allows efficient lookup and prevents data duplication across multiple chats with the same contact.

Build docs developers (and LLMs) love