feat: add search APIs and telegram-like chats sidebar flow
All checks were successful
CI / test (push) Successful in 24s

- implement chat query filtering and message search endpoints

- add db indexes for search fields

- activate chats search input in web

- replace inline create panel with floating TG-style action menu
This commit is contained in:
2026-03-08 00:19:34 +03:00
parent 0a602e4078
commit 4d704fc279
11 changed files with 299 additions and 86 deletions

View File

@@ -0,0 +1,26 @@
"""search indexes
Revision ID: 0003_search_indexes
Revises: 0002_message_reliability_tables
Create Date: 2026-03-08 02:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0003_search_indexes"
down_revision: Union[str, Sequence[str], None] = "0002_message_reliability_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index("ix_chats_title", "chats", ["title"], unique=False)
op.create_index("ix_messages_text", "messages", ["text"], unique=False)
def downgrade() -> None:
op.drop_index("ix_messages_text", table_name="messages")
op.drop_index("ix_chats_title", table_name="chats")

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Select, func, select from sqlalchemy import Select, String, func, or_, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,20 +28,31 @@ async def count_chat_members(db: AsyncSession, *, chat_id: int) -> int:
return int(result.scalar_one()) return int(result.scalar_one())
def _user_chats_query(user_id: int) -> Select[tuple[Chat]]: def _user_chats_query(user_id: int, query: str | None = None) -> Select[tuple[Chat]]:
return ( stmt = select(Chat).join(ChatMember, ChatMember.chat_id == Chat.id).where(ChatMember.user_id == user_id)
select(Chat) if query and query.strip():
.join(ChatMember, ChatMember.chat_id == Chat.id) q = f"%{query.strip()}%"
.where(ChatMember.user_id == user_id) stmt = stmt.where(
.order_by(Chat.id.desc()) or_(
Chat.title.ilike(q),
Chat.type.cast(String).ilike(q),
) )
)
return stmt.order_by(Chat.id.desc())
async def list_user_chats(db: AsyncSession, *, user_id: int, limit: int = 50, before_id: int | None = None) -> list[Chat]: async def list_user_chats(
query = _user_chats_query(user_id).limit(limit) db: AsyncSession,
*,
user_id: int,
limit: int = 50,
before_id: int | None = None,
query: str | None = None,
) -> list[Chat]:
query_stmt = _user_chats_query(user_id, query=query).limit(limit)
if before_id is not None: if before_id is not None:
query = query.where(Chat.id < before_id) query_stmt = query_stmt.where(Chat.id < before_id)
result = await db.execute(query) result = await db.execute(query_stmt)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -31,10 +31,11 @@ router = APIRouter(prefix="/chats", tags=["chats"])
async def list_chats( async def list_chats(
limit: int = 50, limit: int = 50,
before_id: int | None = None, before_id: int | None = None,
query: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> list[ChatRead]: ) -> list[ChatRead]:
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id) return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
@router.post("", response_model=ChatRead) @router.post("", response_model=ChatRead)

View File

@@ -47,9 +47,16 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
return chat return chat
async def get_chats_for_user(db: AsyncSession, *, user_id: int, limit: int = 50, before_id: int | None = None) -> list[Chat]: async def get_chats_for_user(
db: AsyncSession,
*,
user_id: int,
limit: int = 50,
before_id: int | None = None,
query: str | None = None,
) -> list[Chat]:
safe_limit = max(1, min(limit, 100)) safe_limit = max(1, min(limit, 100))
return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id) return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]: async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:

View File

@@ -1,6 +1,7 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import ChatMember
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType
@@ -76,6 +77,31 @@ async def list_chat_messages(
return list(result.scalars().all()) return list(result.scalars().all())
async def search_messages(
db: AsyncSession,
*,
user_id: int,
query: str,
chat_id: int | None = None,
limit: int = 50,
) -> list[Message]:
stmt = (
select(Message)
.join(ChatMember, ChatMember.chat_id == Message.chat_id)
.where(
ChatMember.user_id == user_id,
Message.text.is_not(None),
Message.text.ilike(f"%{query.strip()}%"),
)
.order_by(Message.id.desc())
.limit(limit)
)
if chat_id is not None:
stmt = stmt.where(Message.chat_id == chat_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def delete_message(db: AsyncSession, message: Message) -> None: async def delete_message(db: AsyncSession, message: Message) -> None:
await db.delete(message) await db.delete(message)

View File

@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user from app.auth.service import get_current_user
from app.database.session import get_db from app.database.session import get_db
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest
from app.messages.service import create_chat_message, delete_message, get_messages, update_message from app.messages.service import create_chat_message, delete_message, get_messages, search_messages, update_message
from app.realtime.schemas import MessageStatusPayload from app.realtime.schemas import MessageStatusPayload
from app.realtime.service import realtime_gateway from app.realtime.service import realtime_gateway
from app.users.models import User from app.users.models import User
@@ -27,6 +27,17 @@ async def create_message(
return message return message
@router.get("/search", response_model=list[MessageRead])
async def search_messages_endpoint(
query: str,
chat_id: int | None = None,
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[MessageRead]:
return await search_messages(db, user_id=current_user.id, query=query, chat_id=chat_id, limit=limit)
@router.get("/{chat_id}", response_model=list[MessageRead]) @router.get("/{chat_id}", response_model=list[MessageRead])
async def list_messages( async def list_messages(
chat_id: int, chat_id: int,

View File

@@ -76,6 +76,29 @@ async def get_messages(
return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id) return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id)
async def search_messages(
db: AsyncSession,
*,
user_id: int,
query: str,
chat_id: int | None = None,
limit: int = 50,
) -> list[Message]:
normalized = query.strip()
if len(normalized) < 2:
return []
safe_limit = max(1, min(limit, 100))
if chat_id is not None:
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
return await repository.search_messages(
db,
user_id=user_id,
query=normalized,
chat_id=chat_id,
limit=safe_limit,
)
async def update_message( async def update_message(
db: AsyncSession, db: AsyncSession,
*, *,

View File

@@ -2,8 +2,10 @@ import { http } from "./http";
import type { Chat, ChatType, Message, MessageType } from "../chat/types"; import type { Chat, ChatType, Message, MessageType } from "../chat/types";
import axios from "axios"; import axios from "axios";
export async function getChats(): Promise<Chat[]> { export async function getChats(query?: string): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats"); const { data } = await http.get<Chat[]>("/chats", {
params: query?.trim() ? { query: query.trim() } : undefined
});
return data; return data;
} }
@@ -30,6 +32,16 @@ export async function getMessages(chatId: number, beforeId?: number): Promise<Me
return data; return data;
} }
export async function searchMessages(query: string, chatId?: number): Promise<Message[]> {
const { data } = await http.get<Message[]>("/messages/search", {
params: {
query,
chat_id: chatId
}
});
return data;
}
export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise<Message> { export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise<Message> {
const { data } = await http.post<Message>("/messages", { chat_id: chatId, text, type }); const { data } = await http.post<Message>("/messages", { chat_id: chatId, text, type });
return data; return data;

View File

@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { NewChatPanel } from "./NewChatPanel"; import { NewChatPanel } from "./NewChatPanel";
@@ -6,29 +8,69 @@ export function ChatList() {
const messagesByChat = useChatStore((s) => s.messagesByChat); const messagesByChat = useChatStore((s) => s.messagesByChat);
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const loadChats = useChatStore((s) => s.loadChats);
const me = useAuthStore((s) => s.me);
const [search, setSearch] = useState("");
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
useEffect(() => {
const timer = setTimeout(() => {
void loadChats(search.trim() ? search : undefined);
}, 250);
return () => clearTimeout(timer);
}, [search, loadChats]);
const filteredChats = chats.filter((chat) => {
if (tab === "people") {
return chat.type === "private";
}
if (tab === "groups") {
return chat.type === "group";
}
if (tab === "channels") {
return chat.type === "channel";
}
return true;
});
const tabs: Array<{ id: "all" | "people" | "groups" | "channels"; label: string }> = [
{ id: "all", label: "All" },
{ id: "people", label: "Люди" },
{ id: "groups", label: "Groups" },
{ id: "channels", label: "Каналы" }
];
return ( return (
<aside className="flex h-full w-full max-w-xs flex-col bg-slate-900/60"> <aside className="relative flex h-full w-full max-w-xs flex-col bg-slate-900/60">
<div className="border-b border-slate-700/50 px-4 py-3"> <div className="border-b border-slate-700/50 px-3 py-3">
<div className="mb-3 flex items-center justify-between"> <div className="mb-2 flex items-center gap-2">
<p className="text-base font-semibold tracking-wide">Chats</p> <button className="rounded-lg bg-slate-700/70 px-2 py-2 text-xs"></button>
<span className="rounded-full bg-slate-700/70 px-2 py-1 text-[11px] text-slate-200">{chats.length}</span> <label className="block flex-1">
</div>
<label className="block">
<input <input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" className="w-full rounded-full border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Search chats..." placeholder="Search"
disabled value={search}
value="" onChange={(e) => setSearch(e.target.value)}
readOnly
/> />
</label> </label>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-500/30 text-xs font-semibold uppercase text-sky-100">
{(me?.username || "u").slice(0, 1)}
</div>
</div>
<div className="flex items-center gap-3 overflow-x-auto pt-1 text-sm">
{tabs.map((item) => (
<button
className={`whitespace-nowrap pb-1.5 ${tab === item.id ? "border-b-2 border-sky-400 font-semibold text-sky-300" : "text-slate-300/80"}`}
key={item.id}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
))}
</div>
</div> </div>
<NewChatPanel />
<div className="tg-scrollbar flex-1 overflow-auto"> <div className="tg-scrollbar flex-1 overflow-auto">
{chats.map((chat) => ( {filteredChats.map((chat) => (
<button <button
className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${ className={`block w-full border-b border-slate-800/60 px-4 py-3 text-left transition ${
activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65" activeChatId === chat.id ? "bg-sky-500/20" : "hover:bg-slate-800/65"
@@ -53,6 +95,7 @@ export function ChatList() {
</button> </button>
))} ))}
</div> </div>
<NewChatPanel />
</aside> </aside>
); );
} }

View File

@@ -5,14 +5,16 @@ import type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
type CreateMode = "group" | "channel"; type CreateMode = "group" | "channel";
type DialogMode = "none" | "private" | "group" | "channel";
export function NewChatPanel() { export function NewChatPanel() {
const [mode, setMode] = useState<CreateMode>("group"); const [dialog, setDialog] = useState<DialogMode>("none");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [results, setResults] = useState<UserSearchItem[]>([]); const [results, setResults] = useState<UserSearchItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const setActiveChatId = useChatStore((s) => s.setActiveChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const normalizedQuery = useMemo(() => query.trim(), [query]); const normalizedQuery = useMemo(() => query.trim(), [query]);
@@ -46,6 +48,7 @@ export function NewChatPanel() {
try { try {
await createPrivateChat(userId); await createPrivateChat(userId);
await refreshChatsAndSelectLast(); await refreshChatsAndSelectLast();
setDialog("none");
setQuery(""); setQuery("");
setResults([]); setResults([]);
} catch { } catch {
@@ -55,7 +58,7 @@ export function NewChatPanel() {
} }
} }
async function createByType(event: FormEvent) { async function createByType(event: FormEvent, mode: CreateMode) {
event.preventDefault(); event.preventDefault();
if (!title.trim()) { if (!title.trim()) {
setError("Title is required"); setError("Title is required");
@@ -66,6 +69,7 @@ export function NewChatPanel() {
try { try {
await createChat(mode as ChatType, title.trim(), []); await createChat(mode as ChatType, title.trim(), []);
await refreshChatsAndSelectLast(); await refreshChatsAndSelectLast();
setDialog("none");
setTitle(""); setTitle("");
} catch { } catch {
setError("Failed to create chat"); setError("Failed to create chat");
@@ -74,35 +78,77 @@ export function NewChatPanel() {
} }
} }
function closeDialog() {
setDialog("none");
setError(null);
}
return ( return (
<div className="border-b border-slate-700/50 bg-slate-900/45 p-3"> <>
<div className="mb-2 flex gap-2 text-xs"> <div className="absolute bottom-4 right-4 z-20">
{menuOpen ? (
<div className="mb-2 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1 shadow-xl">
<button <button
className={`rounded-lg px-2.5 py-1.5 ${mode === "group" ? "bg-sky-500 text-slate-950" : "bg-slate-700/70 hover:bg-slate-700"}`} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => setMode("group")} onClick={() => {
setDialog("channel");
setMenuOpen(false);
}}
> >
Group New Channel
</button> </button>
<button <button
className={`rounded-lg px-2.5 py-1.5 ${mode === "channel" ? "bg-sky-500 text-slate-950" : "bg-slate-700/70 hover:bg-slate-700"}`} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => setMode("channel")} onClick={() => {
setDialog("group");
setMenuOpen(false);
}}
> >
Channel New Group
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => {
setDialog("private");
setMenuOpen(false);
}}
>
New Message
</button>
</div>
) : null}
<button
className="h-12 w-12 rounded-full bg-sky-500 text-xl font-semibold text-slate-950 shadow-lg hover:bg-sky-400"
onClick={() => setMenuOpen((v) => !v)}
>
{menuOpen ? "×" : "+"}
</button> </button>
</div> </div>
<div className="mb-3 space-y-2"> {dialog !== "none" ? (
<p className="text-xs text-slate-400">Новый диалог</p> <div className="absolute inset-0 z-30 flex items-end justify-center bg-slate-950/55 p-3">
<div className="w-full max-w-sm rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{dialog === "private" ? "New Message" : dialog === "group" ? "New Group" : "New Channel"}
</p>
<button className="rounded-md bg-slate-700/80 px-2 py-1 text-xs" onClick={closeDialog}>
Close
</button>
</div>
{dialog === "private" ? (
<div className="space-y-2">
<input <input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="@username" placeholder="@username"
value={query} value={query}
onChange={(e) => void handleSearch(e.target.value)} onChange={(e) => void handleSearch(e.target.value)}
/> />
<div className="tg-scrollbar max-h-32 overflow-auto"> <div className="tg-scrollbar max-h-44 overflow-auto">
{results.map((user) => ( {results.map((user) => (
<button <button
className="mb-1 block w-full rounded-lg bg-slate-800/90 px-2 py-1.5 text-left text-sm hover:bg-slate-700/90" className="mb-1 block w-full rounded-lg bg-slate-800 px-3 py-2 text-left text-sm hover:bg-slate-700"
key={user.id} key={user.id}
onClick={() => void createPrivate(user.id)} onClick={() => void createPrivate(user.id)}
> >
@@ -112,18 +158,23 @@ export function NewChatPanel() {
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null} {normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
</div> </div>
</div> </div>
<form className="space-y-2" onSubmit={(e) => void createByType(e)}> ) : (
<form className="space-y-2" onSubmit={(e) => void createByType(e, dialog as CreateMode)}>
<input <input
className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500" className="w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder={mode === "group" ? "Group title" : "Channel title"} placeholder={dialog === "group" ? "Group title" : "Channel title"}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
<button className="w-full rounded-lg bg-sky-500 px-2 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60" disabled={loading} type="submit"> <button className="w-full rounded-lg bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60" disabled={loading} type="submit">
Create {mode} Create {dialog}
</button> </button>
</form> </form>
)}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null} {error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div> </div>
</div>
) : null}
</>
); );
} }

View File

@@ -7,7 +7,7 @@ interface ChatState {
activeChatId: number | null; activeChatId: number | null;
messagesByChat: Record<number, Message[]>; messagesByChat: Record<number, Message[]>;
typingByChat: Record<number, number[]>; typingByChat: Record<number, number[]>;
loadChats: () => Promise<void>; loadChats: (query?: string) => Promise<void>;
setActiveChatId: (chatId: number | null) => void; setActiveChatId: (chatId: number | null) => void;
loadMessages: (chatId: number) => Promise<void>; loadMessages: (chatId: number) => Promise<void>;
prependMessage: (chatId: number, message: Message) => void; prependMessage: (chatId: number, message: Message) => void;
@@ -29,9 +29,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
activeChatId: null, activeChatId: null,
messagesByChat: {}, messagesByChat: {},
typingByChat: {}, typingByChat: {},
loadChats: async () => { loadChats: async (query) => {
const chats = await getChats(); const chats = await getChats(query);
set({ chats, activeChatId: chats[0]?.id ?? null }); const currentActive = get().activeChatId;
const nextActive = chats.some((chat) => chat.id === currentActive) ? currentActive : (chats[0]?.id ?? null);
set({ chats, activeChatId: nextActive });
}, },
setActiveChatId: (chatId) => set({ activeChatId: chatId }), setActiveChatId: (chatId) => set({ activeChatId: chatId }),
loadMessages: async (chatId) => { loadMessages: async (chatId) => {