feat: chat search
This commit is contained in:
@@ -16,14 +16,19 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useNotifications } from "@/components/notifications/notifications-provider";
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
||||||
import { ChatListItem } from "@/components/chat/chat-list-item";
|
import { ChatListItem } from "@/components/chat/chat-list-item";
|
||||||
import { NewGroupDialog } from "@/components/chat/new-group-dialog";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** How long a row stays highlighted after a message lands. */
|
/** How long a row stays highlighted after a message lands. */
|
||||||
@@ -36,6 +41,15 @@ export default function ChatsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [tab, setTab] = useState<InboxTab>("chats");
|
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 [items, setItems] = useState<InboxItem[]>([]);
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
|
const [highlighted, setHighlighted] = useState<Set<string>>(new Set());
|
||||||
@@ -120,12 +134,57 @@ 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 (
|
return (
|
||||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||||
|
{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={closeSearch}
|
||||||
|
aria-label="Close search"
|
||||||
|
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
|
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
|
||||||
Chats
|
Chats
|
||||||
</h1>
|
</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" && (
|
{tab === "chats" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -136,6 +195,8 @@ export default function ChatsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
||||||
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
|
<TabButton active={tab === "chats"} onClick={() => setTab("chats")}>
|
||||||
@@ -155,8 +216,16 @@ export default function ChatsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : state === "loading" ? (
|
) : state === "loading" ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
) : items.length === 0 ? (
|
) : visible.length === 0 ? (
|
||||||
tab === "kautilya" ? (
|
// 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
|
<Empty
|
||||||
icon={<KautilyaIcon className="size-8" />}
|
icon={<KautilyaIcon className="size-8" />}
|
||||||
title="Kautilya is coming soon"
|
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">
|
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
|
||||||
{items.map((item) => (
|
{visible.map((item) => (
|
||||||
<ChatListItem
|
<ChatListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
Reference in New Issue
Block a user