mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add inline global search topbar
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2a8a3dccb6
commit
33a63bded5
@@ -1,58 +1,74 @@
|
||||
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { Channel, SearchHit } from "@/shared/api/types";
|
||||
import { SidebarTrigger } from "@/shared/ui/sidebar";
|
||||
|
||||
type AppHeaderControlsProps = {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
onGoBack: () => void;
|
||||
onGoForward: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onOpenResult: (hit: SearchHit) => void;
|
||||
onOpenUser: (pubkey: string) => void;
|
||||
};
|
||||
|
||||
export function AppHeaderControls({
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
channels,
|
||||
currentPubkey,
|
||||
onGoBack,
|
||||
onGoForward,
|
||||
onOpenSearch,
|
||||
onOpenChannel,
|
||||
onOpenResult,
|
||||
onOpenUser,
|
||||
}: AppHeaderControlsProps) {
|
||||
return (
|
||||
<div className="fixed left-[80px] top-[9px] z-50 flex items-center gap-0.5">
|
||||
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-back"
|
||||
disabled={!canGoBack}
|
||||
onClick={onGoBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-forward"
|
||||
disabled={!canGoForward}
|
||||
onClick={onGoForward}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Search Sprout"
|
||||
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-search"
|
||||
onClick={onOpenSearch}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 h-20 bg-background/35 backdrop-blur-2xl backdrop-saturate-150 [mask-image:linear-gradient(to_bottom,black_0%,black_45%,transparent_100%)] supports-[backdrop-filter]:bg-background/25 [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_45%,transparent_100%)]" />
|
||||
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-[70] h-10">
|
||||
<div className="pointer-events-auto absolute left-[80px] top-[9px] flex items-center gap-0.5">
|
||||
<SidebarTrigger className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
|
||||
<Button
|
||||
aria-label="Go back"
|
||||
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-back"
|
||||
disabled={!canGoBack}
|
||||
onClick={onGoBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Go forward"
|
||||
className="h-[22px] w-[22px] text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
data-testid="global-forward"
|
||||
disabled={!canGoForward}
|
||||
onClick={onGoForward}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[7px] w-[min(420px,42vw)] -translate-x-1/2">
|
||||
<TopbarSearch
|
||||
channels={channels}
|
||||
currentPubkey={currentPubkey}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenResult={onOpenResult}
|
||||
onOpenUser={onOpenUser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export function AppShell() {
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [searchInitialQuery, setSearchInitialQuery] = React.useState("");
|
||||
const [browseDialogType, setBrowseDialogType] =
|
||||
React.useState<BrowseDialogType>(null);
|
||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||
@@ -313,10 +314,14 @@ export function AppShell() {
|
||||
setBrowseDialogType("forum");
|
||||
void refetchChannels();
|
||||
}, [refetchChannels]);
|
||||
const handleOpenSearch = React.useCallback(() => {
|
||||
setIsSearchOpen(true);
|
||||
void refetchChannels();
|
||||
}, [refetchChannels]);
|
||||
const handleOpenSearch = React.useCallback(
|
||||
(query = "") => {
|
||||
setSearchInitialQuery(query);
|
||||
setIsSearchOpen(true);
|
||||
void refetchChannels();
|
||||
},
|
||||
[refetchChannels],
|
||||
);
|
||||
|
||||
const handleBrowseDialogOpenChange = React.useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
@@ -617,9 +622,13 @@ export function AppShell() {
|
||||
<AppHeaderControls
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
onOpenSearch={handleOpenSearch}
|
||||
onOpenChannel={(channelId) => void goChannel(channelId)}
|
||||
onOpenResult={handleOpenSearchResult}
|
||||
onOpenUser={handleOpenSearchUser}
|
||||
/>
|
||||
<AppSidebar
|
||||
activeWorkspace={workspacesHook.activeWorkspace}
|
||||
@@ -700,25 +709,13 @@ export function AppShell() {
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => {
|
||||
void goAgents();
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onSelectHome={() => {
|
||||
void goHome();
|
||||
}}
|
||||
onSelectProjects={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
onSelectPulse={() => {
|
||||
void goPulse();
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={(channelId) => void goChannel(channelId)}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => {
|
||||
void goWorkflows();
|
||||
}}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
@@ -751,6 +748,7 @@ export function AppShell() {
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isSearchOpen={isSearchOpen}
|
||||
searchInitialQuery={searchInitialQuery}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={setIsChannelManagementOpen}
|
||||
@@ -761,9 +759,7 @@ export function AppShell() {
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
onOpenSearchUser={handleOpenSearchUser}
|
||||
onSearchOpenChange={setIsSearchOpen}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onSelectChannel={(channelId) => void goChannel(channelId)}
|
||||
/>
|
||||
|
||||
{settingsOpen ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ type AppShellOverlaysProps = {
|
||||
currentPubkey?: string;
|
||||
isChannelManagementOpen: boolean;
|
||||
isSearchOpen: boolean;
|
||||
searchInitialQuery: string;
|
||||
onBrowseChannelJoin: (channelId: string) => Promise<void>;
|
||||
onBrowseDialogOpenChange: (open: boolean) => void;
|
||||
onChannelManagementOpenChange: (open: boolean) => void;
|
||||
@@ -43,6 +44,7 @@ export function AppShellOverlays({
|
||||
currentPubkey,
|
||||
isChannelManagementOpen,
|
||||
isSearchOpen,
|
||||
searchInitialQuery,
|
||||
onBrowseChannelJoin,
|
||||
onBrowseDialogOpenChange,
|
||||
onChannelManagementOpenChange,
|
||||
@@ -72,6 +74,7 @@ export function AppShellOverlays({
|
||||
<SearchDialog
|
||||
channels={channels}
|
||||
currentPubkey={currentPubkey}
|
||||
initialQuery={searchInitialQuery}
|
||||
onOpenChannel={onSelectChannel}
|
||||
onOpenResult={onOpenSearchResult}
|
||||
onOpenUser={onOpenSearchUser}
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
|
||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
type ChatHeaderProps = {
|
||||
actions?: React.ReactNode;
|
||||
@@ -27,7 +27,7 @@ type ChatHeaderProps = {
|
||||
statusBadge?: React.ReactNode;
|
||||
};
|
||||
|
||||
const HEADER_ICON_CLASS = "h-[14px] w-[14px] text-muted-foreground";
|
||||
const HEADER_ICON_CLASS = "h-[14px] w-[14px] text-foreground";
|
||||
|
||||
function ChannelIcon({
|
||||
channelType,
|
||||
@@ -84,45 +84,59 @@ export function ChatHeader({
|
||||
statusBadge,
|
||||
}: ChatHeaderProps) {
|
||||
const trimmedDescription = description?.trim() ?? "";
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const reserveGlobalControls = sidebarState === "collapsed";
|
||||
const actionCluster = (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
const topbarActions =
|
||||
overlaysContent && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="fixed right-3 top-[7px] z-[60]">{actionCluster}</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"relative z-30 flex min-h-[44px] min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] bg-background/70 py-[6px] pl-[16px] pr-[8px] backdrop-blur-xl transition-[margin,padding] duration-200 ease-linear supports-[backdrop-filter]:bg-background/55 sm:pl-[24px] sm:pr-[12px]",
|
||||
overlaysContent && "-mb-[44px]",
|
||||
reserveGlobalControls && "md:pl-[160px]",
|
||||
)}
|
||||
data-testid="chat-header"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-[4px]">
|
||||
<ChannelIcon
|
||||
channelType={channelType}
|
||||
mode={mode}
|
||||
visibility={visibility}
|
||||
/>
|
||||
<h1
|
||||
className="min-w-0 truncate text-sm font-semibold leading-5 tracking-tight"
|
||||
data-testid="chat-title"
|
||||
title={trimmedDescription || undefined}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{statusBadge ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1">
|
||||
{statusBadge}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{topbarActions}
|
||||
<header
|
||||
className={cn(
|
||||
"relative z-30 flex min-h-[44px] min-w-0 shrink-0 cursor-default select-none items-center gap-[10px] py-[6px] pl-[16px] pr-[8px] transition-[margin,padding] duration-200 ease-linear sm:pl-[24px] sm:pr-[12px]",
|
||||
overlaysContent && "-mb-[44px]",
|
||||
)}
|
||||
data-testid="chat-header"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-background/70 backdrop-blur-xl [mask-image:linear-gradient(to_bottom,black_0%,transparent_100%)] supports-[backdrop-filter]:bg-background/55 [-webkit-mask-image:linear-gradient(to_bottom,black_0%,transparent_100%)]" />
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<UpdateIndicator />
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className="relative z-10 min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-[4px]">
|
||||
<ChannelIcon
|
||||
channelType={channelType}
|
||||
mode={mode}
|
||||
visibility={visibility}
|
||||
/>
|
||||
<h1
|
||||
className="min-w-0 truncate text-sm font-semibold leading-5 tracking-tight text-foreground"
|
||||
data-testid="chat-title"
|
||||
title={trimmedDescription || undefined}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{statusBadge ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1">
|
||||
{statusBadge}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overlaysContent ? null : (
|
||||
<div className="relative z-10">{actionCluster}</div>
|
||||
)}
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,11 +153,7 @@ export function MessageThreadPanel({
|
||||
<>
|
||||
{isOverlay && <OverlayPanelBackdrop onClose={onClose} />}
|
||||
<aside
|
||||
className={cn(
|
||||
PANEL_BASE_CLASS,
|
||||
!isOverlay && "pt-11",
|
||||
isOverlay && PANEL_OVERLAY_CLASS,
|
||||
)}
|
||||
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
|
||||
data-testid="message-thread-panel"
|
||||
style={{ width: `${widthPx}px` }}
|
||||
>
|
||||
@@ -179,12 +175,15 @@ export function MessageThreadPanel({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex min-h-[44px] items-center gap-3 px-4 py-[6px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
|
||||
<h2 className="text-sm font-semibold tracking-tight text-foreground">
|
||||
Thread
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close thread"
|
||||
className="pointer-events-auto h-7 w-7"
|
||||
data-testid="message-thread-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
@@ -196,7 +195,7 @@ export function MessageThreadPanel({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto pb-24"
|
||||
className="min-h-0 flex-1 overflow-y-auto pb-24 pt-11"
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
ref={threadBodyRef}
|
||||
|
||||
@@ -78,6 +78,7 @@ function SearchLoadingState() {
|
||||
type SearchDialogProps = {
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
initialQuery?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => 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) {
|
||||
|
||||
@@ -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<HTMLInputElement>(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<SearchResultSection[]>(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
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<SearchResult[]>(
|
||||
() => 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 (
|
||||
<div className="relative">
|
||||
<div className="flex h-7 items-center gap-2 rounded-lg border border-border/70 bg-background/75 px-2.5 shadow-sm backdrop-blur">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search Sprout"
|
||||
className="h-auto border-0 bg-transparent px-0 py-0 text-xs shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0"
|
||||
data-testid="global-search"
|
||||
onBlur={() => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showResults ? (
|
||||
<div
|
||||
className="absolute left-1/2 top-full mt-1 max-h-[60vh] w-[min(520px,64vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-border/80 bg-popover p-2 text-popover-foreground shadow-2xl"
|
||||
data-testid="topbar-search-results"
|
||||
>
|
||||
{results.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-muted-foreground">
|
||||
{isSearching ? "Searching..." : "No matches found."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sections.map((section) => {
|
||||
let sectionStartIndex = 0;
|
||||
for (const previousSection of sections) {
|
||||
if (previousSection === section) {
|
||||
break;
|
||||
}
|
||||
sectionStartIndex += previousSection.results.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={section.title}>
|
||||
<div className="mb-1.5 flex items-center justify-between px-1.5 text-[11px] font-medium text-muted-foreground">
|
||||
<span>{section.title}</span>
|
||||
<span>{section.count}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{section.results.map((result, index) => {
|
||||
const absoluteIndex = sectionStartIndex + index;
|
||||
|
||||
return (
|
||||
<SearchResultShell
|
||||
icon={resultIcon(result, channelLookup)}
|
||||
isSelected={absoluteIndex === selectedIndex}
|
||||
key={resultKey(result)}
|
||||
leading={
|
||||
result.kind === "user" ? (
|
||||
<UserResultAvatar user={result.user} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => openResult(result)}
|
||||
onMouseEnter={() => setSelectedIndex(absoluteIndex)}
|
||||
testId={resultTestId(result)}
|
||||
>
|
||||
{result.kind === "channel" ? (
|
||||
<ChannelResultBody channel={result.channel} />
|
||||
) : result.kind === "user" ? (
|
||||
<UserResultBody user={result.user} />
|
||||
) : (
|
||||
<MessageResultBody
|
||||
currentPubkey={currentPubkey}
|
||||
hit={result.hit}
|
||||
resultProfiles={resultProfiles}
|
||||
/>
|
||||
)}
|
||||
</SearchResultShell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user