diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 5311271dc..bc8a98b2f 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -70,6 +70,7 @@ export function AppShellOverlays({ void; + onOpenChannel: (channelId: string) => void; onOpenResult: (hit: SearchHit) => void; }; @@ -137,6 +83,7 @@ export function SearchDialog({ currentPubkey, open, onOpenChange, + onOpenChannel, onOpenResult, }: SearchDialogProps) { const [query, setQuery] = React.useState(""); @@ -153,21 +100,69 @@ export function SearchDialog({ limit: 12, }); - const results = searchQuery.data?.hits ?? []; + const messageResults = searchQuery.data?.hits ?? []; + const channelResults = React.useMemo(() => { + if (debouncedQuery.length < MIN_QUERY_LENGTH) { + return []; + } + + const normalizedQuery = debouncedQuery.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, 5); + }, [channels, debouncedQuery]); + const results = React.useMemo( + () => [ + ...channelResults.map((channel) => ({ + kind: "channel" as const, + channel, + })), + ...messageResults.map((hit) => ({ + kind: "message" as const, + hit, + })), + ], + [channelResults, messageResults], + ); const resultProfilesQuery = useUsersBatchQuery( - results.map((hit) => hit.pubkey), + messageResults.map((hit) => hit.pubkey), { - enabled: open && results.length > 0, + enabled: open && messageResults.length > 0, }, ); const resultProfiles = resultProfilesQuery.data?.profiles; const openResult = React.useCallback( - (hit: SearchHit) => { + (result: SearchResult) => { onOpenChange(false); - onOpenResult(hit); + + if (result.kind === "channel") { + onOpenChannel(result.channel.id); + return; + } + + onOpenResult(result.hit); }, - [onOpenChange, onOpenResult], + [onOpenChange, onOpenChannel, onOpenResult], ); React.useEffect(() => { @@ -204,7 +199,7 @@ export function SearchDialog({ }); }, [results]); - const selectedHit = results[selectedIndex]; + const selectedResult = results[selectedIndex]; return ( @@ -254,10 +249,10 @@ export function SearchDialog({ if ( event.key === "Enter" && !event.nativeEvent.isComposing && - selectedHit + selectedResult ) { event.preventDefault(); - openResult(selectedHit); + openResult(selectedResult); } }} placeholder="Search messages, approvals, and forum posts" @@ -277,9 +272,9 @@ export function SearchDialog({ icon={MessagesSquare} title="Search message history" /> - ) : searchQuery.isLoading ? ( + ) : searchQuery.isLoading && results.length === 0 ? ( - ) : searchQuery.error instanceof Error ? ( + ) : searchQuery.error instanceof Error && results.length === 0 ? (
- {searchQuery.data?.found ?? results.length} results + + {channelResults.length + + (searchQuery.data?.found ?? messageResults.length)}{" "} + results + Enter to open
- {results.map((hit, index) => { - const channel = hit.channelId - ? channelLookup.get(hit.channelId) - : undefined; - const authorLabel = resolveUserLabel({ - pubkey: hit.pubkey, - currentPubkey, - profiles: resultProfiles, - preferResolvedSelfLabel: true, - }); - const authorSecondaryLabel = resolveUserSecondaryLabel({ - pubkey: hit.pubkey, - profiles: resultProfiles, - }); - - return ( - - ); - })} + {results.map((result, index) => ( + openResult(result)} + onMouseEnter={() => setSelectedIndex(index)} + testId={resultTestId(result)} + > + {result.kind === "channel" ? ( + + ) : ( + + )} + + ))}
)} diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx new file mode 100644 index 000000000..d520a25c3 --- /dev/null +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -0,0 +1,212 @@ +import type * as React from "react"; +import { ArrowRight, FileText, Hash, type LucideIcon } from "lucide-react"; + +import { + resolveUserLabel, + resolveUserSecondaryLabel, + 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"; + +export type SearchResult = + | { kind: "channel"; channel: Channel } + | { kind: "message"; hit: SearchHit }; + +export function resultKey(result: SearchResult) { + if (result.kind === "channel") { + return `channel-${result.channel.id}`; + } + + return `message-${result.hit.eventId}`; +} + +export function resultTestId(result: SearchResult) { + if (result.kind === "channel") { + return `search-result-channel-${result.channel.id}`; + } + + return `search-result-${result.hit.eventId}`; +} + +export function resultIcon( + result: SearchResult, + channelLookup: ReadonlyMap, +) { + const channelType = + result.kind === "channel" + ? result.channel.channelType + : result.hit.channelId + ? channelLookup.get(result.hit.channelId)?.channelType + : undefined; + + return channelType === "forum" ? FileText : Hash; +} + +export function SearchResultShell({ + children, + icon: Icon, + isSelected, + onClick, + onMouseEnter, + testId, +}: { + children: React.ReactNode; + icon: LucideIcon; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + testId: string; +}) { + return ( + + ); +} + +export function ChannelResultBody({ channel }: { channel: Channel }) { + return ( +
+
+

{channel.name}

+ {channel.channelType} +

+ Channel +

+
+ {channel.description ? ( +

+ {channel.description} +

+ ) : null} +
+ ); +} + +function describeSearchHit(hit: SearchHit) { + switch (hit.kind) { + case 1: + return "Note"; + case 45001: + return "Forum post"; + case 45003: + return "Forum reply"; + case 43001: + return "Agent job"; + case 43003: + return "Agent update"; + case 46010: + return "Approval request"; + default: + return "Message"; + } +} + +function truncateContent(content: string) { + const trimmed = content.trim(); + if (trimmed.length === 0) { + return "No message body."; + } + + if (trimmed.length <= 180) { + return trimmed; + } + + return `${trimmed.slice(0, 177)}...`; +} + +function formatRelativeTime(unixSeconds: number) { + const diff = Math.floor(Date.now() / 1_000) - unixSeconds; + + if (diff < 60) { + return "just now"; + } + + if (diff < 60 * 60) { + return `${Math.floor(diff / 60)}m ago`; + } + + if (diff < 60 * 60 * 24) { + return `${Math.floor(diff / (60 * 60))}h ago`; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_000)); +} + +export function MessageResultBody({ + currentPubkey, + hit, + resultProfiles, +}: { + currentPubkey?: string; + hit: SearchHit; + resultProfiles?: UserProfileLookup; +}) { + const authorLabel = resolveUserLabel({ + pubkey: hit.pubkey, + currentPubkey, + profiles: resultProfiles, + preferResolvedSelfLabel: true, + }); + const authorSecondaryLabel = resolveUserSecondaryLabel({ + pubkey: hit.pubkey, + profiles: resultProfiles, + }); + const avatarUrl = + resultProfiles?.[hit.pubkey.toLowerCase()]?.avatarUrl ?? null; + + return ( +
+
+

+ {hit.channelName} +

+ {describeSearchHit(hit)} + + + {authorLabel} + +

+ {formatRelativeTime(hit.createdAt)} +

+
+ {authorSecondaryLabel ? ( +

+ {authorSecondaryLabel} +

+ ) : null} +

+ {truncateContent(hit.content)} +

+
+ ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 700076a57..883ab66ab 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -883,7 +883,7 @@ const mockChannels: MockChannel[] = [ name: "design", channel_type: "stream", visibility: "open", - description: "Design system and UX discussions", + description: "Design system and UX discussions with engineering partners", topic: null, purpose: null, last_message_at: isoMinutesAgo(120), diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 8a174022c..966af274a 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -246,6 +246,36 @@ 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 page.getByTestId("search-input").fill("engineering"); + const results = page.getByTestId("search-results"); + + await expect(results).toContainText("engineering"); + await expect(results).toContainText("Engineering discussions"); + await expect(results).toContainText( + "Design system and UX discussions with engineering partners", + ); + await expect( + results.locator('[data-testid^="search-result-channel-"]').first(), + ).toHaveAttribute( + "data-testid", + "search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9", + ); + + await results + .getByTestId("search-result-channel-1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9") + .click(); + + await expect(page).toHaveURL( + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9$/, + ); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); +}); + test("search results use your resolved profile label instead of You", async ({ page, }) => {