feat(web): add message search modal (chat/global)
Some checks failed
CI / test (push) Failing after 19s
Some checks failed
CI / test (push) Failing after 19s
- add Search action in chat header - support search in active chat or globally when no chat selected - show searchable message results and jump to target chat on click
This commit is contained in:
@@ -3,6 +3,8 @@ import { ChatList } from "../components/ChatList";
|
|||||||
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
import { ChatInfoPanel } from "../components/ChatInfoPanel";
|
||||||
import { MessageComposer } from "../components/MessageComposer";
|
import { MessageComposer } from "../components/MessageComposer";
|
||||||
import { MessageList } from "../components/MessageList";
|
import { MessageList } from "../components/MessageList";
|
||||||
|
import { searchMessages } from "../api/chats";
|
||||||
|
import type { Message } from "../chat/types";
|
||||||
import { useRealtime } from "../hooks/useRealtime";
|
import { useRealtime } from "../hooks/useRealtime";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
@@ -19,6 +21,10 @@ export function ChatsPage() {
|
|||||||
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
const activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||||
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
|
const isReadOnlyChannel = Boolean(activeChat && activeChat.type === "channel" && activeChat.my_role === "member");
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
||||||
|
|
||||||
useRealtime();
|
useRealtime();
|
||||||
|
|
||||||
@@ -32,6 +38,39 @@ export function ChatsPage() {
|
|||||||
}
|
}
|
||||||
}, [activeChatId, loadMessages]);
|
}, [activeChatId, loadMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const term = searchQuery.trim();
|
||||||
|
if (term.length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setSearchLoading(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const found = await searchMessages(term, activeChatId ?? undefined);
|
||||||
|
if (!cancelled) {
|
||||||
|
setSearchResults(found);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [searchOpen, searchQuery, activeChatId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-screen w-full p-2 text-text md:p-4">
|
<main className="h-screen w-full p-2 text-text md:p-4">
|
||||||
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
|
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
|
||||||
@@ -55,9 +94,14 @@ export function ChatsPage() {
|
|||||||
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
|
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
|
<div className="flex items-center gap-2">
|
||||||
Logout
|
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
|
||||||
</button>
|
Search
|
||||||
|
</button>
|
||||||
|
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<MessageList />
|
<MessageList />
|
||||||
@@ -76,6 +120,41 @@ export function ChatsPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} />
|
<ChatInfoPanel chatId={activeChatId} open={infoOpen} onClose={() => setInfoOpen(false)} />
|
||||||
|
{searchOpen ? (
|
||||||
|
<div className="fixed inset-0 z-[130] flex items-start justify-center bg-slate-950/60 p-3 md:p-6" onClick={() => setSearchOpen(false)}>
|
||||||
|
<div className="w-full max-w-xl rounded-2xl border border-slate-700/80 bg-slate-900 p-3 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold">Search messages</p>
|
||||||
|
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setSearchOpen(false)}>Close</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="mb-2 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={activeChatId ? "Search in current chat..." : "Global search..."}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
{searchLoading ? <p className="px-2 py-1 text-xs text-slate-400">Searching...</p> : null}
|
||||||
|
{!searchLoading && searchQuery.trim().length >= 2 && searchResults.length === 0 ? (
|
||||||
|
<p className="px-2 py-1 text-xs text-slate-400">Nothing found</p>
|
||||||
|
) : null}
|
||||||
|
<div className="tg-scrollbar max-h-[50vh] space-y-1 overflow-auto">
|
||||||
|
{searchResults.map((message) => (
|
||||||
|
<button
|
||||||
|
className="block w-full rounded-lg bg-slate-800/80 px-3 py-2 text-left hover:bg-slate-700/80"
|
||||||
|
key={`search-msg-${message.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveChatId(message.chat_id);
|
||||||
|
setSearchOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-[11px] text-slate-400">chat #{message.chat_id} · msg #{message.id}</p>
|
||||||
|
<p className="line-clamp-2 text-sm text-slate-100">{message.text || "[media]"}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user