diff --git a/app/main.py b/app/main.py index ed32b3a..606f939 100644 --- a/app/main.py +++ b/app/main.py @@ -13,6 +13,7 @@ from app.media.router import router as media_router from app.messages.router import router as messages_router from app.notifications.router import router as notifications_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.users.router import router as users_router 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(notifications_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) diff --git a/app/search/__init__.py b/app/search/__init__.py new file mode 100644 index 0000000..fe16459 --- /dev/null +++ b/app/search/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/app/search/router.py b/app/search/router.py new file mode 100644 index 0000000..96a24a1 --- /dev/null +++ b/app/search/router.py @@ -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, + ) + diff --git a/app/search/schemas.py b/app/search/schemas.py new file mode 100644 index 0000000..ab4ad63 --- /dev/null +++ b/app/search/schemas.py @@ -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] + diff --git a/app/search/service.py b/app/search/service.py new file mode 100644 index 0000000..415e886 --- /dev/null +++ b/app/search/service.py @@ -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, + ) diff --git a/web/src/api/search.ts b/web/src/api/search.ts new file mode 100644 index 0000000..f97d92b --- /dev/null +++ b/web/src/api/search.ts @@ -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 { + const { data } = await http.get("/search", { + params: { query, users_limit: 10, chats_limit: 10, messages_limit: 10 } + }); + return data; +} + diff --git a/web/src/components/ChatList.tsx b/web/src/components/ChatList.tsx index 7f4499f..f283d7d 100644 --- a/web/src/components/ChatList.tsx +++ b/web/src/components/ChatList.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { clearChat, createPrivateChat, discoverChats, deleteChat, getChats, joinChat } from "../api/chats"; -import { searchUsers } from "../api/users"; -import type { DiscoverChat, UserSearchItem } from "../chat/types"; +import { clearChat, createPrivateChat, deleteChat, getChats, joinChat } from "../api/chats"; +import { globalSearch } from "../api/search"; +import type { DiscoverChat, Message, UserSearchItem } from "../chat/types"; import { updateMyProfile } from "../api/users"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; @@ -13,11 +13,13 @@ export function ChatList() { const messagesByChat = useChatStore((s) => s.messagesByChat); const activeChatId = useChatStore((s) => s.activeChatId); const setActiveChatId = useChatStore((s) => s.setActiveChatId); + const setFocusedMessage = useChatStore((s) => s.setFocusedMessage); const loadChats = useChatStore((s) => s.loadChats); const me = useAuthStore((s) => s.me); const [search, setSearch] = useState(""); const [userResults, setUserResults] = useState([]); const [discoverResults, setDiscoverResults] = useState([]); + const [messageResults, setMessageResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [tab, setTab] = useState<"all" | "people" | "groups" | "channels">("all"); const [ctxChatId, setCtxChatId] = useState(null); @@ -40,17 +42,15 @@ export function ChatList() { ); useEffect(() => { - const timer = setTimeout(() => { - void loadChats(search.trim() ? search : undefined); - }, 250); - return () => clearTimeout(timer); - }, [search, loadChats]); + void loadChats(); + }, [loadChats]); useEffect(() => { const term = search.trim(); if (term.replace("@", "").length < 2) { setUserResults([]); setDiscoverResults([]); + setMessageResults([]); setSearchLoading(false); return; } @@ -58,16 +58,18 @@ export function ChatList() { setSearchLoading(true); void (async () => { try { - const [users, publicChats] = await Promise.all([searchUsers(term), discoverChats(term)]); + const result = await globalSearch(term); if (cancelled) { return; } - setUserResults(users); - setDiscoverResults(publicChats); + setUserResults(result.users); + setDiscoverResults(result.chats); + setMessageResults(result.messages); } catch { if (!cancelled) { setUserResults([]); setDiscoverResults([]); + setMessageResults([]); } } finally { if (!cancelled) { @@ -156,7 +158,7 @@ export function ChatList() { {search.trim().replace("@", "").length >= 2 ? (
{searchLoading ?

Searching...

: null} - {!searchLoading && userResults.length === 0 && discoverResults.length === 0 ? ( + {!searchLoading && userResults.length === 0 && discoverResults.length === 0 && messageResults.length === 0 ? (

Nothing found

) : null} {userResults.length > 0 ? ( @@ -173,6 +175,7 @@ export function ChatList() { setSearch(""); setUserResults([]); setDiscoverResults([]); + setMessageResults([]); }} >

{user.name}

@@ -197,6 +200,7 @@ export function ChatList() { setSearch(""); setUserResults([]); setDiscoverResults([]); + setMessageResults([]); }} >
@@ -208,6 +212,28 @@ export function ChatList() { ))}
) : null} + {messageResults.length > 0 ? ( +
+

Messages

+ {messageResults.slice(0, 5).map((message) => ( + + ))} +
+ ) : null}
) : null} @@ -299,7 +325,7 @@ export function ChatList() { return; } await deleteChat(deleteModalChatId, canDeleteForEveryone && deleteForAll); - await loadChats(search.trim() ? search : undefined); + await loadChats(); if (activeChatId === deleteModalChatId) { setActiveChatId(null); } @@ -351,7 +377,7 @@ export function ChatList() { allow_private_messages: profileAllowPrivateMessages }); useAuthStore.setState({ me: updated }); - await loadChats(search.trim() ? search : undefined); + await loadChats(); setProfileOpen(false); } catch { setProfileError("Failed to update profile"); diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 461625a..9e0c581 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file