Push a live signal when a user is added to a room (#26)

Previously GET /api/rooms/mine was only ever fetched once at app mount,
so a room added mid-session stayed invisible until a full page reload
-- add_member had no way to reach an already-open client at all.

Backend: ConnectionManager and Broadcaster (renamed from RoomBroadcaster)
now support per-user channels alongside the existing per-room ones, so a
signal can reach a user's socket even for a room they haven't joined
(and by definition can't have, until this fires). add_member publishes
a room_added event on the target user's channel.

Frontend: the WebSocket connection is no longer scoped to whichever
room is open -- ChatShellPage now owns one persistent connection for
the whole session (including while no room is open, which is exactly
when this bug showed), and ChatPane joins/leaves rooms on top of it.
A room_added event triggers a room-list refetch with no reload needed.

Verified end-to-end in the browser: a user sitting on the empty room
list saw a newly-added room appear live, then chatted in it normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:29:06 -06:00
co-authored by Claude Sonnet 5
parent c9d61c3d12
commit 7dcc7104df
11 changed files with 222 additions and 64 deletions
+55 -31
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { NetworkError } from '../api/client'
import { getRoomMessages } from '../api/rooms'
import { useChatSocket } from '../ws/useChatSocket'
import type { ChatSocketHandle } from '../ws/useChatSocket'
import type { ChatMessageEnvelope, Message, MyRoomItem, RoomMember, ServerEnvelope } from '../types'
import { Composer } from './Composer'
import { MessageList } from './MessageList'
@@ -15,10 +14,10 @@ interface ChatPaneProps {
onBack: () => void
onToggleInfo: () => void
infoOpen: boolean
socket: ChatSocketHandle
}
export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOpen }: ChatPaneProps) {
const navigate = useNavigate()
export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOpen, socket }: ChatPaneProps) {
const [history, setHistory] = useState<Message[]>([])
const [live, setLive] = useState<ChatMessageEnvelope[]>([])
const [wsError, setWsError] = useState<string | null>(null)
@@ -40,35 +39,60 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
})
}, [room.id])
const onMessage = useCallback((envelope: ServerEnvelope) => {
if (envelope.type === 'message') {
setLive((prev) => [...prev, envelope])
} else if (envelope.type === 'message_update') {
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') {
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)
}
}, [])
useEffect(() => {
socket.joinRoom(room.id)
return () => socket.leaveRoom(room.id)
}, [socket, room.id])
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
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 === 'message' && envelope.room_id === room.id) {
setLive((prev) => [...prev, envelope])
} 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],
)
const { connected, send, sendEdit, sendReaction } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
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">
+13
View File
@@ -12,6 +12,7 @@ import { TopBar } from '../components/TopBar'
import { useAuth } from '../context/AuthContext'
import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import type { MyRoomItem, RoomMember } from '../types'
import { useChatSocket } from '../ws/useChatSocket'
import './ChatShellPage.css'
type ModalKind = 'new' | 'browse' | null
@@ -56,6 +57,17 @@ export function ChatShellPage() {
refreshRooms().catch(() => {})
}, [refreshRooms])
const onSocketUnauthenticated = useCallback(() => navigate('/login'), [navigate])
const socket = useChatSocket({ onUnauthenticated: onSocketUnauthenticated })
useEffect(
() =>
socket.subscribe((envelope) => {
if (envelope.type === 'room_added') refreshRooms()
}),
[socket, refreshRooms],
)
useEffect(() => {
refreshMembers()
// Also re-run when the logged-in user's own profile changes (display
@@ -97,6 +109,7 @@ export function ChatShellPage() {
onBack={() => navigate('/rooms')}
onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen}
socket={socket}
/>
) : (
!isMobile && (
+6
View File
@@ -114,12 +114,18 @@ export interface ChatErrorEnvelope {
detail: string
}
export interface ChatRoomAddedEnvelope {
type: 'room_added'
room_id: string
}
export type ServerEnvelope =
| ChatMessageEnvelope
| ChatMessageUpdateEnvelope
| ChatReactionUpdateEnvelope
| ChatJoinedEnvelope
| ChatErrorEnvelope
| ChatRoomAddedEnvelope
export interface AdminUser {
id: string
+49 -15
View File
@@ -2,21 +2,25 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { ServerEnvelope } from '../types'
interface UseChatSocketOptions {
roomId: string
onMessage: (envelope: ServerEnvelope) => void
onUnauthenticated: () => void
}
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30000
export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatSocketOptions) {
// One connection per authenticated session, established as soon as the app
// shell mounts -- not per-room. A room is just something this socket can be
// told to "join"/"leave" while it's open; the connection itself persists
// across room switches and while no room is open at all, since a per-user
// signal (e.g. "you were added to a room") has to reach the client whether
// or not any room is currently open.
export function useChatSocket({ onUnauthenticated }: UseChatSocketOptions) {
const socketRef = useRef<WebSocket | null>(null)
const [connected, setConnected] = useState(false)
const onMessageRef = useRef(onMessage)
onMessageRef.current = onMessage
const onUnauthenticatedRef = useRef(onUnauthenticated)
onUnauthenticatedRef.current = onUnauthenticated
const subscribersRef = useRef(new Set<(envelope: ServerEnvelope) => void>())
const joinedRoomsRef = useRef(new Set<string>())
useEffect(() => {
let stopped = false
@@ -38,12 +42,17 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
if (socketRef.current !== ws) return
reconnectDelay = RECONNECT_BASE_DELAY_MS
setConnected(true)
ws.send(JSON.stringify({ type: 'join', room_id: roomId }))
// 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 }))
}
}
ws.onmessage = (event) => {
if (socketRef.current !== ws) return
onMessageRef.current(JSON.parse(event.data) as ServerEnvelope)
const envelope = JSON.parse(event.data) as ServerEnvelope
for (const handler of subscribersRef.current) handler(envelope)
}
ws.onclose = (event) => {
@@ -80,9 +89,32 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
socketRef.current?.close()
socketRef.current = null
}
}, [roomId])
}, [])
const send = useCallback((content: string, imageId?: string, fileId?: string) => {
const subscribe = useCallback((handler: (envelope: ServerEnvelope) => void) => {
subscribersRef.current.add(handler)
return () => {
subscribersRef.current.delete(handler)
}
}, [])
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 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 send = useCallback((roomId: string, content: string, imageId?: string, fileId?: string) => {
const ws = socketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(
@@ -94,19 +126,21 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
file_id: fileId ?? null,
}),
)
}, [roomId])
}, [])
const sendEdit = useCallback((messageId: string, content: string) => {
const sendEdit = useCallback((roomId: string, messageId: string, content: string) => {
const ws = socketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
}, [roomId])
}, [])
const sendReaction = useCallback((messageId: string, emoji: string) => {
const sendReaction = useCallback((roomId: string, messageId: string, emoji: string) => {
const ws = socketRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'reaction', room_id: roomId, message_id: messageId, emoji }))
}, [roomId])
}, [])
return { connected, send, sendEdit, sendReaction }
return { connected, subscribe, joinRoom, leaveRoom, send, sendEdit, sendReaction }
}
export type ChatSocketHandle = ReturnType<typeof useChatSocket>