Add username search and improve chat creation UX
All checks were successful
CI / test (push) Successful in 23s

Backend user search:

- Added users search endpoint for @username lookup.

- Implemented repository/service/router support with bounded result limits.

Web chat creation:

- Added API client for /users/search.

- Added NewChatPanel for creating private chats via @username search.

- Added group/channel creation flow from sidebar.

UX refinement:

- Hide message composer when no chat is selected.

- Show explicit placeholder: 'Выберите чат, чтобы начать переписку'.

- Added tsbuildinfo ignore rule.
This commit is contained in:
2026-03-07 22:34:53 +03:00
parent ab65a8b768
commit 9ef9366aca
11 changed files with 225 additions and 8 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ __pycache__/
test.db
web/node_modules
web/dist
web/tsconfig.tsbuildinfo

View File

@@ -1,4 +1,4 @@
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.users.models import User
@@ -31,3 +31,19 @@ async def list_users_by_ids(db: AsyncSession, user_ids: list[int]) -> list[User]
return []
result = await db.execute(select(User).where(User.id.in_(user_ids)))
return list(result.scalars().all())
async def search_users_by_username(
db: AsyncSession,
*,
query: str,
limit: int = 20,
exclude_user_id: int | None = None,
) -> list[User]:
normalized = query.lower().strip().lstrip("@")
stmt = select(User).where(func.lower(User.username).like(f"%{normalized}%"))
if exclude_user_id is not None:
stmt = stmt.where(User.id != exclude_user_id)
stmt = stmt.order_by(User.username.asc()).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())

View File

@@ -4,8 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user
from app.database.session import get_db
from app.users.models import User
from app.users.schemas import UserProfileUpdate, UserRead
from app.users.service import get_user_by_id, get_user_by_username, update_user_profile
from app.users.schemas import UserProfileUpdate, UserRead, UserSearchRead
from app.users.service import get_user_by_id, get_user_by_username, search_users_by_username, update_user_profile
router = APIRouter(prefix="/users", tags=["users"])
@@ -15,6 +15,24 @@ async def read_me(current_user: User = Depends(get_current_user)) -> UserRead:
return current_user
@router.get("/search", response_model=list[UserSearchRead])
async def search_users(
query: str,
limit: int = 20,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[UserSearchRead]:
if len(query.strip().lstrip("@")) < 2:
return []
users = await search_users_by_username(
db,
query=query,
limit=limit,
exclude_user_id=current_user.id,
)
return users
@router.get("/{user_id}", response_model=UserRead)
async def read_user(user_id: int, db: AsyncSession = Depends(get_db), _current_user: User = Depends(get_current_user)) -> UserRead:
user = await get_user_by_id(db, user_id)

View File

@@ -25,3 +25,11 @@ class UserRead(UserBase):
class UserProfileUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=50)
avatar_url: str | None = Field(default=None, max_length=512)
class UserSearchRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
avatar_url: str | None = None

View File

