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

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