feat: chat search

This commit is contained in:
gosangam
2026-08-30 10:57:28 +05:30
parent cc9ea45c61
commit bf8db28a7c
+84 -15
View File
@@ -16,14 +16,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { MessageCircle, Users } from "lucide-react";
import { MessageCircle, Search, Users, X } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
import { ChatListItem } from "@/components/chat/chat-list-item";
import { NewGroupDialog } from "@/components/chat/new-group-dialog";
import { chat, type InboxItem, type InboxTab } from "@/lib/chat";
import {
chat,
conversationTitle,
type InboxItem,
type InboxTab,
} from "@/lib/chat";
import { cn } from "@/lib/utils";
/** How long a row stays highlighted after a message lands. */
@@ -36,6 +41,15 @@ export default function ChatsPage() {
const router = useRouter();
const [tab, setTab] = useState<InboxTab>("chats");
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState("");
// Closing clears the term too: leaving a filter applied behind a collapsed
// field is how a chat goes missing and looks like a bug.
const closeSearch = useCallback(() => {
setSearching(false);
setQuery("");
}, []);
const [items, setItems] = useState<InboxItem[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
@@ -120,22 +134,69 @@ export default function ChatsPage() {
);
}
// Filtered here rather than on the server: the inbox is already loaded in
// full, so this is instant and keeps working offline. Matches the thread's
// name and, for a DM, the peer's handle — the two things you actually
// remember someone by.
const term = query.trim().toLowerCase();
const visible = term
? items.filter((item) => {
const title = conversationTitle(item).toLowerCase();
const handle = item.peer?.handle?.toLowerCase() ?? "";
return title.includes(term) || handle.includes(term);
})
: items;
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
<div className="flex items-center justify-between gap-3">
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Chats
</h1>
{tab === "chats" && (
{searching ? (
// The search field REPLACES the header rather than sitting under it:
// the title and New group are not useful while filtering, and stacking
// a second row would push the list down every time it opens.
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search chats"
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
/>
<button
type="button"
onClick={() => setCreatingGroup(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
onClick={closeSearch}
aria-label="Close search"
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
>
<Users className="size-4" /> New group
<X className="size-4" />
</button>
)}
</div>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Chats
</h1>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setSearching(true)}
aria-label="Search chats"
className="inline-flex size-9 items-center justify-center rounded-lg border border-border text-foreground hover:bg-muted/60"
>
<Search className="size-4" />
</button>
{tab === "chats" && (
<button
type="button"
onClick={() => setCreatingGroup(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted/60"
>
<Users className="size-4" /> New group
</button>
)}
</div>
</div>
)}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
@@ -155,8 +216,16 @@ export default function ChatsPage() {
</p>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
tab === "kautilya" ? (
) : visible.length === 0 ? (
// A search that matches nothing is NOT an empty inbox: saying "no
// chats yet" there reads as data loss rather than a narrow filter.
term ? (
<Empty
icon={<Search className="size-8" />}
title="No matches"
text={`Nothing in ${tab === "kautilya" ? "Kautilya" : "your chats"} matches “${query.trim()}”.`}
/>
) : tab === "kautilya" ? (
<Empty
icon={<KautilyaIcon className="size-8" />}
title="Kautilya is coming soon"
@@ -171,7 +240,7 @@ export default function ChatsPage() {
)
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((item) => (
{visible.map((item) => (
<ChatListItem
key={item.id}
item={item}