Private
Public Access
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:
@@ -6,16 +6,24 @@ from redis.asyncio import Redis
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
|
||||
ROOM_CHANNEL_PREFIX = "room:"
|
||||
USER_CHANNEL_PREFIX = "user:"
|
||||
|
||||
|
||||
class RoomBroadcaster:
|
||||
class Broadcaster:
|
||||
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
|
||||
|
||||
Publishes to a per-room Redis channel; every app instance -- including
|
||||
the one that published -- subscribes via a single pattern subscription
|
||||
and forwards to its own locally connected WebSocket clients via
|
||||
ConnectionManager. A single instance just talks to itself through Redis,
|
||||
so there's no separate code path for the 1-instance vs N-instance case.
|
||||
Publishes to a per-room or per-user Redis channel; every app instance --
|
||||
including the one that published -- subscribes via a single pattern
|
||||
subscription and forwards to its own locally connected WebSocket clients
|
||||
via ConnectionManager. A single instance just talks to itself through
|
||||
Redis, so there's no separate code path for the 1-instance vs N-instance
|
||||
case.
|
||||
|
||||
Room channels carry anything scoped to a room's joined members (new
|
||||
messages, edits, reactions). User channels carry anything scoped to one
|
||||
person regardless of which rooms they've joined -- currently just
|
||||
"you've been added to a room," which by definition arrives before the
|
||||
recipient could ever have joined that room's own channel.
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis, manager: ConnectionManager) -> None:
|
||||
@@ -25,17 +33,24 @@ class RoomBroadcaster:
|
||||
async def publish(self, room_id: uuid.UUID, payload: dict) -> None:
|
||||
await self._redis.publish(f"{ROOM_CHANNEL_PREFIX}{room_id}", json.dumps(payload))
|
||||
|
||||
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
||||
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
|
||||
|
||||
async def listen(self) -> None:
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*")
|
||||
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] != "pmessage":
|
||||
continue
|
||||
channel = message["channel"]
|
||||
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
||||
payload = json.loads(message["data"])
|
||||
await self._manager.broadcast(room_id, payload)
|
||||
if channel.startswith(ROOM_CHANNEL_PREFIX):
|
||||
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
|
||||
await self._manager.broadcast(room_id, payload)
|
||||
elif channel.startswith(USER_CHANNEL_PREFIX):
|
||||
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
|
||||
await self._manager.send_to_user(user_id, payload)
|
||||
finally:
|
||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*")
|
||||
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
|
||||
await pubsub.aclose()
|
||||
|
||||
@@ -78,6 +78,7 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
presence = websocket.app.state.presence
|
||||
broadcaster = websocket.app.state.broadcaster
|
||||
joined_rooms: set[uuid.UUID] = set()
|
||||
manager.register_user(user.id, websocket)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -225,5 +226,6 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
||||
pass
|
||||
finally:
|
||||
manager.leave_all(websocket)
|
||||
manager.unregister_user(user.id, websocket)
|
||||
for room_id in joined_rooms:
|
||||
await presence.leave(room_id, user.id)
|
||||
|
||||
@@ -8,13 +8,14 @@ class ConnectionManager:
|
||||
"""Local, single-process WebSocket socket registry.
|
||||
|
||||
Purely about delivering to sockets connected to *this* process --
|
||||
cross-instance fan-out lives in RoomBroadcaster, and cross-instance
|
||||
"who's connected" for push lives in Presence, both backed by Redis
|
||||
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)
|
||||
@@ -28,6 +29,22 @@ class ConnectionManager:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user