diff --git a/alembic/versions/0006_user_name_bio_profile.py b/alembic/versions/0006_user_name_bio_profile.py new file mode 100644 index 0000000..39fd218 --- /dev/null +++ b/alembic/versions/0006_user_name_bio_profile.py @@ -0,0 +1,29 @@ +"""add user name and bio + +Revision ID: 0006_user_name_bio_profile +Revises: 0005_chat_public_saved_features +Create Date: 2026-03-08 02:20:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0006_user_name_bio_profile" +down_revision: Union[str, Sequence[str], None] = "0005_chat_public_saved_features" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("name", sa.String(length=100), nullable=True)) + op.add_column("users", sa.Column("bio", sa.String(length=500), nullable=True)) + op.execute("UPDATE users SET name = username WHERE name IS NULL OR name = ''") + op.alter_column("users", "name", existing_type=sa.String(length=100), nullable=False) + + +def downgrade() -> None: + op.drop_column("users", "bio") + op.drop_column("users", "name") diff --git a/app/auth/schemas.py b/app/auth/schemas.py index ed1526d..a2a4961 100644 --- a/app/auth/schemas.py +++ b/app/auth/schemas.py @@ -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 diff --git a/app/auth/service.py b/app/auth/service.py index b4e9114..3b708c7 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -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), ) diff --git a/app/chats/repository.py b/app/chats/repository.py index 778bbeb..bdaf3de 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -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() diff --git a/app/chats/router.py b/app/chats/router.py index 4c9c892..eed41c6 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -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) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index cd17386..71387c8 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -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 diff --git a/app/chats/service.py b/app/chats/service.py index 74e06aa..8225f71 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -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") diff --git a/app/users/models.py b/app/users/models.py index 317bbdd..a037c73 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -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( diff --git a/app/users/repository.py b/app/users/repository.py index 48bc83b..f243fde 100644 --- a/app/users/repository.py +++ b/app/users/repository.py @@ -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 diff --git a/app/users/router.py b/app/users/router.py index ed2353b..c3a77b9 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -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 diff --git a/app/users/schemas.py b/app/users/schemas.py index d64ddbb..dba125b 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -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 diff --git a/app/users/service.py b/app/users/service.py index 5b77da3..5f9ff30 100644 --- a/app/users/service.py +++ b/app/users/service.py @@ -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() diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index d3bf6c5..1571a5e 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,8 +1,8 @@ import { http } from "./http"; import type { AuthUser, TokenPair } from "../chat/types"; -export async function registerRequest(email: string, username: string, password: string): Promise { - await http.post("/auth/register", { email, username, password }); +export async function registerRequest(email: string, name: string, username: string, password: string): Promise { + await http.post("/auth/register", { email, name, username, password }); } export async function loginRequest(email: string, password: string): Promise { diff --git a/web/src/api/users.ts b/web/src/api/users.ts index 279b776..8f40573 100644 --- a/web/src/api/users.ts +++ b/web/src/api/users.ts @@ -1,5 +1,5 @@ import { http } from "./http"; -import type { UserSearchItem } from "../chat/types"; +import type { AuthUser, UserSearchItem } from "../chat/types"; export async function searchUsers(query: string, limit = 20): Promise { const { data } = await http.get("/users/search", { @@ -7,3 +7,15 @@ export async function searchUsers(query: string, limit = 20): Promise { + const { data } = await http.put("/users/profile", payload); + return data; +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index a758346..e7831a8 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -6,6 +6,7 @@ export interface Chat { id: number; type: ChatType; title: string | null; + display_title?: string | null; handle?: string | null; description?: string | null; is_public?: boolean; @@ -36,7 +37,9 @@ export interface Message { export interface AuthUser { id: number; email: string; + name: string; username: string; + bio?: string | null; avatar_url: string | null; email_verified: boolean; created_at: string; @@ -51,6 +54,7 @@ export interface TokenPair { export interface UserSearchItem { id: number; + name: string; username: string; avatar_url: string | null; } diff --git a/web/src/components/AuthPanel.tsx b/web/src/components/AuthPanel.tsx index 90859c9..3f50c07 100644 --- a/web/src/components/AuthPanel.tsx +++ b/web/src/components/AuthPanel.tsx @@ -9,6 +9,7 @@ export function AuthPanel() { const loading = useAuthStore((s) => s.loading); const [mode, setMode] = useState("login"); const [email, setEmail] = useState(""); + const [name, setName] = useState(""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -20,7 +21,7 @@ export function AuthPanel() { setSuccess(null); try { if (mode === "register") { - await registerRequest(email, username, password); + await registerRequest(email, name, username, password); setSuccess("Registered. Check email verification, then login."); setMode("login"); return; @@ -44,7 +45,10 @@ export function AuthPanel() {
setEmail(e.target.value)} /> {mode === "register" && ( - setUsername(e.target.value)} /> + <> + setName(e.target.value)} /> + setUsername(e.target.value)} /> + )} setPassword(e.target.value)} />
{tabs.map((item) => ( @@ -84,18 +104,18 @@ export function ChatList() { onClick={() => setActiveChatId(chat.id)} onContextMenu={(e) => { e.preventDefault(); - const safePos = getSafeContextPosition(e.clientX, e.clientY); + const safePos = getSafeContextPosition(e.clientX, e.clientY, 176, 56); setCtxChatId(chat.id); setCtxPos(safePos); }} >
- {(chat.title || chat.type).slice(0, 1)} + {(chat.display_title || chat.title || chat.type).slice(0, 1)}
-

{chat.title || `${chat.type} #${chat.id}`}

+

{chat.display_title || chat.title || `${chat.type} #${chat.id}`}

{messagesByChat[chat.id]?.length ? "now" : ""} @@ -128,15 +148,17 @@ export function ChatList() {

Delete chat #{deleteModalChatId}

- + {canDeleteForEveryone ? ( + + ) : null}
) : null} + + {profileOpen ? ( +
+
+

Edit profile

+
+ setProfileName(e.target.value)} /> + setProfileUsername(e.target.value.replace("@", ""))} /> + setProfileBio(e.target.value)} /> + setProfileAvatarUrl(e.target.value)} /> +
+ {profileError ?

{profileError}

: null} +
+ + +
+
+
+ ) : null} ); } - function getSafeContextPosition(x: number, y: number): { x: number; y: number } { - const menuWidth = 176; - const menuHeight = 56; + function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } { const pad = 8; - const safeX = Math.min(Math.max(pad, x), window.innerWidth - menuWidth - pad); - const safeY = Math.min(Math.max(pad, y), window.innerHeight - menuHeight - pad); + const cursorOffset = 4; + const wantedX = x + cursorOffset; + const wantedY = y + cursorOffset; + const safeX = Math.min(Math.max(pad, wantedX), window.innerWidth - menuWidth - pad); + const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad); return { x: safeX, y: safeY }; } diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 1143f31..b1f045c 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -70,7 +70,8 @@ export function MessageList() { }`} onContextMenu={(e) => { e.preventDefault(); - setCtx({ x: e.clientX, y: e.clientY, messageId: message.id }); + const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108); + setCtx({ x: pos.x, y: pos.y, messageId: message.id }); }} > {message.forwarded_from_message_id ? ( @@ -147,3 +148,13 @@ function renderStatus(status: string | undefined): string { if (status === "read") return "✓✓"; return "✓"; } + +function getSafeContextPosition(x: number, y: number, menuWidth: number, menuHeight: number): { x: number; y: number } { + const pad = 8; + const cursorOffset = 4; + const wantedX = x + cursorOffset; + const wantedY = y + cursorOffset; + const safeX = Math.min(Math.max(pad, wantedX), window.innerWidth - menuWidth - pad); + const safeY = Math.min(Math.max(pad, wantedY), window.innerHeight - menuHeight - pad); + return { x: safeX, y: safeY }; +} diff --git a/web/src/components/NewChatPanel.tsx b/web/src/components/NewChatPanel.tsx index d69b008..f1f2dfb 100644 --- a/web/src/components/NewChatPanel.tsx +++ b/web/src/components/NewChatPanel.tsx @@ -13,6 +13,7 @@ export function NewChatPanel() { const [title, setTitle] = useState(""); const [handle, setHandle] = useState(""); const [description, setDescription] = useState(""); + const [isPublic, setIsPublic] = useState(false); const [results, setResults] = useState([]); const [discoverResults, setDiscoverResults] = useState([]); const [loading, setLoading] = useState(false); @@ -87,8 +88,14 @@ export function NewChatPanel() { setError(null); try { let chat; - if (handle.trim()) { - chat = await createPublicChat(mode, title.trim(), handle.trim().replace("@", "").toLowerCase(), description.trim() || undefined); + if (isPublic) { + const normalizedHandle = handle.trim().replace("@", "").toLowerCase(); + if (!normalizedHandle) { + setError("Public chat requires @handle"); + setLoading(false); + return; + } + chat = await createPublicChat(mode, title.trim(), normalizedHandle, description.trim() || undefined); } else { chat = await createChat(mode as ChatType, title.trim(), []); } @@ -97,6 +104,7 @@ export function NewChatPanel() { setTitle(""); setHandle(""); setDescription(""); + setIsPublic(false); } catch { setError("Failed to create chat"); } finally { @@ -124,6 +132,7 @@ export function NewChatPanel() { setQuery(""); setResults([]); setDiscoverResults([]); + setIsPublic(false); } return ( @@ -148,8 +157,8 @@ export function NewChatPanel() {
) : null} -
@@ -169,7 +178,8 @@ export function NewChatPanel() {
{results.map((user) => ( ))} {normalizedQuery && results.length === 0 ?

No users

: null} @@ -180,6 +190,7 @@ export function NewChatPanel() { {dialog === "discover" ? (
void handleDiscover(e.target.value)} /> +

Search works only for public groups/channels.

{discoverResults.map((chat) => (
@@ -204,7 +215,13 @@ export function NewChatPanel() { {dialog === "group" || dialog === "channel" ? ( void createByType(e, dialog)}> setTitle(e.target.value)} /> - setHandle(e.target.value)} /> + + {isPublic ? ( + setHandle(e.target.value)} /> + ) : null} setDescription(e.target.value)} />
-

{activeChat?.title || `@${me?.username}`}

+

{activeChat?.display_title || activeChat?.title || me?.name || `@${me?.username}`}

{activeChat ? activeChat.type : "Select a chat"}