Private
Public Access
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:
@@ -12,6 +12,7 @@ from app.services.auth_service import (
|
|||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
authenticate_user,
|
authenticate_user,
|
||||||
)
|
)
|
||||||
|
from app.services.message_events import broadcast_member_updated
|
||||||
from app.services.password_service import (
|
from app.services.password_service import (
|
||||||
InvalidCurrentPasswordError,
|
InvalidCurrentPasswordError,
|
||||||
PasswordResetInvalidError,
|
PasswordResetInvalidError,
|
||||||
@@ -70,6 +71,7 @@ async def me(current_user: User = Depends(get_current_user)) -> User:
|
|||||||
|
|
||||||
@router.patch("/me", response_model=UserRead)
|
@router.patch("/me", response_model=UserRead)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
|
request: Request,
|
||||||
data: ProfileUpdate,
|
data: ProfileUpdate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -85,11 +87,16 @@ async def update_profile(
|
|||||||
current_user.theme = updates["theme"]
|
current_user.theme = updates["theme"]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
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
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar", response_model=UserRead)
|
@router.post("/me/avatar", response_model=UserRead)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -123,11 +130,13 @@ async def upload_avatar(
|
|||||||
if previous_filename:
|
if previous_filename:
|
||||||
delete_file(previous_filename)
|
delete_file(previous_filename)
|
||||||
|
|
||||||
|
await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/me/avatar", response_model=UserRead)
|
@router.delete("/me/avatar", response_model=UserRead)
|
||||||
async def remove_avatar(
|
async def remove_avatar(
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
@@ -140,6 +149,7 @@ async def remove_avatar(
|
|||||||
if previous_filename:
|
if previous_filename:
|
||||||
delete_file(previous_filename)
|
delete_file(previous_filename)
|
||||||
|
|
||||||
|
await broadcast_member_updated(db, request.app.state.broadcaster, current_user.id)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,22 @@ async def broadcast_reaction_update(
|
|||||||
# image uploads (see backend/README.md).
|
# 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:
|
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
|
"""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
|
to a room -- without it, GET /rooms/mine is only ever fetched once at
|
||||||
|
|||||||
@@ -134,3 +134,29 @@ def test_add_member_notifies_target_user_via_websocket(ws_client_factory, monkey
|
|||||||
|
|
||||||
received = bob_ws.receive_json()
|
received = bob_ws.receive_json()
|
||||||
assert received == {"type": "room_added", "room_id": room["id"]}
|
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"]}
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export function ChatShellPage() {
|
|||||||
() =>
|
() =>
|
||||||
socket.subscribe((envelope) => {
|
socket.subscribe((envelope) => {
|
||||||
if (envelope.type === 'room_added') refreshRooms()
|
if (envelope.type === 'room_added') refreshRooms()
|
||||||
|
else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers()
|
||||||
}),
|
}),
|
||||||
[socket, refreshRooms],
|
[socket, refreshRooms, refreshMembers, roomId],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ export interface ChatRoomAddedEnvelope {
|
|||||||
room_id: string
|
room_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatMemberUpdatedEnvelope {
|
||||||
|
type: 'member_updated'
|
||||||
|
room_id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerEnvelope =
|
export type ServerEnvelope =
|
||||||
| ChatMessageEnvelope
|
| ChatMessageEnvelope
|
||||||
| ChatMessageUpdateEnvelope
|
| ChatMessageUpdateEnvelope
|
||||||
@@ -126,6 +132,7 @@ export type ServerEnvelope =
|
|||||||
| ChatJoinedEnvelope
|
| ChatJoinedEnvelope
|
||||||
| ChatErrorEnvelope
|
| ChatErrorEnvelope
|
||||||
| ChatRoomAddedEnvelope
|
| ChatRoomAddedEnvelope
|
||||||
|
| ChatMemberUpdatedEnvelope
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user