void;
onOpenChannel: (channelId: string) => void;
@@ -94,6 +95,7 @@ type SearchResultSection = {
export function SearchDialog({
channels,
currentPubkey,
+ initialQuery = "",
open,
onOpenChange,
onOpenChannel,
@@ -240,6 +242,15 @@ export function SearchDialog({
}
}, [open]);
+ React.useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ setQuery(initialQuery);
+ setSelectedIndex(0);
+ }, [initialQuery, open]);
+
React.useEffect(() => {
setSelectedIndex((current) => {
if (results.length === 0) {
diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx
new file mode 100644
index 000000000..ebecd2263
--- /dev/null
+++ b/desktop/src/features/search/ui/TopbarSearch.tsx
@@ -0,0 +1,307 @@
+import * as React from "react";
+import { Search } from "lucide-react";
+
+import {
+ useUserSearchQuery,
+ useUsersBatchQuery,
+} from "@/features/profile/hooks";
+import { useSearchMessagesQuery } from "@/features/search/hooks";
+import {
+ ChannelResultBody,
+ MessageResultBody,
+ resultIcon,
+ resultKey,
+ resultTestId,
+ SearchResultShell,
+ type SearchResult,
+ UserResultAvatar,
+ UserResultBody,
+} from "@/features/search/ui/SearchResultItem";
+import type { Channel, SearchHit } from "@/shared/api/types";
+import { Input } from "@/shared/ui/input";
+
+const MIN_QUERY_LENGTH = 2;
+
+type SearchResultSection = {
+ count: number;
+ results: SearchResult[];
+ title: string;
+};
+
+type TopbarSearchProps = {
+ channels: Channel[];
+ currentPubkey?: string;
+ onOpenChannel: (channelId: string) => void;
+ onOpenResult: (hit: SearchHit) => void;
+ onOpenUser: (pubkey: string) => void;
+};
+
+export function TopbarSearch({
+ channels,
+ currentPubkey,
+ onOpenChannel,
+ onOpenResult,
+ onOpenUser,
+}: TopbarSearchProps) {
+ const [query, setQuery] = React.useState("");
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
+ const [isFocused, setIsFocused] = React.useState(false);
+ const inputRef = React.useRef
(null);
+ const trimmedQuery = query.trim();
+ const shouldSearch = isFocused && trimmedQuery.length >= MIN_QUERY_LENGTH;
+ const channelLookup = React.useMemo(
+ () => new Map(channels.map((channel) => [channel.id, channel])),
+ [channels],
+ );
+
+ const searchQuery = useSearchMessagesQuery(trimmedQuery, {
+ enabled: shouldSearch,
+ limit: 8,
+ });
+ const usersQuery = useUserSearchQuery(trimmedQuery, {
+ enabled: shouldSearch,
+ limit: 4,
+ });
+
+ const messageResults = searchQuery.data?.hits ?? [];
+ const userResults = React.useMemo(
+ () =>
+ (usersQuery.data ?? []).filter(
+ (user) => user.pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
+ ),
+ [currentPubkey, usersQuery.data],
+ );
+ const channelResults = React.useMemo(() => {
+ if (!shouldSearch) {
+ return [];
+ }
+
+ const normalizedQuery = trimmedQuery.toLowerCase();
+
+ return channels
+ .filter(
+ (channel) =>
+ channel.channelType !== "dm" &&
+ (channel.archivedAt
+ ? channel.isMember
+ : channel.visibility === "open" || channel.isMember) &&
+ (channel.name.toLowerCase().includes(normalizedQuery) ||
+ channel.description.toLowerCase().includes(normalizedQuery)),
+ )
+ .sort((a, b) => {
+ const aNameMatches = a.name.toLowerCase().includes(normalizedQuery);
+ const bNameMatches = b.name.toLowerCase().includes(normalizedQuery);
+
+ if (aNameMatches !== bNameMatches) {
+ return aNameMatches ? -1 : 1;
+ }
+
+ return a.name.localeCompare(b.name);
+ })
+ .slice(0, 4);
+ }, [channels, shouldSearch, trimmedQuery]);
+
+ const sections = React.useMemo(
+ () =>
+ [
+ {
+ count: userResults.length,
+ results: userResults.map((user) => ({
+ kind: "user" as const,
+ user,
+ })),
+ title: "People",
+ },
+ {
+ count: channelResults.length,
+ results: channelResults.map((channel) => ({
+ kind: "channel" as const,
+ channel,
+ })),
+ title: "Channels",
+ },
+ {
+ count: searchQuery.data?.found ?? messageResults.length,
+ results: messageResults.map((hit) => ({
+ kind: "message" as const,
+ hit,
+ })),
+ title: "Messages",
+ },
+ ].filter((section) => section.results.length > 0),
+ [channelResults, messageResults, searchQuery.data?.found, userResults],
+ );
+ const results = React.useMemo(
+ () => sections.flatMap((section) => section.results),
+ [sections],
+ );
+ const resultProfilesQuery = useUsersBatchQuery(
+ messageResults.map((hit) => hit.pubkey),
+ {
+ enabled: shouldSearch && messageResults.length > 0,
+ },
+ );
+ const resultProfiles = resultProfilesQuery.data?.profiles;
+
+ React.useEffect(() => {
+ setSelectedIndex((current) => {
+ if (results.length === 0) {
+ return 0;
+ }
+
+ return Math.min(current, results.length - 1);
+ });
+ }, [results]);
+
+ const reset = React.useCallback(() => {
+ setQuery("");
+ setSelectedIndex(0);
+ setIsFocused(false);
+ inputRef.current?.blur();
+ }, []);
+
+ const openResult = React.useCallback(
+ (result: SearchResult) => {
+ reset();
+
+ if (result.kind === "channel") {
+ onOpenChannel(result.channel.id);
+ return;
+ }
+
+ if (result.kind === "user") {
+ onOpenUser(result.user.pubkey);
+ return;
+ }
+
+ onOpenResult(result.hit);
+ },
+ [onOpenChannel, onOpenResult, onOpenUser, reset],
+ );
+
+ const selectedResult = results[selectedIndex];
+ const isSearching = searchQuery.isLoading || usersQuery.isLoading;
+ const showResults = shouldSearch;
+
+ return (
+
+
+
+ {
+ window.setTimeout(() => setIsFocused(false), 120);
+ }}
+ onChange={(event) => {
+ setQuery(event.target.value);
+ setSelectedIndex(0);
+ setIsFocused(true);
+ }}
+ onFocus={() => setIsFocused(true)}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ reset();
+ return;
+ }
+
+ if (event.key === "ArrowDown" && results.length > 0) {
+ event.preventDefault();
+ setSelectedIndex((current) =>
+ Math.min(current + 1, results.length - 1),
+ );
+ return;
+ }
+
+ if (event.key === "ArrowUp" && results.length > 0) {
+ event.preventDefault();
+ setSelectedIndex((current) => Math.max(current - 1, 0));
+ return;
+ }
+
+ if (
+ event.key === "Enter" &&
+ !event.nativeEvent.isComposing &&
+ selectedResult
+ ) {
+ event.preventDefault();
+ openResult(selectedResult);
+ }
+ }}
+ placeholder="Search Sprout"
+ ref={inputRef}
+ value={query}
+ />
+
+
+ {showResults ? (
+
+ {results.length === 0 ? (
+
+ {isSearching ? "Searching..." : "No matches found."}
+
+ ) : (
+
+ {sections.map((section) => {
+ let sectionStartIndex = 0;
+ for (const previousSection of sections) {
+ if (previousSection === section) {
+ break;
+ }
+ sectionStartIndex += previousSection.results.length;
+ }
+
+ return (
+
+
+ {section.title}
+ {section.count}
+
+
+ {section.results.map((result, index) => {
+ const absoluteIndex = sectionStartIndex + index;
+
+ return (
+
+ ) : undefined
+ }
+ onClick={() => openResult(result)}
+ onMouseEnter={() => setSelectedIndex(absoluteIndex)}
+ testId={resultTestId(result)}
+ >
+ {result.kind === "channel" ? (
+
+ ) : result.kind === "user" ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ );
+ })}
+
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts
index b0a8384af..018d25c09 100644
--- a/desktop/tests/e2e/smoke.spec.ts
+++ b/desktop/tests/e2e/smoke.spec.ts
@@ -71,17 +71,6 @@ async function openSearchDialogWithShortcut(
.toBe(true);
}
-async function openSearchDialogWithButton(
- page: import("@playwright/test").Page,
-) {
- const searchDialog = page.getByTestId("search-dialog");
- const openSearchButton = page.getByTestId("global-search");
-
- await expect(openSearchButton).toBeVisible();
- await openSearchButton.click();
- await expect(searchDialog).toBeVisible();
-}
-
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -249,7 +238,7 @@ test("opens relay-backed search from the sidebar and loads the exact result", as
test("opens channel matches from search", async ({ page }) => {
await page.goto("/");
- await openSearchDialogWithButton(page);
+ await openSearchDialogWithShortcut(page);
await page.getByTestId("search-input").fill("engineering");
const results = page.getByTestId("search-results");
@@ -276,10 +265,31 @@ test("opens channel matches from search", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
});
+test("topbar search keeps focus inline until enter", async ({ page }) => {
+ await page.goto("/");
+
+ const topbarSearch = page.getByTestId("global-search");
+ await expect(topbarSearch).toBeVisible();
+
+ await topbarSearch.fill("engineering");
+ const inlineResults = page.getByTestId("topbar-search-results");
+
+ await expect(page.getByTestId("search-dialog")).not.toBeVisible();
+ await expect(inlineResults).toContainText("Channels");
+ await expect(inlineResults).toContainText("engineering");
+
+ await topbarSearch.press("Enter");
+
+ await expect(page).toHaveURL(
+ /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9$/,
+ );
+ await expect(page.getByTestId("chat-title")).toHaveText("engineering");
+});
+
test("opens people matches from global search", async ({ page }) => {
await page.goto("/");
- await openSearchDialogWithButton(page);
+ await openSearchDialogWithShortcut(page);
await page.getByTestId("search-input").fill("alice");
const results = page.getByTestId("search-results");
@@ -303,7 +313,7 @@ test("search results use your resolved profile label instead of You", async ({
}) => {
await page.goto("/");
- await openSearchDialogWithButton(page);
+ await openSearchDialogWithShortcut(page);
await page.getByTestId("search-input").fill("welcome");
const results = page.getByTestId("search-results");
@@ -318,7 +328,7 @@ test("opens accessible unjoined channels from search in read-only mode", async (
}) => {
await page.goto("/");
- await openSearchDialogWithButton(page);
+ await openSearchDialogWithShortcut(page);
await page.getByTestId("search-input").fill("critique");
const results = page.getByTestId("search-results");