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
+16 -4
View File
@@ -7,7 +7,7 @@ from app.models import Message, MessageFile, Room, RoomMembership, User
from app.schemas.message import ReactionSummary
from app.services.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event
from app.ws.broadcaster import RoomBroadcaster
from app.ws.broadcaster import Broadcaster
from app.ws.presence import Presence
@@ -70,7 +70,7 @@ async def _message_payload(db: AsyncSession, message: Message, username: str) ->
async def broadcast_new_message(
db: AsyncSession,
broadcaster: RoomBroadcaster,
broadcaster: Broadcaster,
presence: Presence,
room_id: uuid.UUID,
message: Message,
@@ -86,7 +86,7 @@ async def broadcast_new_message(
async def broadcast_message_update(
db: AsyncSession, broadcaster: RoomBroadcaster, room_id: uuid.UUID, message: Message
db: AsyncSession, broadcaster: Broadcaster, room_id: uuid.UUID, message: Message
) -> None:
payload = {
"type": "message_update",
@@ -100,7 +100,7 @@ async def broadcast_message_update(
async def broadcast_reaction_update(
broadcaster: RoomBroadcaster,
broadcaster: Broadcaster,
room_id: uuid.UUID,
message_id: uuid.UUID,
reactions: list[ReactionSummary],
@@ -115,3 +115,15 @@ async def broadcast_reaction_update(
# Deliberately no dispatch_event() call -- reactions don't get an
# outgoing-webhook event type, matching the same scope cut made for
# image uploads (see backend/README.md).
async def broadcast_room_added(broadcaster: Broadcaster, user_id: uuid.UUID, room: Room) -> None:
"""The only signal a user's open client gets that they were just added
to a room -- without it, GET /rooms/mine is only ever fetched once at
app mount, so a room added mid-session stays invisible until a full
reload. Published on the user's own channel rather than the room's,
since the whole point is reaching someone who hasn't joined that room's
channel yet (and by definition can't have)."""
await broadcaster.publish_to_user(
user_id, {"type": "room_added", "room_id": str(room.id)}
)