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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:08:36 -06:00
co-authored by Claude Sonnet 5
parent c84c92446c
commit 1b4d681ad0
5 changed files with 61 additions and 1 deletions
+10
View File
@@ -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
+16
View File
@@ -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