Skip to content

Unread Message Badges

The messaging module tracks unread inbound messages per user and displays red count badges on each contact in the sidebar. When a new inbound message arrives for a contact you’re not viewing, a blue toast notification appears with the sender name and a message preview.

Tracks when each user last viewed each conversation.

ColumnTypeDescription
idUUIDPrimary key
organization_idUUIDFK → organization
user_idUUIDFK → user
contact_idUUIDFK → contact
last_read_atTIMESTAMPTZWhen the user last viewed this conversation
created_atTIMESTAMPTZRow creation time
updated_atTIMESTAMPTZLast update time

Unique constraint: (user_id, contact_id) — one read state per user-contact pair.

Read state is per-user. User A marking a conversation as read has no effect on User B’s unread count.

// Query params for batch unread counts
pub struct UnreadCountsQuery {
pub contact_ids: String, // comma-separated UUIDs
}
// Response: map of contact_id → unread inbound count
pub struct UnreadCountsResponse {
pub counts: HashMap<Uuid, u32>,
}

Contacts with zero unread messages are omitted from counts. Treat missing keys as 0.

Batch-fetches unread inbound message counts for the specified contacts.

Query params: ?contact_ids=uuid1,uuid2,uuid3

Response:

{
"counts": {
"550e8400-e29b-41d4-a716-446655440000": 3,
"7c9e6679-7425-40de-944b-e07fc1f90ae7": 1
}
}

Permissions: MessageCollectionPermission::List

The service deduplicates contact IDs and runs a single batch query with a LEFT JOIN on conversation_read_state:

SELECT m.contact_id, COUNT(*)::int AS unread_count
FROM message m
LEFT JOIN conversation_read_state crs
ON crs.contact_id = m.contact_id AND crs.user_id = $1
WHERE m.organization_id = $2
AND m.contact_id IN ($3, $4, ...)
AND m.direction = 'inbound'
AND m.created_at > COALESCE(crs.last_read_at, '1970-01-01T00:00:00Z')
GROUP BY m.contact_id

If no read state exists for a contact, COALESCE falls back to epoch — treating all inbound messages as unread.

Marks a conversation as read for the current user.

Request body: None
Response: 204 No Content

Permissions: MessageCollectionPermission::List + ContactInstanceAction::View on the target contact (prevents cross-org access).

Executes an upsert:

INSERT INTO conversation_read_state (id, organization_id, user_id, contact_id, last_read_at, ...)
VALUES (gen_random_uuid(), $1, $2, $3, now(), ...)
ON CONFLICT (user_id, contact_id) DO UPDATE SET last_read_at = now(), updated_at = now()

Each ContactRow in the messaging sidebar accepts an unread_count prop. When greater than zero, a red pill badge appears next to the contact name:

if unread_count > 0 {
span {
class: "bg-red-500 text-white text-[10px] font-semibold rounded-full \
min-w-[18px] h-[18px] flex items-center justify-center px-1 shrink-0",
"{unread_count}"
}
}

The active contact always shows 0 — its badge is suppressed since you’re already viewing it.

When you click a contact, the UI optimistically removes it from the unread map and fires the mark-read API in the background:

use_effect(use_reactive!(|active_contact_id| {
if let Some(cid) = active_contact_id {
unread_counts.with_mut(|m| { m.remove(&cid); }); // instant badge removal
spawn(async move {
let _ = mark_conversation_read_api(cid.to_string()).await;
});
}
}));

The component watches the real-time event stream for EVT_MESSAGE_NEW events. For each new inbound message:

  • Active contact — auto-marks as read silently (no toast, no badge).
  • Other contact — shows a blue info toast with the contact name and an 80-character body preview, then triggers a refresh of unread counts.
toaster.info(contact_name, body_preview);

Unread counts refetch automatically when:

  • The contact list loads or changes
  • A new EVT_MESSAGE_NEW event arrives (via refresh_key increment)

The migration creates the conversation_read_state table and seeds it so existing conversations start with zero unread messages. It inserts one row per (user, contact) pair for every contact with inbound messages, setting last_read_at = now().

Run with just migrate or cd migration && cargo run -- up.

FilePurpose
migration/src/m20260402_120000_create_conversation_read_state.rsTable + seed migration
src/mods/messaging/types/unread_count_type.rsUnreadCountsQuery, UnreadCountsResponse
src/mods/messaging/api/get_unread_counts_api.rsBatch unread counts endpoint
src/mods/messaging/api/mark_conversation_read_api.rsMark-read endpoint
src/mods/messaging/services/get_unread_counts_service.rsBatch query service
src/mods/messaging/services/mark_conversation_read_service.rsUpsert service
src/mods/messaging/components/messaging_contact_list_component.rsBadges, toasts, auto-mark-read
src/shared/types/toast_type.rsToastVariant::Info
src/shared/context/toaster_context.rsToaster::info()