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
+7 -1
View File
@@ -59,13 +59,18 @@ export function ChatShellPage() {
const socket = useChatSocketContext()
const setRoomUnread = useCallback((id: string, hasUnread: boolean) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: hasUnread } : r)))
}, [])
useEffect(
() =>
socket.subscribe((envelope) => {
if (envelope.type === 'room_added') refreshRooms()
else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers()
else if (envelope.type === 'unread_update') setRoomUnread(envelope.room_id, true)
}),
[socket, refreshRooms, refreshMembers, roomId],
[socket, refreshRooms, refreshMembers, roomId, setRoomUnread],
)
useEffect(() => {
@@ -110,6 +115,7 @@ export function ChatShellPage() {
onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen}
socket={socket}
onRoomRead={(id) => setRoomUnread(id, false)}
/>
) : (
!isMobile && (