Add unread message indicators for rooms (#38)

Shows a dot on rooms with unread messages in the sidebar, updated live
over WebSocket. Reuses the same offline-member audience computation
already used for push notifications: a member gets the real-time signal
whenever they aren't currently connected to that room's channel, which
correctly covers both "room not open" and "room open but tab
backgrounded" (the client leaves a room's channel while hidden).

Persisted server-side via a new room_memberships.last_read_at column so
state survives reload and stays consistent across devices, advanced by
an explicit mark-read call the frontend makes on room-open and on each
live message received while the room is genuinely visible -- gated on a
live visibility check, not a cached ref, so a backgrounded-but-open room
keeps accumulating unread instead of auto-marking-read the instant a
message arrives somewhere it can't be seen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 20:44:20 -06:00
co-authored by Claude Sonnet 5
parent 974d92ab4d
commit 68e487e5ec
14 changed files with 336 additions and 11 deletions
+10
View File
@@ -21,6 +21,7 @@ from app.services.message_service import (
edit_message,
toggle_reaction,
)
from app.services.room_service import mark_room_read
router = APIRouter(tags=["ws"])
@@ -162,6 +163,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
message = await create_message(
db, envelope.room_id, user.id, envelope.content, image_id, file_id
)
# Sending implies having seen the room as of now -- without
# this, GET /rooms/mine would show the sender's own room as
# unread the instant they send into it (last_read_at isn't
# otherwise bumped until the frontend's own message echo
# triggers a mark-read call, which is a real but avoidable
# race). Deliberately not done in create_message() itself:
# the incoming-webhook path also calls it, and a webhook's
# attributed sender may not actually be watching.
await mark_room_read(db, envelope.room_id, user.id)
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
elif envelope.type == "edit":