feat: add user display profiles and fix web context menu UX
Some checks failed
CI / test (push) Failing after 17s

backend:

- add required user name and optional bio fields

- extend auth/register and user schemas/services with name/bio

- add alembic migration 0006 with safe backfill name=username

- compute per-user chat display_title for private chats

- keep Saved Messages delete-for-all protections

web:

- registration now includes name

- add profile edit modal (name/username/bio/avatar url)

- show private chat names via display_title

- fix context menus to open near cursor with viewport clamping

- stabilize +/close floating button to remove visual jump
This commit is contained in:
2026-03-08 00:57:02 +03:00
parent 321f918dca
commit 456595a576
20 changed files with 249 additions and 39 deletions

View File

@@ -159,3 +159,9 @@ async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, u
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_private_counterpart_user_id(db: AsyncSession, *, chat_id: int, user_id: int) -> int | None:
stmt = select(ChatMember.user_id).where(ChatMember.chat_id == chat_id, ChatMember.user_id != user_id).limit(1)
result = await db.execute(stmt)
return result.scalar_one_or_none()

View File

@@ -26,6 +26,8 @@ from app.chats.service import (
leave_chat_for_user,
pin_chat_message_for_user,
remove_chat_member_for_user,
serialize_chat_for_user,
serialize_chats_for_user,
update_chat_member_role_for_user,
update_chat_title_for_user,
)
@@ -43,7 +45,8 @@ async def list_chats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[ChatRead]:
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
chats = await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
return await serialize_chats_for_user(db, user_id=current_user.id, chats=chats)
@router.get("/saved", response_model=ChatRead)
@@ -51,7 +54,8 @@ async def get_saved_messages_chat(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await ensure_saved_messages_chat(db, user_id=current_user.id)
chat = await ensure_saved_messages_chat(db, user_id=current_user.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.get("/discover", response_model=list[ChatDiscoverRead])
@@ -70,7 +74,8 @@ async def create_chat(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
chat = await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.post("/{chat_id}/join", response_model=ChatRead)
@@ -79,7 +84,8 @@ async def join_chat(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
chat = await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.get("/{chat_id}", response_model=ChatDetailRead)
@@ -106,7 +112,8 @@ async def update_chat_title(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
chat = await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)
@router.get("/{chat_id}/members", response_model=list[ChatMemberRead])
@@ -182,4 +189,5 @@ async def pin_chat_message(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
chat = await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)
return await serialize_chat_for_user(db, user_id=current_user.id, chat=chat)

View File

@@ -11,6 +11,7 @@ class ChatRead(BaseModel):
id: int
type: ChatType
title: str | None = None
display_title: str | None = None
handle: str | None = None
description: str | None = None
is_public: bool = False

View File

@@ -4,11 +4,42 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatTitleUpdateRequest
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest
from app.messages.repository import get_message_by_id
from app.users.repository import get_user_by_id
async def serialize_chat_for_user(db: AsyncSession, *, user_id: int, chat: Chat) -> ChatRead:
display_title = chat.title
if chat.is_saved:
display_title = "Saved Messages"
elif chat.type == ChatType.PRIVATE:
counterpart_id = await repository.get_private_counterpart_user_id(db, chat_id=chat.id, user_id=user_id)
if counterpart_id:
counterpart = await get_user_by_id(db, counterpart_id)
if counterpart:
display_title = counterpart.name or counterpart.username
return ChatRead.model_validate(
{
"id": chat.id,
"type": chat.type,
"title": chat.title,
"display_title": display_title,
"handle": chat.handle,
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"pinned_message_id": chat.pinned_message_id,
"created_at": chat.created_at,
}
)
async def serialize_chats_for_user(db: AsyncSession, *, user_id: int, chats: list[Chat]) -> list[ChatRead]:
return [await serialize_chat_for_user(db, user_id=user_id, chat=chat) for chat in chats]
async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: ChatCreateRequest) -> Chat:
member_ids = list(dict.fromkeys(payload.member_ids))
member_ids = [member_id for member_id in member_ids if member_id != creator_id]
@@ -318,7 +349,7 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id:
async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
delete_for_all = payload.for_all or chat.type == ChatType.CHANNEL
delete_for_all = (payload.for_all and not chat.is_saved) or chat.type == ChatType.CHANNEL
if delete_for_all:
if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")