Skip to main content

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.

RoutePurpose
/messagesMessaging inbox (shared by all roles)
/messages/[conversationId]Direct link to a specific conversation
/messages?conversationId=xxxDeep-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.

Previous routing

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

StateTypePurpose
conversationsConversation[]All user conversations (pinned sorted first)
messagesMessage[]Messages in active conversation (oldest first)
activeConversationConversation | nullCurrently selected conversation
isConnectedboolean | nullWebSocket connection status
searchQuerystringConversation search filter
presenceMapMap<string, PresenceInfo>Online/offline status per user
activeTypingUsersTypingIndicator[]Users currently typing in active conversation
sendingMessagebooleanWhether a send is in flight
errorstring | nullLast error message

Operations

CategoryMethods
MessagessendMessage, editMessage, deleteMessage
ConversationscreateDirectConversation, createGroupConversation, markAsRead
Conversation ActionspinConversation, muteConversation, archiveConversation, flagConversation, markAsUnread
Group ManagementupdateGroupTitle, addGroupParticipants, removeGroupParticipant, leaveGroupConversation
Real-timeWebSocket subscriptions for messages, typing, presence
SearchsearchConversations — filter by content/name
PaginationloadMoreConversations, loadMoreMessages

Company Context Switch

When the user switches companies (companyId changes), the hook automatically resets:

  • activeConversationIdnull
  • searchQuery""
  • presenceMap → empty
  • optimisticMessages → 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

ComponentFilePurpose
MessagesLayoutcomponents/messaging/MessagesLayout.tsxShared layout for both pages (conversation list + thread + empty state + new conversation modal)
ConversationListcomponents/messaging/ConversationList.tsxScrollable list with search, pinned-first sorting, unread badges, presence dots
ConversationItemcomponents/messaging/ConversationItem.tsxSingle conversation preview (avatar, last message, unread count, context menu)
MessageThreadcomponents/messaging/MessageThread.tsxFull conversation thread with typing indicator, presence, group management
MessageBubblecomponents/messaging/MessageBubble.tsxIndividual message display with edit/delete/reply actions
MessageInputcomponents/messaging/MessageInput.tsxMessage composition with typing indicator emission
NewConversationModalcomponents/messaging/NewConversationModal.tsxCreate new direct or group conversation
TypingIndicatorcomponents/messaging/TypingIndicator.tsxAnimated "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)
  • mobileShowThread state 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:

SubscriptionPurpose
messageReceivedNew, edited, and deleted messages (includes sender echo with isSelf flag)
typingIndicatorChangedTyping start/stop events per user per conversation
presenceChangedOnline/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 — sendTypingIndicator mutation 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: contactPresence query seeds the presenceMap with current status for all conversation participants
  • Real-time: presenceChanged subscription updates the map as users come online/offline
  • Heartbeat: The hook sends a heartbeat mutation every 60 seconds to maintain online status (stored in a ref to prevent interval resets)
  • Offline signal: On page close, a goOffline mutation fires via fetch with keepalive: true

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=xxx on conversation click
  • Keyboard accessible: Escape to close, Enter/Space on items, aria-expanded/aria-haspopup on trigger

Data Flow

Company Scoping

When an HR person messages a candidate:

  • HR view: Conversation has companyId set → visible in their company-scoped list
  • Candidate view: No companyId filter → 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