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.
Data Model
Section titled “Data Model”conversation_read_state Table
Section titled “conversation_read_state Table”Tracks when each user last viewed each conversation.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
organization_id | UUID | FK → organization |
user_id | UUID | FK → user |
contact_id | UUID | FK → contact |
last_read_at | TIMESTAMPTZ | When the user last viewed this conversation |
created_at | TIMESTAMPTZ | Row creation time |
updated_at | TIMESTAMPTZ | Last 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.
Request/Response Types
Section titled “Request/Response Types”// Query params for batch unread countspub struct UnreadCountsQuery { pub contact_ids: String, // comma-separated UUIDs}
// Response: map of contact_id → unread inbound countpub struct UnreadCountsResponse { pub counts: HashMap<Uuid, u32>,}Contacts with zero unread messages are omitted from counts. Treat missing keys as 0.
API Endpoints
Section titled “API Endpoints”GET /api/messages/unread-counts
Section titled “GET /api/messages/unread-counts”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_countFROM message mLEFT JOIN conversation_read_state crs ON crs.contact_id = m.contact_id AND crs.user_id = $1WHERE 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_idIf no read state exists for a contact, COALESCE falls back to epoch — treating all inbound messages as unread.
PUT /api/messages/:contact_id/read
Section titled “PUT /api/messages/:contact_id/read”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()UI Behavior
Section titled “UI Behavior”Badge Display
Section titled “Badge Display”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.
Auto-Mark-Read
Section titled “Auto-Mark-Read”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; }); }}));Toast Notifications
Section titled “Toast Notifications”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);Refresh Triggers
Section titled “Refresh Triggers”Unread counts refetch automatically when:
- The contact list loads or changes
- A new
EVT_MESSAGE_NEWevent arrives (viarefresh_keyincrement)
Migration
Section titled “Migration”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.
Key Files
Section titled “Key Files”| File | Purpose |
|---|---|
migration/src/m20260402_120000_create_conversation_read_state.rs | Table + seed migration |
src/mods/messaging/types/unread_count_type.rs | UnreadCountsQuery, UnreadCountsResponse |
src/mods/messaging/api/get_unread_counts_api.rs | Batch unread counts endpoint |
src/mods/messaging/api/mark_conversation_read_api.rs | Mark-read endpoint |
src/mods/messaging/services/get_unread_counts_service.rs | Batch query service |
src/mods/messaging/services/mark_conversation_read_service.rs | Upsert service |
src/mods/messaging/components/messaging_contact_list_component.rs | Badges, toasts, auto-mark-read |
src/shared/types/toast_type.rs | ToastVariant::Info |
src/shared/context/toaster_context.rs | Toaster::info() |