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
+18 -2
View File
@@ -12,7 +12,12 @@ from app.ws.presence import Presence
async def _notify_offline_members(
db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, message: Message
db: AsyncSession,
broadcaster: Broadcaster,
presence: Presence,
room_id: uuid.UUID,
sender: User,
message: Message,
) -> None:
result = await db.execute(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
@@ -26,6 +31,17 @@ async def _notify_offline_members(
if not offline_ids:
return
# This is also exactly the right audience for "give this room an unread
# dot": presence.connected_user_ids(room_id) means "has this room's
# channel joined right now" -- which the client only does while the tab
# is genuinely foregrounded (see useChatSocket.ts's visibility-gated
# join/leave), so a backgrounded-but-open room correctly lands here too,
# not just rooms that aren't open at all.
for user_id in offline_ids:
await broadcaster.publish_to_user(
user_id, {"type": "unread_update", "room_id": str(room_id)}
)
room = await db.get(Room, room_id)
if message.content:
body = f"{sender.username}: {message.content}"[:120]
@@ -81,7 +97,7 @@ async def broadcast_new_message(
trigger identical fan-out/push/event behavior."""
payload = await _message_payload(db, message, sender.username)
await broadcaster.publish(room_id, payload)
await _notify_offline_members(db, presence, room_id, sender, message)
await _notify_offline_members(db, broadcaster, presence, room_id, sender, message)
await dispatch_event(db, "message.created", room_id, payload)