Private
Public Access
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>
191 lines
7.3 KiB
TypeScript
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>
|
|
)
|
|
}
|