feat(search): add unified global search for users/chats/messages
Some checks failed
CI / test (push) Failing after 24s
Some checks failed
CI / test (push) Failing after 24s
This commit is contained in:
@@ -13,6 +13,7 @@ from app.media.router import router as media_router
|
|||||||
from app.messages.router import router as messages_router
|
from app.messages.router import router as messages_router
|
||||||
from app.notifications.router import router as notifications_router
|
from app.notifications.router import router as notifications_router
|
||||||
from app.realtime.router import router as realtime_router
|
from app.realtime.router import router as realtime_router
|
||||||
|
from app.search.router import router as search_router
|
||||||
from app.realtime.service import realtime_gateway
|
from app.realtime.service import realtime_gateway
|
||||||
from app.users.router import router as users_router
|
from app.users.router import router as users_router
|
||||||
from app.utils.redis_client import close_redis_client, get_redis_client
|
from app.utils.redis_client import close_redis_client, get_redis_client
|
||||||
@@ -74,3 +75,4 @@ app.include_router(messages_router, prefix=settings.api_v1_prefix)
|
|||||||
app.include_router(media_router, prefix=settings.api_v1_prefix)
|
app.include_router(media_router, prefix=settings.api_v1_prefix)
|
||||||
app.include_router(notifications_router, prefix=settings.api_v1_prefix)
|
app.include_router(notifications_router, prefix=settings.api_v1_prefix)
|
||||||
app.include_router(realtime_router, prefix=settings.api_v1_prefix)
|
app.include_router(realtime_router, prefix=settings.api_v1_prefix)
|
||||||
|
app.include_router(search_router, prefix=settings.api_v1_prefix)
|
||||||
|
|||||||
2
app/search/__init__.py
Normal file
2
app/search/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__all__ = []
|
||||||
|
|
||||||
30
app/search/router.py
Normal file
30
app/search/router.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.service import get_current_user
|
||||||
|
from app.database.session import get_db
|
||||||
|
from app.search.schemas import GlobalSearchRead
|
||||||
|
from app.search.service import global_search
|
||||||
|
from app.users.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/search", tags=["search"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=GlobalSearchRead)
|
||||||
|
async def global_search_endpoint(
|
||||||
|
query: str,
|
||||||
|
users_limit: int = 10,
|
||||||
|
chats_limit: int = 10,
|
||||||
|
messages_limit: int = 10,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> GlobalSearchRead:
|
||||||
|
return await global_search(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
query=query,
|
||||||
|
users_limit=users_limit,
|
||||||
|
chats_limit=chats_limit,
|
||||||
|
messages_limit=messages_limit,
|
||||||
|
)
|
||||||
|
|
||||||
12
app/search/schemas.py
Normal file
12
app/search/schemas.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.chats.schemas import ChatDiscoverRead
|
||||||
|
from app.messages.schemas import MessageRead
|
||||||
|
from app.users.schemas import UserSearchRead
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSearchRead(BaseModel):
|
||||||
|
users: list[UserSearchRead]
|
||||||
|
chats: list[ChatDiscoverRead]
|
||||||
|
messages: list[MessageRead]
|
||||||
|
|
||||||
82
app/search/service.py
Normal file
82
app/search/service.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.chats.models import Chat
|
||||||
|
from app.chats import repository as chats_repository
|
||||||
|
from app.chats.schemas import ChatDiscoverRead
|
||||||
|
from app.chats.service import serialize_chat_for_user
|
||||||
|
from app.messages.service import search_messages
|
||||||
|
from app.search.schemas import GlobalSearchRead
|
||||||
|
from app.users.service import search_users_by_username
|
||||||
|
|
||||||
|
|
||||||
|
async def global_search(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
query: str,
|
||||||
|
users_limit: int = 10,
|
||||||
|
chats_limit: int = 10,
|
||||||
|
messages_limit: int = 10,
|
||||||
|
) -> GlobalSearchRead:
|
||||||
|
normalized = query.strip()
|
||||||
|
if len(normalized.lstrip("@")) < 2:
|
||||||
|
return GlobalSearchRead(users=[], chats=[], messages=[])
|
||||||
|
|
||||||
|
users_task = search_users_by_username(
|
||||||
|
db,
|
||||||
|
query=normalized,
|
||||||
|
limit=max(1, min(users_limit, 50)),
|
||||||
|
exclude_user_id=user_id,
|
||||||
|
)
|
||||||
|
messages_task = search_messages(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
query=normalized,
|
||||||
|
chat_id=None,
|
||||||
|
limit=max(1, min(messages_limit, 50)),
|
||||||
|
)
|
||||||
|
|
||||||
|
users, messages = await asyncio.gather(users_task, messages_task)
|
||||||
|
|
||||||
|
# Combine own chats and discoverable public chats into one chat result list.
|
||||||
|
own_chats = await chats_repository.list_user_chats(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
limit=max(1, min(chats_limit, 50)),
|
||||||
|
query=normalized,
|
||||||
|
)
|
||||||
|
own_chat_ids = {chat.id for chat in own_chats}
|
||||||
|
discovered_rows = await chats_repository.discover_public_chats(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
query=normalized,
|
||||||
|
limit=max(1, min(chats_limit, 50)),
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_chats: list[tuple[Chat, bool]] = [(chat, True) for chat in own_chats]
|
||||||
|
for chat, is_member in discovered_rows:
|
||||||
|
if chat.id in own_chat_ids:
|
||||||
|
continue
|
||||||
|
merged_chats.append((chat, is_member))
|
||||||
|
if len(merged_chats) >= max(1, min(chats_limit, 50)):
|
||||||
|
break
|
||||||
|
|
||||||
|
chats: list[ChatDiscoverRead] = []
|
||||||
|
for chat, is_member in merged_chats:
|
||||||
|
serialized = await serialize_chat_for_user(db, user_id=user_id, chat=chat)
|
||||||
|
chats.append(
|
||||||
|
ChatDiscoverRead.model_validate(
|
||||||
|
{
|
||||||
|
**serialized.model_dump(),
|
||||||
|
"is_member": bool(is_member),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return GlobalSearchRead(
|
||||||
|
users=users,
|
||||||
|
chats=chats,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
16
web/src/api/search.ts
Normal file
16
web/src/api/search.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { http } from "./http";
|
||||||
|
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
|
||||||
|
|
||||||
|
export interface GlobalSearchResponse {
|
||||||
|
users: UserSearchItem[];
|
||||||
|
chats: DiscoverChat[];
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function globalSearch(query: string): Promise<GlobalSearchResponse> {
|
||||||
|
const { data } = await http.get<GlobalSearchResponse>("/search", {
|
||||||
|
params: { query, users_limit: 10, chats_limit: 10, messages_limit: 10 }
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { clearChat, createPrivateChat, discoverChats, deleteChat, getChats, joinChat } from "../api/chats";
|
import { clearChat, createPrivateChat, deleteChat, getChats, joinChat } from "../api/chats";
|
||||||
import { searchUsers } from "../api/users";
|
import { globalSearch } from "../api/search";
|
||||||
import type { DiscoverChat, UserSearchItem } from "../chat/types";
|
import type { DiscoverChat, Message, UserSearchItem } from "../chat/types";
|
||||||
import { updateMyProfile } from "../api/users";
|
import { updateMyProfile } from "../api/users";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
@@ -13,11 +13,13 @@ 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 setFocusedMessage = useChatStore((s) => s.setFocusedMessage);
|
||||||
const loadChats = useChatStore((s) => s.loadChats);
|
const loadChats = useChatStore((s) => s.loadChats);
|
||||||
const me = useAuthStore((s) => s.me);
|
const me = useAuthStore((s) => s.me);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
const [userResults, setUserResults] = useState<UserSearchItem[]>([]);
|
||||||
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
const [discoverResults, setDiscoverResults] = useState<DiscoverChat[]>([]);
|
||||||
|
const [messageResults, setMessageResults] = useState<Message[]>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
|
const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all");
|
||||||
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
|
const [ctxChatId, setCtxChatId] = useState<number | null>(null);
|
||||||
@@ -40,17 +42,15 @@ export function ChatList() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
void loadChats();
|
||||||
void loadChats(search.trim() ? search : undefined);
|
}, [loadChats]);
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [search, loadChats]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const term = search.trim();
|
const term = search.trim();
|
||||||
if (term.replace("@", "").length < 2) {
|
if (term.replace("@", "").length < 2) {
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -58,16 +58,18 @@ export function ChatList() {
|
|||||||
setSearchLoading(true);
|
setSearchLoading(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [users, publicChats] = await Promise.all([searchUsers(term), discoverChats(term)]);
|
const result = await globalSearch(term);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUserResults(users);
|
setUserResults(result.users);
|
||||||
setDiscoverResults(publicChats);
|
setDiscoverResults(result.chats);
|
||||||
|
setMessageResults(result.messages);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -156,7 +158,7 @@ export function ChatList() {
|
|||||||
{search.trim().replace("@", "").length >= 2 ? (
|
{search.trim().replace("@", "").length >= 2 ? (
|
||||||
<div className="mt-3 rounded-xl border border-slate-700/70 bg-slate-900/70 p-2">
|
<div className="mt-3 rounded-xl border border-slate-700/70 bg-slate-900/70 p-2">
|
||||||
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
|
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
|
||||||
{!searchLoading && userResults.length === 0 && discoverResults.length === 0 ? (
|
{!searchLoading && userResults.length === 0 && discoverResults.length === 0 && messageResults.length === 0 ? (
|
||||||
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
|
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
|
||||||
) : null}
|
) : null}
|
||||||
{userResults.length > 0 ? (
|
{userResults.length > 0 ? (
|
||||||
@@ -173,6 +175,7 @@ export function ChatList() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="truncate text-xs font-semibold">{user.name}</p>
|
<p className="truncate text-xs font-semibold">{user.name}</p>
|
||||||
@@ -197,6 +200,7 @@ export function ChatList() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setUserResults([]);
|
setUserResults([]);
|
||||||
setDiscoverResults([]);
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -208,6 +212,28 @@ export function ChatList() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{messageResults.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-400">Messages</p>
|
||||||
|
{messageResults.slice(0, 5).map((message) => (
|
||||||
|
<button
|
||||||
|
className="block w-full rounded-lg px-2 py-1.5 text-left hover:bg-slate-800"
|
||||||
|
key={`message-${message.id}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setActiveChatId(message.chat_id);
|
||||||
|
setFocusedMessage(message.chat_id, message.id);
|
||||||
|
setSearch("");
|
||||||
|
setUserResults([]);
|
||||||
|
setDiscoverResults([]);
|
||||||
|
setMessageResults([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="truncate text-[11px] text-slate-400">chat #{message.chat_id}</p>
|
||||||
|
<p className="truncate text-xs font-semibold">{message.text || "[media]"}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +325,7 @@ export function ChatList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll);
|
||||||
await loadChats(search.trim() ? search : undefined);
|
await loadChats();
|
||||||
if (activeChatId === deleteModalChatId) {
|
if (activeChatId === deleteModalChatId) {
|
||||||
setActiveChatId(null);
|
setActiveChatId(null);
|
||||||
}
|
}
|
||||||
@@ -351,7 +377,7 @@ export function ChatList() {
|
|||||||
allow_private_messages: profileAllowPrivateMessages
|
allow_private_messages: profileAllowPrivateMessages
|
||||||
});
|
});
|
||||||
useAuthStore.setState({ me: updated });
|
useAuthStore.setState({ me: updated });
|
||||||
await loadChats(search.trim() ? search : undefined);
|
await loadChats();
|
||||||
setProfileOpen(false);
|
setProfileOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
setProfileError("Failed to update profile");
|
setProfileError("Failed to update profile");
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/notifications.ts","./src/api/search.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/apperrorboundary.tsx","./src/components/authpanel.tsx","./src/components/chatinfopanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"}
|
||||||
Reference in New Issue
Block a user