mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add global search palette
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7b33c47eb4
commit
5108762d12
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
⌘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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">⌘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}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user