@@ -16,6 +16,22 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
return await repository.get_user_by_username(db, username)
async def search_users_by_username(
db: AsyncSession,
*,
query: str,
limit: int = 20,
exclude_user_id: int | None = None,
) -> list[User]:
safe_limit = max(1, min(limit, 50))
return await repository.search_users_by_username(
db,
query=query,
limit=safe_limit,
exclude_user_id=exclude_user_id,
)
async def update_user_profile(
db: AsyncSession,
user: User,

View File

@@ -1,5 +1,5 @@
import { http } from "./http";
import type { Chat, Message, MessageType } from "../chat/types";
import type { Chat, ChatType, Message, MessageType } from "../chat/types";
export async function getChats(): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats");
@@ -7,10 +7,14 @@ export async function getChats(): Promise<Chat[]> {
}
export async function createPrivateChat(memberId: number): Promise<Chat> {
return createChat("private", null, [memberId]);
}
export async function createChat(type: ChatType, title: string | null, memberIds: number[] = []): Promise<Chat> {
const { data } = await http.post<Chat>("/chats", {
type: "private",
title: null,
member_ids: [memberId]
type,
title,
member_ids: memberIds
});
return data;
}

9
web/src/api/users.ts Normal file
View File

@@ -0,0 +1,9 @@
import { http } from "./http";
import type { UserSearchItem } from "../chat/types";
export async function searchUsers(query: string, limit = 20): Promise<UserSearchItem[]> {
const { data } = await http.get<UserSearchItem[]>("/users/search", {
params: { query, limit }
});
return data;
}

View File

@@ -33,3 +33,9 @@ export interface TokenPair {
refresh_token: string;
token_type: string;
}
export interface UserSearchItem {
id: number;
username: string;
avatar_url: string | null;
}

View File

@@ -1,4 +1,5 @@
import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel";
export function ChatList() {
const chats = useChatStore((s) => s.chats);
@@ -8,6 +9,7 @@ export function ChatList() {
return (
<aside className="w-full max-w-xs border-r border-slate-700 bg-panel">
<div className="border-b border-slate-700 p-3 text-sm font-semibold">Chats</div>
<NewChatPanel />
<div className="max-h-[calc(100vh-56px)] overflow-auto">
{chats.map((chat) => (
<button

View File

@@ -0,0 +1,131 @@
import { FormEvent, useMemo, useState } from "react";
import { createChat, createPrivateChat, getChats } from "../api/chats";
import { searchUsers } from "../api/users";
import type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore";
type CreateMode = "private" | "group" | "channel";
export function NewChatPanel() {
const [mode, setMode] = useState<CreateMode>("private");
const [query, setQuery] = useState("");
const [title, setTitle] = useState("");
const [results, setResults] = useState<UserSearchItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const normalizedQuery = useMemo(() => query.trim(), [query]);
async function handleSearch(value: string) {
setQuery(value);
setError(null);
if (value.trim().replace("@", "").length < 2) {
setResults([]);
return;
}
try {
const users = await searchUsers(value);
setResults(users);
} catch {
setError("Search failed");
}
}
async function refreshChatsAndSelectLast() {
const chats = await getChats();
useChatStore.setState({ chats });
if (chats[0]) {
setActiveChatId(chats[0].id);
}
}
async function createPrivate(userId: number) {
setLoading(true);
setError(null);
try {
await createPrivateChat(userId);
await refreshChatsAndSelectLast();
setQuery("");
setResults([]);
} catch {
setError("Failed to create private chat");
} finally {
setLoading(false);
}
}
async function createByType(event: FormEvent) {
event.preventDefault();
if (mode === "private") {
return;
}
if (!title.trim()) {
setError("Title is required");
return;
}
setLoading(true);
setError(null);
try {
await createChat(mode as ChatType, title.trim(), []);
await refreshChatsAndSelectLast();
setTitle("");
} catch {
setError("Failed to create chat");
} finally {
setLoading(false);
}
}
return (
<div className="border-b border-slate-700 p-3">
<div className="mb-2 flex gap-2 text-xs">
<button className={`rounded px-2 py-1 ${mode === "private" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("private")}>
Private
</button>
<button className={`rounded px-2 py-1 ${mode === "group" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("group")}>
Group
</button>
<button className={`rounded px-2 py-1 ${mode === "channel" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("channel")}>
Channel
</button>
</div>
{mode === "private" ? (
<div className="space-y-2">
<input
className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
placeholder="@username"
value={query}
onChange={(e) => void handleSearch(e.target.value)}
/>
<div className="max-h-32 overflow-auto">
{results.map((user) => (
<button
className="mb-1 block w-full rounded bg-slate-800 px-2 py-1 text-left text-sm hover:bg-slate-700"
key={user.id}
onClick={() => void createPrivate(user.id)}
>
@{user.username}
</button>
))}
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
</div>
</div>
) : (
<form className="space-y-2" onSubmit={(e) => void createByType(e)}>
<input
className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
placeholder={mode === "group" ? "Group title" : "Channel title"}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button className="w-full rounded bg-slate-700 px-2 py-1 text-sm hover:bg-slate-600" disabled={loading} type="submit">
Create {mode}
</button>
</form>
)}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div>
);
}

View File

@@ -38,7 +38,13 @@ export function ChatsPage() {
<div className="min-h-0 flex-1">
<MessageList />
</div>
<MessageComposer />
{activeChatId ? (
<MessageComposer />
) : (
<div className="border-t border-slate-700 bg-panel p-4 text-center text-sm text-slate-400">
Выберите чат, чтобы начать переписку
</div>
)}
</section>
</main>
);