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
+4
View File
@@ -59,6 +59,10 @@ export function listRoomAttachments(roomId: string): Promise<RoomAttachment[]> {
return apiFetch<RoomAttachment[]>(`/api/rooms/${roomId}/attachments`)
}
export function markRoomRead(roomId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/read`, { method: 'POST' })
}
export function addRoomMember(roomId: string, userId: string): Promise<RoomMember> {
return apiFetch<RoomMember>(`/api/rooms/${roomId}/members`, {
method: 'POST',
+29 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { NetworkError } from '../api/client'
import { getRoomMessages } from '../api/rooms'
import { getRoomMessages, markRoomRead } from '../api/rooms'
import type { ChatSocketHandle } from '../ws/useChatSocket'
import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types'
import { Composer } from './Composer'
@@ -15,9 +15,19 @@ interface ChatPaneProps {
onToggleInfo: () => void
infoOpen: boolean
socket: ChatSocketHandle
onRoomRead: (roomId: string) => void
}
export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOpen, socket }: ChatPaneProps) {
export function ChatPane({
room,
members,
isMobile,
onBack,
onToggleInfo,
infoOpen,
socket,
onRoomRead,
}: ChatPaneProps) {
const [history, setHistory] = useState<Message[]>([])
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
const [wsError, setWsError] = useState<string | null>(null)
@@ -56,6 +66,20 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
return () => socket.leaveRoom(room.id)
}, [socket, room.id])
const markRead = useCallback(() => {
// Live check, not a cached ref -- same reasoning as joinRoom's in
// useChatSocket.ts: a backgrounded-but-open tab must keep accumulating
// unread rather than auto-marking-read the instant a message arrives
// somewhere it can't actually be seen.
if (document.visibilityState !== 'visible') return
onRoomRead(room.id)
markRoomRead(room.id).catch(() => {
// Best-effort -- an unread dot lagging by one message isn't worth
// surfacing an error for; the next successful mark-read call (or a
// future refreshRooms()) resyncs it.
})
}, [room.id, onRoomRead])
useEffect(
() =>
// The socket is shared across every room this tab visits, so a
@@ -74,8 +98,10 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
// while this socket wasn't in the room's channel, so resync
// instead of trusting whatever's already in state.
refreshHistory()
markRead()
} else if (envelope.type === 'message' && envelope.room_id === room.id) {
setLive((prev) => [...prev, envelope])
markRead()
} else if (envelope.type === 'message_update' && envelope.room_id === room.id) {
setHistory((prev) =>
prev.map((m) =>
@@ -98,7 +124,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
setWsError(envelope.detail)
}
}),
[socket, room.id, refreshHistory],
[socket, room.id, refreshHistory, markRead],
)
const connected = socket.connected
+8
View File
@@ -50,3 +50,11 @@
text-overflow: ellipsis;
margin-top: 2px;
}
.room-row-unread-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ds-accent);
}
+1
View File
@@ -32,6 +32,7 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
</div>
{room.description && <div className="room-row-subtitle">{room.description}</div>}
</div>
{room.has_unread && !active && <span className="room-row-unread-dot" aria-label="Unread messages" />}
</Link>
)
}
+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 && (
+7
View File
@@ -37,6 +37,7 @@ export interface RoomListItem extends Room {
export interface MyRoomItem extends Room {
role: RoomRole
has_unread: boolean
}
export interface RoomMember {
@@ -138,6 +139,11 @@ export interface ChatMemberUpdatedEnvelope {
user_id: string
}
export interface ChatUnreadUpdateEnvelope {
type: 'unread_update'
room_id: string
}
export type ServerEnvelope =
| ChatMessageEnvelope
| ChatMessageUpdateEnvelope
@@ -146,6 +152,7 @@ export type ServerEnvelope =
| ChatErrorEnvelope
| ChatRoomAddedEnvelope
| ChatMemberUpdatedEnvelope
| ChatUnreadUpdateEnvelope
export interface AdminUser {
id: string