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([]) const [live, setLive] = useState([]) const [wsError, setWsError] = useState(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 (
{isMobile && ( )}
#{room.name}
{members.length} member{members.length === 1 ? '' : 's'}
{wsError &&

{wsError}

} {historyUnavailableOffline && (

Message history for this room isn't available offline yet.

)}
) }