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

@@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=100)
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=8, max_length=128)
@@ -50,7 +51,9 @@ class AuthUserResponse(BaseModel):
id: int
email: EmailStr
name: str
username: str
bio: str | None = None
avatar_url: str | None = None
email_verified: bool
created_at: datetime

View File

@@ -54,6 +54,7 @@ async def register_user(
user = await create_user(
db,
email=payload.email,
name=payload.name,
username=payload.username,
password_hash=hash_password(payload.password),
)

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")

View File

@@ -16,10 +16,12 @@ class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(

View File

@@ -4,8 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.users.models import User
async def create_user(db: AsyncSession, *, email: str, username: str, password_hash: str) -> User:
user = User(email=email, username=username, password_hash=password_hash, email_verified=False)
async def create_user(db: AsyncSession, *, email: str, name: str, username: str, password_hash: str) -> User:
user = User(email=email, name=name, username=username, password_hash=password_hash, email_verified=False)
db.add(user)
await db.flush()
return user

View File

@@ -55,7 +55,9 @@ async def update_profile(
updated = await update_user_profile(
db,
current_user,
name=payload.name,
username=payload.username,
bio=payload.bio,
avatar_url=payload.avatar_url,
)
return updated

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
username: str = Field(min_length=3, max_length=50)
email: EmailStr
@@ -17,13 +18,16 @@ class UserRead(UserBase):
id: int
avatar_url: str | None = None
bio: str | None = None
email_verified: bool
created_at: datetime
updated_at: datetime
class UserProfileUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
username: str | None = Field(default=None, min_length=3, max_length=50)
bio: str | None = Field(default=None, max_length=500)
avatar_url: str | None = Field(default=None, max_length=512)
@@ -31,5 +35,6 @@ class UserSearchRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
username: str
avatar_url: str | None = None

View File

@@ -36,11 +36,17 @@ async def update_user_profile(
db: AsyncSession,
user: User,
*,
name: str | None = None,
username: str | None = None,
bio: str | None = None,
avatar_url: str | None = None,
) -> User:
if name is not None:
user.name = name
if username is not None:
user.username = username
if bio is not None:
user.bio = bio
if avatar_url is not None:
user.avatar_url = avatar_url
await db.commit()