Messaging
The messaging system provides direct and group messaging between users with real-time updates via WebSocket subscriptions, typing indicators, and online presence tracking.
Routing
Messaging uses a unified route under app/(shared)/messages/ — both company users and job seekers share the same page. The companyId is derived from user.selectedCompanyId (optional), so conversations are automatically scoped to the active company context when present.
| Route | Purpose |
|---|---|
/messages | Messaging inbox (shared by all roles) |
/messages/[conversationId] | Direct link to a specific conversation |
/messages?conversationId=xxx | Deep-link via query param (from navbar dropdown) |
The [conversationId] sub-route defaults mobileShowThread to true so mobile users land directly on the thread instead of the conversation list.
Both pages are wrapped with AuthGuard requiredRole="any" for authentication without role restriction. The (shared) layout has no AuthGuard of its own.
Before unification, messaging had separate routes under /company/messages and /jobseeker/messages. These routes no longer exist. The navbar and all internal links now point to /messages.
useMessaging Hook
File: lib/hooks/useMessaging.ts
Central hook for all messaging operations. Accepts optional { companyId } to scope conversations.
// Company user — scoped to active company
const messaging = useMessaging({ companyId: user.selectedCompanyId })
// Job seeker — no company scope
const messaging = useMessaging()
State
| State | Type | Purpose |
|---|---|---|
conversations | Conversation[] | All user conversations (pinned sorted first) |
messages | Message[] | Messages in active conversation (oldest first) |
activeConversation | Conversation | null | Currently selected conversation |
isConnected | boolean | null | WebSocket connection status |
searchQuery | string | Conversation search filter |
presenceMap | Map<string, PresenceInfo> | Online/offline status per user |
activeTypingUsers | TypingIndicator[] | Users currently typing in active conversation |
sendingMessage | boolean | Whether a send is in flight |
error | string | null | Last error message |
Operations
| Category | Methods |
|---|---|
| Messages | sendMessage, editMessage, deleteMessage |
| Conversations | createDirectConversation, createGroupConversation, markAsRead |
| Conversation Actions | pinConversation, muteConversation, archiveConversation, flagConversation, markAsUnread |
| Group Management | updateGroupTitle, addGroupParticipants, removeGroupParticipant, leaveGroupConversation |
| Real-time | WebSocket subscriptions for messages, typing, presence |
| Search | searchConversations — filter by content/name |
| Pagination | loadMoreConversations, loadMoreMessages |
Company Context Switch
When the user switches companies (companyId changes), the hook automatically resets:
activeConversationId→nullsearchQuery→""presenceMap→ emptyoptimisticMessages→ empty- All pending typing indicator timers are cleared
This prevents cross-company data leakage — conversations from the previous company are never visible in the new context.
Components
| Component | File | Purpose |
|---|---|---|
| MessagesLayout | components/messaging/MessagesLayout.tsx | Shared layout for both pages (conversation list + thread + empty state + new conversation modal) |
| ConversationList | components/messaging/ConversationList.tsx | Scrollable list with search, pinned-first sorting, unread badges, presence dots |
| ConversationItem | components/messaging/ConversationItem.tsx | Single conversation preview (avatar, last message, unread count, context menu) |
| MessageThread | components/messaging/MessageThread.tsx | Full conversation thread with typing indicator, presence, group management |
| MessageBubble | components/messaging/MessageBubble.tsx | Individual message display with edit/delete/reply actions |
| MessageInput | components/messaging/MessageInput.tsx | Message composition with typing indicator emission |
| NewConversationModal | components/messaging/NewConversationModal.tsx | Create new direct or group conversation |
| TypingIndicator | components/messaging/TypingIndicator.tsx | Animated "X is typing..." display |
MessagesLayout
Both /messages and /messages/[conversationId] use MessagesLayout to avoid code duplication. The pages only handle:
- Route-specific logic (query params vs path params)
mobileShowThreadstate management- Auto-selecting the initial conversation
Everything else (ConversationList, MessageThread, NewConversationModal, presence computation) lives in MessagesLayout.
Real-Time Updates
Three WebSocket subscriptions power the real-time features:
| Subscription | Purpose |
|---|---|
messageReceived | New, edited, and deleted messages (includes sender echo with isSelf flag) |
typingIndicatorChanged | Typing start/stop events per user per conversation |
presenceChanged | Online/offline status changes |
All subscriptions use the shared backendWsClient singleton — one WebSocket connection shared across all hooks.
Optimistic Rendering
Incoming WebSocket messages are displayed instantly via local optimisticMessages state, without waiting for the Apollo cache refetch. When the Apollo query returns, optimistic messages are deduped by id and pruned. A secondary refetch fires after 500ms to handle race conditions.
Connection Status
The messages page shows a reconnecting banner when the WebSocket disconnects:
┌─────────────────────────────────────┐
│ 🟡 Reconnecting... │
├─────────────────────────────────────┤
│ Conversation list │
The page description switches between "Live" and "Connecting..." based on isConnected.
Typing Indicators
- Sending: Debounced at 2.5 seconds —
sendTypingIndicatormutation fires at most every 2.5s while the user types - Receiving: Each indicator auto-expires after 4 seconds via
setTimeout. When a message arrives from the same user, their typing indicator is cleared immediately - Cleanup: All pending typing timers are cleared on unmount and company switch to prevent memory leaks
Online Presence
- Initial load:
contactPresencequery seeds thepresenceMapwith current status for all conversation participants - Real-time:
presenceChangedsubscription updates the map as users come online/offline - Heartbeat: The hook sends a
heartbeatmutation every 60 seconds to maintain online status (stored in a ref to prevent interval resets) - Offline signal: On page close, a
goOfflinemutation fires viafetchwithkeepalive: true
Navbar Badge
The useUnreadMessageCount hook polls unreadMessageCount every 30 seconds with optional companyId filtering.
NavbarMessages (components/navigation/NavbarMessages.tsx) shows:
- Unread count badge on the message icon
- Dropdown popover with recent conversations (fetched on open)
- Deep-links to
/messages?conversationId=xxxon conversation click - Keyboard accessible: Escape to close, Enter/Space on items,
aria-expanded/aria-haspopupon trigger
Data Flow
Company Scoping
When an HR person messages a candidate:
- HR view: Conversation has
companyIdset → visible in their company-scoped list - Candidate view: No
companyIdfilter → sees all conversations they participate in, including the HR conversation - HR switches company: Hook resets, new company's conversations load, old conversation disappears from the list