diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index 8260128..7a33a2e 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -23,8 +23,11 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp const [wsError, setWsError] = useState(null) const [historyUnavailableOffline, setHistoryUnavailableOffline] = useState(false) - useEffect(() => { - setHistory([]) + 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) @@ -39,6 +42,15 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp }) }, [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) @@ -53,7 +65,16 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp // that misattributing one to the wrong room's banner isn't worth // guarding against separately. socket.subscribe((envelope: ServerEnvelope) => { - if (envelope.type === 'message' && envelope.room_id === room.id) { + 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() + } else if (envelope.type === 'message' && envelope.room_id === room.id) { setLive((prev) => [...prev, envelope]) } else if (envelope.type === 'message_update' && envelope.room_id === room.id) { setHistory((prev) => @@ -77,7 +98,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp setWsError(envelope.detail) } }), - [socket, room.id], + [socket, room.id, refreshHistory], ) const connected = socket.connected diff --git a/frontend/src/ws/useChatSocket.ts b/frontend/src/ws/useChatSocket.ts index a7bba92..d3d608e 100644 --- a/frontend/src/ws/useChatSocket.ts +++ b/frontend/src/ws/useChatSocket.ts @@ -20,7 +20,23 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { const onUnauthenticatedRef = useRef(onUnauthenticated) onUnauthenticatedRef.current = onUnauthenticated const subscribersRef = useRef(new Set<(envelope: ServerEnvelope) => void>()) - const joinedRoomsRef = useRef(new Set()) + // Rooms the app *wants* joined (set via joinRoom/leaveRoom) -- distinct + // from whether the server currently has this connection joined, which is + // additionally gated on document visibility below. A backgrounded tab + // stays technically connected but tells the server "leave" for every + // desired room, so the server's existing offline-push logic (which keys + // off room presence, not raw connection state) correctly treats a + // backgrounded user the same as a disconnected one instead of assuming a + // live WebSocket delivery the user can't actually see will do the job. + const desiredRoomsRef = useRef(new Set()) + const isVisibleRef = useRef(document.visibilityState === 'visible') + + const sendRoomFrame = useCallback((type: 'join' | 'leave', roomId: string) => { + const ws = socketRef.current + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type, room_id: roomId })) + } + }, []) useEffect(() => { let stopped = false @@ -43,9 +59,14 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { reconnectDelay = RECONNECT_BASE_DELAY_MS setConnected(true) // Re-join whatever rooms were joined before a reconnect -- the - // server has no memory of a dropped connection's prior state. - for (const roomId of joinedRoomsRef.current) { - ws.send(JSON.stringify({ type: 'join', room_id: roomId })) + // server has no memory of a dropped connection's prior state. Only + // while visible: reconnecting from a backgrounded tab should stay + // "left" for the same reason backgrounding leaves in the first + // place (see desiredRoomsRef's comment above). + if (isVisibleRef.current) { + for (const roomId of desiredRoomsRef.current) { + sendRoomFrame('join', roomId) + } } } @@ -89,7 +110,20 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { socketRef.current?.close() socketRef.current = null } - }, []) + }, [sendRoomFrame]) + + useEffect(() => { + function handleVisibilityChange() { + const visible = document.visibilityState === 'visible' + if (visible === isVisibleRef.current) return + isVisibleRef.current = visible + for (const roomId of desiredRoomsRef.current) { + sendRoomFrame(visible ? 'join' : 'leave', roomId) + } + } + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, [sendRoomFrame]) const subscribe = useCallback((handler: (envelope: ServerEnvelope) => void) => { subscribersRef.current.add(handler) @@ -98,21 +132,21 @@ export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) { } }, []) - const joinRoom = useCallback((roomId: string) => { - joinedRoomsRef.current.add(roomId) - const ws = socketRef.current - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'join', room_id: roomId })) - } - }, []) + const joinRoom = useCallback( + (roomId: string) => { + desiredRoomsRef.current.add(roomId) + if (isVisibleRef.current) sendRoomFrame('join', roomId) + }, + [sendRoomFrame], + ) - const leaveRoom = useCallback((roomId: string) => { - joinedRoomsRef.current.delete(roomId) - const ws = socketRef.current - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'leave', room_id: roomId })) - } - }, []) + const leaveRoom = useCallback( + (roomId: string) => { + desiredRoomsRef.current.delete(roomId) + if (isVisibleRef.current) sendRoomFrame('leave', roomId) + }, + [sendRoomFrame], + ) const send = useCallback((roomId: string, content: string, imageId?: string, fileId?: string) => { const ws = socketRef.current