Files
ds-chat/frontend/src/components/ChatPane.tsx
T
ksmithandClaude Sonnet 5 68e487e5ec 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>
2026-08-16 20:44:20 -06:00

191 lines
7.3 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import { NetworkError } from '../api/client'
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'
import { MessageList } from './MessageList'
import './ChatPane.css'
interface ChatPaneProps {
room: MyRoomItem
members: RoomMember[]
isMobile: boolean
onBack: () => void
onToggleInfo: () => void
infoOpen: boolean
socket: ChatSocketHandle
onRoomRead: (roomId: string) => void
}
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)
const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false)
const refreshHistory = useCallback(() => {
// live is cleared alongside history, not just on room switch: it's
// superseded by this fetch fully replacing history with the current
// authoritative list, so anything already in live would otherwise
// render twice once history was fetched.
setLive([])
setWsError(null)
setHistoryUnavailableOffline(false)
getRoomMessages(room.id)
.then(setHistory)
.catch((err) => {
if (err instanceof NetworkError) {
setHistoryUnavailableOffline(true)
} else {
setWsError(String(err))
}
})
}, [room.id])
useEffect(() => {
// Blanks the previous room's messages immediately, rather than leaving
// them on screen until the fetch resolves -- refreshHistory itself
// deliberately doesn't do this (a resync on the *same* room shouldn't
// flash empty while refetching).
setHistory([])
refreshHistory()
}, [room.id, refreshHistory])
useEffect(() => {
socket.joinRoom(room.id)
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
// stray in-flight event for a room just left (or a different tab's
// room, in theory) has to be filtered out here rather than assumed
// away -- `error` has no room_id to filter on, but is rare enough
// that misattributing one to the wrong room's banner isn't worth
// guarding against separately.
socket.subscribe((envelope: ServerEnvelope) => {
if (envelope.type === 'joined' && envelope.room_id === room.id) {
// Fires on initial join (a harmless redundant fetch right after
// the mount effect's own) and, more importantly, on every
// rejoin -- coming back from a backgrounded tab (see
// useChatSocket's visibility handling) or reconnecting after a
// dropped connection. Either way, messages could have arrived
// 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) =>
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
),
)
setLive((prev) =>
prev.map((m) =>
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
),
)
} else if (envelope.type === 'reaction_update' && envelope.room_id === room.id) {
setHistory((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
)
setLive((prev) =>
prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
)
} else if (envelope.type === 'error') {
setWsError(envelope.detail)
}
}),
[socket, room.id, refreshHistory, markRead],
)
const connected = socket.connected
const send = useCallback(
(content: string, imageId?: string, fileId?: string) => socket.send(room.id, content, imageId, fileId),
[socket, room.id],
)
const sendEdit = useCallback(
(messageId: string, content: string) => socket.sendEdit(room.id, messageId, content),
[socket, room.id],
)
const sendReaction = useCallback(
(messageId: string, emoji: string) => socket.sendReaction(room.id, messageId, emoji),
[socket, room.id],
)
return (
<section className="chat-pane">
<header className="chat-pane-header">
{isMobile && (
<button type="button" className="chat-pane-back" onClick={onBack} aria-label="Back to rooms">
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<polyline points="14,4 6,10 14,16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<div className="chat-pane-title-block">
<div className="chat-pane-title">#{room.name}</div>
<div className="chat-pane-subtitle">{members.length} member{members.length === 1 ? '' : 's'}</div>
</div>
<button
type="button"
className={`chat-pane-info-btn${infoOpen ? ' chat-pane-info-btn-active' : ''}`}
onClick={onToggleInfo}
aria-label="Room details"
aria-pressed={infoOpen}
>
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="8" stroke="currentColor" strokeWidth="1.6" />
<circle cx="10" cy="6.4" r="1" fill="currentColor" />
<rect x="9" y="9" width="2" height="6" rx="1" fill="currentColor" />
</svg>
</button>
</header>
{wsError && <p className="chat-pane-error">{wsError}</p>}
{historyUnavailableOffline && (
<p className="chat-pane-error chat-pane-note">
Message history for this room isn't available offline yet.
</p>
)}
<MessageList
roomId={room.id}
messages={[...history, ...live]}
members={members}
onEdit={sendEdit}
onReact={sendReaction}
/>
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
</section>
)
}