feat: add global search palette

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-22 14:05:15 -04:00
co-authored by Cursor
parent 7b33c47eb4
commit 5108762d12
9 changed files with 691 additions and 286 deletions
+58
View File
@@ -0,0 +1,58 @@
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { SidebarTrigger } from "@/shared/ui/sidebar";
type AppHeaderControlsProps = {
canGoBack: boolean;
canGoForward: boolean;
onGoBack: () => void;
onGoForward: () => void;
onOpenSearch: () => void;
};
export function AppHeaderControls({
canGoBack,
canGoForward,
onGoBack,
onGoForward,
onOpenSearch,
}: 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>
);
}
+31 -33
View File
@@ -1,9 +1,9 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { AppHeaderControls } from "@/app/AppHeaderControls";
import { AppShellProvider } from "@/app/AppShellContext";
import {
AppShellOverlays,
@@ -57,13 +57,11 @@ import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { Button } from "@/shared/ui/button";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/shared/ui/sidebar";
hasPrimaryShortcutModifier,
isMacPlatform,
} from "@/shared/lib/platform";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
type AppView =
| "home"
@@ -369,6 +367,16 @@ export function AppShell() {
},
[openSearchHit],
);
const handleOpenSearchUser = React.useCallback(
(pubkey: string) => {
void openDmMutation
.mutateAsync({
pubkeys: [pubkey],
})
.then((directMessage) => goChannel(directMessage.id));
},
[goChannel, openDmMutation],
);
const handleDesktopNotificationAction = React.useEffectEvent(
async (target: DesktopNotificationTarget) => {
@@ -482,6 +490,14 @@ export function AppShell() {
}
const key = event.key.toLowerCase();
const isSpace =
isMacPlatform() && (event.key === " " || event.code === "Space");
if (isSpace && !event.shiftKey) {
event.preventDefault();
handleOpenSearch();
return;
}
if (key === "k" && !event.shiftKey) {
event.preventDefault();
handleOpenSearch();
@@ -598,31 +614,13 @@ export function AppShell() {
className="fixed inset-x-0 top-0 z-20 h-10 cursor-default select-none"
data-tauri-drag-region
/>
<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={goBack}
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={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
<AppHeaderControls
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={goBack}
onGoForward={goForward}
onOpenSearch={handleOpenSearch}
/>
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
@@ -702,7 +700,6 @@ export function AppShell() {
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
@@ -762,6 +759,7 @@ export function AppShell() {
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onOpenSearchUser={handleOpenSearchUser}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
+3
View File
@@ -31,6 +31,7 @@ type AppShellOverlaysProps = {
onChannelManagementOpenChange: (open: boolean) => void;
onDeleteActiveChannel: () => void;
onOpenSearchResult: (hit: SearchHit) => void;
onOpenSearchUser: (pubkey: string) => void;
onSearchOpenChange: (open: boolean) => void;
onSelectChannel: (channelId: string) => void;
};
@@ -47,6 +48,7 @@ export function AppShellOverlays({
onChannelManagementOpenChange,
onDeleteActiveChannel,
onOpenSearchResult,
onOpenSearchUser,
onSearchOpenChange,
onSelectChannel,
}: AppShellOverlaysProps) {
@@ -72,6 +74,7 @@ export function AppShellOverlays({
currentPubkey={currentPubkey}
onOpenChannel={onSelectChannel}
onOpenResult={onOpenSearchResult}
onOpenUser={onOpenSearchUser}
onOpenChange={onSearchOpenChange}
open={true}
/>
+144 -70
View File
@@ -3,10 +3,14 @@ import {
LoaderCircle,
MessagesSquare,
Search,
X,
type LucideIcon,
} from "lucide-react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
useUserSearchQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import type { Channel, SearchHit } from "@/shared/api/types";
import {
@@ -17,6 +21,8 @@ import {
resultTestId,
SearchResultShell,
type SearchResult,
UserResultAvatar,
UserResultBody,
} from "@/features/search/ui/SearchResultItem";
import {
Dialog,
@@ -76,6 +82,7 @@ type SearchDialogProps = {
onOpenChange: (open: boolean) => void;
onOpenChannel: (channelId: string) => void;
onOpenResult: (hit: SearchHit) => void;
onOpenUser: (pubkey: string) => void;
};
export function SearchDialog({
@@ -85,6 +92,7 @@ export function SearchDialog({
onOpenChange,
onOpenChannel,
onOpenResult,
onOpenUser,
}: SearchDialogProps) {
const [query, setQuery] = React.useState("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");
@@ -99,8 +107,19 @@ export function SearchDialog({
enabled: open,
limit: 12,
});
const usersQuery = useUserSearchQuery(debouncedQuery, {
enabled: open && debouncedQuery.length >= MIN_QUERY_LENGTH,
limit: 6,
});
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 (debouncedQuery.length < MIN_QUERY_LENGTH) {
return [];
@@ -130,18 +149,39 @@ export function SearchDialog({
})
.slice(0, 5);
}, [channels, debouncedQuery]);
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<SearchResult[]>(
() => [
...channelResults.map((channel) => ({
kind: "channel" as const,
channel,
})),
...messageResults.map((hit) => ({
kind: "message" as const,
hit,
})),
],
[channelResults, messageResults],
() => sections.flatMap((section) => section.results),
[sections],
);
const resultProfilesQuery = useUsersBatchQuery(
messageResults.map((hit) => hit.pubkey),
@@ -160,9 +200,14 @@ export function SearchDialog({
return;
}
if (result.kind === "user") {
onOpenUser(result.user.pubkey);
return;
}
onOpenResult(result.hit);
},
[onOpenChange, onOpenChannel, onOpenResult],
[onOpenChange, onOpenChannel, onOpenResult, onOpenUser],
);
React.useEffect(() => {
@@ -204,28 +249,23 @@ export function SearchDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="gap-0 overflow-hidden p-0"
className="max-w-3xl translate-y-[-8vh] gap-0 overflow-hidden p-0 [&>button]:hidden"
data-testid="search-dialog"
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
>
<DialogHeader className="border-b border-border/80 px-6 py-5">
<DialogTitle className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
<Search className="h-4 w-4" />
</span>
Search
</DialogTitle>
<DialogDescription>
Full-text search across accessible channels.
<DialogHeader className="border-b border-border/80 px-4 py-3">
<DialogTitle className="sr-only">Search Sprout</DialogTitle>
<DialogDescription className="sr-only">
Search people, channels, and message history across Sprout.
</DialogDescription>
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-sm">
<div className="flex items-center gap-2 rounded-xl border border-input bg-card px-3 py-2.5 shadow-sm">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
autoFocus
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0"
data-testid="search-input"
onChange={(event) => {
setQuery(event.target.value);
@@ -255,77 +295,111 @@ export function SearchDialog({
openResult(selectedResult);
}
}}
placeholder="Search messages, approvals, and forum posts"
placeholder="Search Sprout"
ref={inputRef}
value={query}
/>
<span className="hidden shrink-0 text-xs text-muted-foreground/50 sm:block">
&#x2318;K
</span>
<button
aria-label="Close search"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => onOpenChange(false)}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<div className="max-h-[64vh] overflow-y-auto">
{debouncedQuery.length < MIN_QUERY_LENGTH ? (
<SearchState
description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
description="Type at least two characters to search people, channels, streams, forums, DMs, approvals, and agent updates."
icon={MessagesSquare}
title="Search message history"
title="Search across Sprout"
/>
) : searchQuery.isLoading && results.length === 0 ? (
) : (searchQuery.isLoading || usersQuery.isLoading) &&
results.length === 0 ? (
<SearchLoadingState />
) : searchQuery.error instanceof Error && results.length === 0 ? (
) : (searchQuery.error instanceof Error ||
usersQuery.error instanceof Error) &&
results.length === 0 ? (
<SearchState
description={searchQuery.error.message}
description={
searchQuery.error instanceof Error
? searchQuery.error.message
: usersQuery.error instanceof Error
? usersQuery.error.message
: "Search failed."
}
icon={LoaderCircle}
title="Search unavailable"
/>
) : results.length === 0 ? (
<SearchState
description="Try a different keyword, channel name, or phrase from the message body."
description="Try a different keyword, username, channel name, or phrase from the message body."
icon={Search}
title="No matches found"
/>
) : (
<div className="p-3" data-testid="search-results">
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<span>
{channelResults.length +
(searchQuery.data?.found ?? messageResults.length)}{" "}
results
</span>
<span>Enter to open</span>
</div>
<div className="p-2.5" data-testid="search-results">
<div className="space-y-3">
{sections.map((section) => {
let sectionStartIndex = 0;
for (const previousSection of sections) {
if (previousSection === section) {
break;
}
sectionStartIndex += previousSection.results.length;
}
<div className="space-y-2">
{results.map((result, index) => (
<SearchResultShell
icon={resultIcon(result, channelLookup)}
isSelected={index === selectedIndex}
key={resultKey(result)}
onClick={() => openResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
testId={resultTestId(result)}
>
{result.kind === "channel" ? (
<ChannelResultBody channel={result.channel} />
) : (
<MessageResultBody
currentPubkey={currentPubkey}
hit={result.hit}
resultProfiles={resultProfiles}
/>
)}
</SearchResultShell>
))}
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>
)}
</div>
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
Search is relay-backed and scoped to channels you can access.
</div>
</DialogContent>
</Dialog>
);
@@ -1,24 +1,71 @@
import type * as React from "react";
import { ArrowRight, FileText, Hash, type LucideIcon } from "lucide-react";
import { FileText, Hash, UserRound, type LucideIcon } from "lucide-react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import {
resolveUserLabel,
resolveUserSecondaryLabel,
truncatePubkey,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { Channel, SearchHit } from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types";
export type SearchResult =
| { kind: "channel"; channel: Channel }
| { kind: "message"; hit: SearchHit };
| { kind: "message"; hit: SearchHit }
| { kind: "user"; user: UserSearchResult };
const INLINE_MARKDOWN_COMPONENTS: Components = {
a: ({ children }) => <span>{children}</span>,
blockquote: ({ children }) => <span>{children}</span>,
br: () => " ",
code: ({ children }) => <span>{children}</span>,
em: ({ children }) => <em>{children}</em>,
h1: ({ children }) => <span>{children}</span>,
h2: ({ children }) => <span>{children}</span>,
h3: ({ children }) => <span>{children}</span>,
h4: ({ children }) => <span>{children}</span>,
h5: ({ children }) => <span>{children}</span>,
h6: ({ children }) => <span>{children}</span>,
hr: () => null,
img: ({ alt, src }) => <span>{alt || src || "Image"}</span>,
li: ({ children }) => <span>{children} </span>,
ol: ({ children }) => <span>{children}</span>,
p: ({ children }) => <span>{children}</span>,
pre: ({ children }) => <span>{children}</span>,
strong: ({ children }) => <strong>{children}</strong>,
table: ({ children }) => <span>{children}</span>,
tbody: ({ children }) => <span>{children}</span>,
td: ({ children }) => <span>{children} </span>,
th: ({ children }) => <span>{children} </span>,
thead: ({ children }) => <span>{children}</span>,
tr: ({ children }) => <span>{children} </span>,
ul: ({ children }) => <span>{children}</span>,
};
function InlineMarkdownSnippet({ content }: { content: string }) {
return (
<ReactMarkdown
components={INLINE_MARKDOWN_COMPONENTS}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{content}
</ReactMarkdown>
);
}
export function resultKey(result: SearchResult) {
if (result.kind === "channel") {
return `channel-${result.channel.id}`;
}
if (result.kind === "user") {
return `user-${result.user.pubkey}`;
}
return `message-${result.hit.eventId}`;
}
@@ -27,6 +74,10 @@ export function resultTestId(result: SearchResult) {
return `search-result-channel-${result.channel.id}`;
}
if (result.kind === "user") {
return `search-result-user-${result.user.pubkey}`;
}
return `search-result-${result.hit.eventId}`;
}
@@ -34,6 +85,10 @@ export function resultIcon(
result: SearchResult,
channelLookup: ReadonlyMap<string, Channel>,
) {
if (result.kind === "user") {
return UserRound;
}
const channelType =
result.kind === "channel"
? result.channel.channelType
@@ -48,6 +103,7 @@ export function SearchResultShell({
children,
icon: Icon,
isSelected,
leading,
onClick,
onMouseEnter,
testId,
@@ -55,6 +111,7 @@ export function SearchResultShell({
children: React.ReactNode;
icon: LucideIcon;
isSelected: boolean;
leading?: React.ReactNode;
onClick: () => void;
onMouseEnter: () => void;
testId: string;
@@ -63,46 +120,97 @@ export function SearchResultShell({
<button
className={
isSelected
? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-sm outline-none transition-colors"
: "w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-sm outline-none transition-colors hover:border-primary/20 hover:bg-accent"
? "w-full rounded-lg bg-primary/10 px-2.5 py-2 text-left outline-none transition-colors"
: "w-full rounded-lg px-2.5 py-2 text-left outline-none transition-colors hover:bg-accent"
}
data-testid={testId}
onClick={onClick}
onMouseEnter={onMouseEnter}
type="button"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="flex items-center gap-2.5">
{leading ?? (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
<Icon className="h-3.5 w-3.5" />
</div>
)}
{children}
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</button>
);
}
function formatUserName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
truncatePubkey(user.pubkey)
);
}
function formatUserSecondary(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return truncatePubkey(user.pubkey);
}
export function ChannelResultBody({ channel }: { channel: Channel }) {
const description = channel.description.trim();
return (
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">{channel.name}</p>
<Badge variant="secondary">{channel.channelType}</Badge>
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
Channel
<div className="flex items-center gap-2">
<p
className="truncate text-sm font-medium tracking-tight"
title={description || channel.name}
>
{channel.name}
</p>
<p className="ml-auto shrink-0 whitespace-nowrap text-xs text-muted-foreground">
{channel.channelType}
</p>
</div>
{channel.description ? (
<p className="mt-2 text-sm leading-6 text-foreground">
{channel.description}
</p>
) : null}
</div>
);
}
export function UserResultBody({ user }: { user: UserSearchResult }) {
return (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p
className="truncate text-sm font-medium tracking-tight"
title={formatUserSecondary(user)}
>
{formatUserName(user)}
</p>
<p className="ml-auto shrink-0 whitespace-nowrap text-xs text-muted-foreground">
Person
</p>
</div>
</div>
);
}
export function UserResultAvatar({ user }: { user: UserSearchResult }) {
const label = formatUserName(user);
return (
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-6 w-6 rounded-md text-[9px] shadow-none"
iconClassName="h-3.5 w-3.5"
label={label}
/>
);
}
function describeSearchHit(hit: SearchHit) {
switch (hit.kind) {
case 1:
@@ -177,36 +285,30 @@ export function MessageResultBody({
pubkey: hit.pubkey,
profiles: resultProfiles,
});
const avatarUrl =
resultProfiles?.[hit.pubkey.toLowerCase()]?.avatarUrl ?? null;
const metadata = [
hit.channelName,
authorLabel,
formatRelativeTime(hit.createdAt),
]
.filter(Boolean)
.join(" · ");
const title = [metadata, authorSecondaryLabel, hit.content]
.filter(Boolean)
.join("\n");
return (
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold tracking-tight">
{hit.channelName}
<div className="flex items-center gap-2">
<p
className="truncate text-sm font-medium tracking-tight"
title={title}
>
<InlineMarkdownSnippet content={truncateContent(hit.content)} />
</p>
<Badge variant="secondary">{describeSearchHit(hit)}</Badge>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<UserAvatar
avatarUrl={avatarUrl}
displayName={authorLabel}
size="xs"
/>
{authorLabel}
</span>
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
{formatRelativeTime(hit.createdAt)}
<p className="ml-auto shrink-0 whitespace-nowrap text-xs text-muted-foreground">
{describeSearchHit(hit)}
</p>
</div>
{authorSecondaryLabel ? (
<p className="mt-1 text-xs text-muted-foreground">
{authorSecondaryLabel}
</p>
) : null}
<p className="mt-2 text-sm leading-6 text-foreground">
{truncateContent(hit.content)}
</p>
</div>
);
}
+11 -37
View File
@@ -37,7 +37,6 @@ import type {
UserStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -124,7 +123,6 @@ type AppSidebarProps = {
onOpenAddWorkspace: () => void;
onOpenBrowseChannels: () => void;
onOpenBrowseForums: () => void;
onOpenSearch: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (
channelId: string,
@@ -341,7 +339,6 @@ export function AppSidebar({
onOpenAddWorkspace,
onOpenBrowseChannels,
onOpenBrowseForums,
onOpenSearch,
onHideDm,
onMarkChannelUnread,
onOpenDm,
@@ -462,33 +459,9 @@ export function AppSidebar({
variant="sidebar"
>
<SidebarHeader
className="cursor-default select-none gap-3 pt-10"
className="cursor-default select-none gap-3 pt-12"
data-tauri-drag-region
>
<div className="px-0.5">
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
workspaces={workspaces}
/>
</div>
<Button
className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-sm hover:bg-sidebar-accent hover:text-sidebar-foreground"
data-testid="open-search"
onClick={onOpenSearch}
size="sm"
type="button"
variant="ghost"
>
<span className="flex items-center gap-2">
<Search className="h-4 w-4" />
Search messages
</span>
<span className="text-xs text-sidebar-foreground/50">&#x2318;K</span>
</Button>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
@@ -709,21 +682,22 @@ export function AppSidebar({
/>
</span>
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
{selfUserStatus?.text || selfUserStatus?.emoji ? (
<p className="truncate text-xs text-sidebar-foreground/50">
{selfUserStatus.emoji ? (
<span className="mr-1">{selfUserStatus.emoji}</span>
) : null}
{selfUserStatus.text}
</p>
) : null}
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
placement="footer"
workspaces={workspaces}
/>
</div>
</div>
</SidebarMenuButton>
@@ -6,8 +6,10 @@ import {
WifiOff,
} from "lucide-react";
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "@/features/workspaces/types";
import { getUserProfile } from "@/shared/api/tauri";
import {
DropdownMenu,
DropdownMenuContent,
@@ -26,6 +28,7 @@ import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
@@ -38,8 +41,117 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
disconnected: "Disconnected from relay",
};
function relayIconUrl(relayUrl: string | undefined) {
if (!relayUrl) {
return null;
}
try {
const url = new URL(relayUrl);
url.protocol = url.protocol === "ws:" ? "http:" : "https:";
url.pathname = "/favicon.ico";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return null;
}
}
function relayInfoUrl(relayUrl: string | undefined) {
if (!relayUrl) {
return null;
}
try {
const url = new URL(relayUrl);
url.protocol = url.protocol === "ws:" ? "http:" : "https:";
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return null;
}
}
async function fetchRelayProfileIcon(relayUrl: string) {
const infoUrl = relayInfoUrl(relayUrl);
if (!infoUrl) {
return null;
}
const response = await fetch(infoUrl, {
headers: {
Accept: "application/nostr+json",
},
});
if (!response.ok) {
return null;
}
const info = (await response.json()) as { self?: unknown };
if (typeof info.self !== "string" || info.self.length === 0) {
return null;
}
const profile = await getUserProfile(info.self);
return profile.avatarUrl ? rewriteRelayUrl(profile.avatarUrl) : null;
}
function RelayIcon({
associatedIconUrl,
className,
workspace,
}: {
associatedIconUrl?: string | null;
className: string;
workspace: Workspace | null | undefined;
}) {
const iconUrl = relayIconUrl(workspace?.relayUrl);
const fallbackIconUrl = "/sprout.svg";
const [src, setSrc] = React.useState(
associatedIconUrl ?? iconUrl ?? fallbackIconUrl,
);
React.useEffect(() => {
setSrc(associatedIconUrl ?? iconUrl ?? fallbackIconUrl);
}, [associatedIconUrl, iconUrl]);
if (!src) {
return null;
}
if (src === fallbackIconUrl) {
return (
<span
aria-hidden="true"
className={`${className} flex items-center justify-center leading-none`}
>
🌱
</span>
);
}
return (
<img
alt=""
className={className}
onError={() => {
setSrc((current) =>
current === fallbackIconUrl ? null : fallbackIconUrl,
);
}}
referrerPolicy="no-referrer"
src={src}
/>
);
}
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
placement?: "header" | "footer";
workspaces: Workspace[];
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
@@ -52,6 +164,7 @@ type WorkspaceSwitcherProps = {
export function WorkspaceSwitcher({
activeWorkspace,
placement = "header",
workspaces,
onSwitchWorkspace,
onAddWorkspace,
@@ -64,103 +177,164 @@ export function WorkspaceSwitcher({
const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const isFooterPlacement = placement === "footer";
const activeRelayIconQuery = useQuery({
enabled: Boolean(activeWorkspace?.relayUrl),
queryKey: ["relay-profile-icon", activeWorkspace?.relayUrl ?? ""],
queryFn: () => fetchRelayProfileIcon(activeWorkspace?.relayUrl ?? ""),
staleTime: 5 * 60 * 1_000,
gcTime: 30 * 60 * 1_000,
retry: false,
});
const activeRelayIconUrl = activeRelayIconQuery.data ?? null;
const triggerLabel = degraded
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
: (activeWorkspace?.name ?? "No workspace");
const triggerContent = (
<>
{degraded ? (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-hidden="false"
className={
isFooterPlacement
? "flex h-3.5 w-3.5 shrink-0 animate-pulse items-center justify-center text-destructive"
: "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
}
data-testid="relay-connection-warning"
role="img"
>
<WifiOff className={isFooterPlacement ? "h-3 w-3" : "h-4 w-4"} />
</span>
</TooltipTrigger>
<TooltipContent side={isFooterPlacement ? "top" : "bottom"}>
{connectionLabel}
</TooltipContent>
</Tooltip>
) : isFooterPlacement ? null : (
<RelayIcon
associatedIconUrl={activeRelayIconUrl}
className="h-5 w-5 shrink-0 rounded-md object-cover"
workspace={activeWorkspace}
/>
)}
{!degraded && isFooterPlacement ? (
<RelayIcon
associatedIconUrl={activeRelayIconUrl}
className="h-3 w-3 shrink-0 rounded-sm object-cover"
workspace={activeWorkspace}
/>
) : null}
<span
className={
degraded
? "min-w-0 flex-1 truncate text-destructive animate-pulse"
: "min-w-0 flex-1 truncate"
}
>
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown
className={
isFooterPlacement
? "h-3 w-3 shrink-0 text-sidebar-foreground/40"
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
}
/>
</>
);
const dropdown = (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
{isFooterPlacement ? (
<button
aria-label={triggerLabel}
className="mt-0.5 flex min-w-0 items-center gap-1 rounded-md text-xs leading-4 text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring data-[state=open]:text-sidebar-foreground"
data-testid="workspace-switcher"
onClick={(event) => {
event.stopPropagation();
}}
type="button"
>
{triggerContent}
</button>
) : (
<SidebarMenuButton
aria-label={degraded ? triggerLabel : undefined}
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
data-testid="workspace-switcher"
type="button"
>
{triggerContent}
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={
isFooterPlacement
? "min-w-[220px]"
: "w-[--radix-dropdown-menu-trigger-width] min-w-[220px]"
}
onCloseAutoFocus={(e) => e.preventDefault()}
side={isFooterPlacement ? "top" : "bottom"}
sideOffset={4}
>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
className="group flex items-center gap-2 pr-1"
onSelect={() => {
onSwitchWorkspace(workspace.id);
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<RelayIcon
associatedIconUrl={
activeWorkspace?.id === workspace.id ? activeRelayIconUrl : null
}
className="h-4 w-4 shrink-0 rounded-sm object-cover"
workspace={workspace}
/>
<span className="min-w-0 flex-1 truncate">{workspace.name}</span>
<button
aria-label={`Edit ${workspace.name}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
setEditingWorkspace(workspace);
}}
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onAddWorkspace}>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
: undefined
}
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
data-testid="workspace-switcher"
type="button"
>
{degraded ? (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-hidden="false"
className="flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
data-testid="relay-connection-warning"
role="img"
>
<WifiOff className="h-4 w-4" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{connectionLabel}
</TooltipContent>
</Tooltip>
) : (
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none">
🌱
</span>
)}
<span
className={
degraded
? "min-w-0 flex-1 truncate text-sm font-medium text-destructive animate-pulse"
: "min-w-0 flex-1 truncate text-sm font-medium"
}
>
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[--radix-dropdown-menu-trigger-width] min-w-[220px]"
onCloseAutoFocus={(e) => e.preventDefault()}
side="bottom"
sideOffset={4}
>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
className="group flex items-center gap-2 pr-1"
onSelect={() => {
onSwitchWorkspace(workspace.id);
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">
{workspace.name}
</span>
<button
aria-label={`Edit ${workspace.name}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
setEditingWorkspace(workspace);
}}
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onAddWorkspace}>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
{isFooterPlacement ? (
dropdown
) : (
<SidebarMenu>
<SidebarMenuItem>{dropdown}</SidebarMenuItem>
</SidebarMenu>
)}
<EditWorkspaceDialog
canRemove={workspaces.length > 1}
+3 -3
View File
@@ -19,9 +19,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
// Navigation
{
id: "quick-search",
label: "Quick search",
description: "Open the search dialog",
keys: "⌘K",
label: "Global search",
description: "Open the global search palette",
keys: "⌘K / ⌘Space",
keysWindows: "Ctrl+K",
category: "Navigation",
},
+24 -2
View File
@@ -44,7 +44,7 @@ async function openSearchDialogWithShortcut(
page: import("@playwright/test").Page,
) {
const searchDialog = page.getByTestId("search-dialog");
const openSearchButton = page.getByTestId("open-search");
const openSearchButton = page.getByTestId("global-search");
await expect(openSearchButton).toBeVisible();
await expect
@@ -75,7 +75,7 @@ async function openSearchDialogWithButton(
page: import("@playwright/test").Page,
) {
const searchDialog = page.getByTestId("search-dialog");
const openSearchButton = page.getByTestId("open-search");
const openSearchButton = page.getByTestId("global-search");
await expect(openSearchButton).toBeVisible();
await openSearchButton.click();
@@ -276,6 +276,28 @@ test("opens channel matches from search", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
});
test("opens people matches from global search", async ({ page }) => {
await page.goto("/");
await openSearchDialogWithButton(page);
await page.getByTestId("search-input").fill("alice");
const results = page.getByTestId("search-results");
await expect(results).toContainText("People");
await expect(results).toContainText("alice");
await results
.getByTestId(
"search-result-user-953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
)
.click();
await expect(page).toHaveURL(
/#\/channels\/f48efb06-0c93-5025-aac9-2e646bb6bfa8$/,
);
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
});
test("search results use your resolved profile label instead of You", async ({
page,
}) => {