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