From 1b4d681ad03bfc5776f5ed159d1a203f5e297f5c Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sun, 16 Aug 2026 11:08:36 -0600 Subject: [PATCH] Broadcast profile updates so member lists stay live (no reload needed) Another user's new display name or avatar didn't show up until you reloaded -- update_profile/upload_avatar/remove_avatar never told anyone. Same root cause and fix shape as #26 (room_added): the frontend's already-fetched member list had no way to hear about a change, since nothing ever pushed one. Reuses the existing per-room broadcast channel (not the per-user one #26 added, since this only matters for rooms the affected user shares with someone currently looking at them) -- publishes member_updated to every room the user belongs to; ChatShellPage refetches members when it arrives for the currently open room. Verified end-to-end in the browser: one user's room-info member list updated live when another user changed their display name from a separate session, no reload. Co-Authored-By: Claude Sonnet 5 --- backend/app/routers/auth.py | 10 ++++++++++ backend/app/services/message_events.py | 16 ++++++++++++++++ backend/tests/test_broadcast.py | 26 ++++++++++++++++++++++++++ frontend/src/pages/ChatShellPage.tsx | 3 ++- frontend/src/types.ts | 7 +++++++ 5 files changed, 61 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index bbd355e..68f4fda 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -12,6 +12,7 @@ from app.services.auth_service import ( InvalidCredentialsError, authenticate_user, ) +from app.services.message_events import broadcast_member_updated from app.services.password_service import ( InvalidCurrentPasswordError, PasswordResetInvalidError, @@ -70,6 +71,7 @@ async def me(current_user: User = Depends(get_current_user)) -> User: @router.patch("/me", response_model=UserRead) async def update_profile( + request: Request, data: ProfileUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -85,11 +87,16 @@ async def update_profile( current_user.theme = updates["theme"] await db.commit() await db.refresh(current_user) + # theme is private to this user, not shown to anyone else -- only + # broadcast when something other members would actually see changed. + if "display_name" in updates: + await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id) return current_user @router.post("/me/avatar", response_model=UserRead) async def upload_avatar( + request: Request, file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -123,11 +130,13 @@ async def upload_avatar( if previous_filename: delete_file(previous_filename) + await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id) return current_user @router.delete("/me/avatar", response_model=UserRead) async def remove_avatar( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> User: @@ -140,6 +149,7 @@ async def remove_avatar( if previous_filename: delete_file(previous_filename) + await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id) return current_user diff --git a/backend/app/services/message_events.py b/backend/app/services/message_events.py index fabdc34..41eaacb 100644 --- a/backend/app/services/message_events.py +++ b/backend/app/services/message_events.py @@ -117,6 +117,22 @@ async def broadcast_reaction_update( # image uploads (see backend/README.md). +async def broadcast_member_updated(db: AsyncSession, broadcaster: Broadcaster, user_id: uuid.UUID) -> None: + """Tells every room a user belongs to that their displayable info + (avatar, display name) changed -- without it, other members' already- + fetched member lists (and anything resolving avatar/name from them, + like MessageList) go stale until the room is reopened. Only reaches + clients that currently have that room's channel joined, which is + exactly when a stale avatar would actually be visible on screen.""" + result = await db.execute( + select(RoomMembership.room_id).where(RoomMembership.user_id == user_id) + ) + for (room_id,) in result.all(): + await broadcaster.publish( + room_id, {"type": "member_updated", "room_id": str(room_id), "user_id": str(user_id)} + ) + + 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 diff --git a/backend/tests/test_broadcast.py b/backend/tests/test_broadcast.py index 6f37db2..d55455c 100644 --- a/backend/tests/test_broadcast.py +++ b/backend/tests/test_broadcast.py @@ -134,3 +134,29 @@ def test_add_member_notifies_target_user_via_websocket(ws_client_factory, monkey received = bob_ws.receive_json() assert received == {"type": "room_added", "room_id": room["id"]} + + +def test_profile_update_notifies_room_members_via_websocket(ws_client_factory, monkeypatch): + # Only reaches clients that have the room's own channel joined -- + # exactly the case where a stale avatar/display name would actually be + # visible on screen (a room the user has open right now). + _fake_send_email(monkeypatch) + + instance1 = ws_client_factory() + instance2 = ws_client_factory() + + alice = _register_ws(instance1, _unique("alice")) + room = instance1.post("/api/rooms", json={"name": _unique("general")}).json() + + bob = _register_ws(instance2, _unique("bob")) + instance2.post(f"/api/rooms/{room['id']}/join") + + with instance2.websocket_connect("/ws/chat") as bob_ws: + bob_ws.send_json({"type": "join", "room_id": room["id"]}) + assert bob_ws.receive_json()["type"] == "joined" + + resp = instance1.patch("/api/auth/me", json={"display_name": "Alice Updated"}) + assert resp.status_code == 200, resp.text + + received = bob_ws.receive_json() + assert received == {"type": "member_updated", "room_id": room["id"], "user_id": alice["id"]} diff --git a/frontend/src/pages/ChatShellPage.tsx b/frontend/src/pages/ChatShellPage.tsx index 19660eb..bfe228d 100644 --- a/frontend/src/pages/ChatShellPage.tsx +++ b/frontend/src/pages/ChatShellPage.tsx @@ -64,8 +64,9 @@ export function ChatShellPage() { () => socket.subscribe((envelope) => { if (envelope.type === 'room_added') refreshRooms() + else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers() }), - [socket, refreshRooms], + [socket, refreshRooms, refreshMembers, roomId], ) useEffect(() => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9ba2e8d..9903983 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -119,6 +119,12 @@ export interface ChatRoomAddedEnvelope { room_id: string } +export interface ChatMemberUpdatedEnvelope { + type: 'member_updated' + room_id: string + user_id: string +} + export type ServerEnvelope = | ChatMessageEnvelope | ChatMessageUpdateEnvelope @@ -126,6 +132,7 @@ export type ServerEnvelope = | ChatJoinedEnvelope | ChatErrorEnvelope | ChatRoomAddedEnvelope + | ChatMemberUpdatedEnvelope export interface AdminUser { id: string