Private
Public Access
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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user