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
+25 -10
View File
@@ -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()