Files
ds-chat/backend/app/ws/connection_manager.py
T
ksmithandClaude Sonnet 5 7dcc7104df 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>
2026-08-16 10:29:06 -06:00

51 lines
1.9 KiB
Python

import uuid
from collections import defaultdict
from fastapi import WebSocket
class ConnectionManager:
"""Local, single-process WebSocket socket registry.
Purely about delivering to sockets connected to *this* process --
cross-instance fan-out lives in Broadcaster, and cross-instance "who's
connected" for push lives in Presence, both backed by Redis
(ARCHITECTURE.md phase 5).
"""
def __init__(self) -> None:
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
self._users: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms[room_id].add(websocket)
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms[room_id].discard(websocket)
if not self._rooms[room_id]:
del self._rooms[room_id]
def leave_all(self, websocket: WebSocket) -> None:
for room_id in list(self._rooms.keys()):
self.leave(room_id, websocket)
def register_user(self, user_id: uuid.UUID, websocket: WebSocket) -> None:
"""Ties a socket to the authenticated user who owns it, independent
of which (if any) room it has joined -- lets a user be reached the
instant they're added to a room, before they've ever joined that
room's channel."""
self._users[user_id].add(websocket)
def unregister_user(self, user_id: uuid.UUID, websocket: WebSocket) -> None:
self._users[user_id].discard(websocket)
if not self._users[user_id]:
del self._users[user_id]
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._rooms.get(room_id, ())):
await websocket.send_json(payload)
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._users.get(user_id, ())):
await websocket.send_json(payload)