diff --git a/desktop/src/features/search/ui/SearchScopeControls.tsx b/desktop/src/features/search/ui/SearchScopeControls.tsx index a3c9fc98bf..915a60b29b 100644 --- a/desktop/src/features/search/ui/SearchScopeControls.tsx +++ b/desktop/src/features/search/ui/SearchScopeControls.tsx @@ -92,56 +92,3 @@ export function SearchDialogInputRow({ ); } - -type CurrentChannelSearchActionProps = { - channelLabel: string; - channelType: Channel["channelType"]; - isSelected: boolean; - menuIndex: number; - onActivate: () => void; - onMouseEnter: () => void; -}; - -export function CurrentChannelSearchAction({ - channelLabel, - channelType, - isSelected, - menuIndex, - onActivate, - onMouseEnter, -}: CurrentChannelSearchActionProps) { - const isDirectMessage = channelType === "dm"; - - return ( -
- -
- ); -} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 116e629dba..94d4388742 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -11,7 +11,6 @@ import { type SearchResult, } from "@/features/search/ui/SearchResultItem"; import { - CurrentChannelSearchAction, getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; @@ -19,7 +18,6 @@ import { SearchResultTrailing } from "@/features/search/ui/SearchResultTrailing" import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import { getChannelActivityTime, - getSearchActionMenuIndex, getSuggestedSearchResults, } from "@/features/search/ui/topbarSearchSuggestions"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; @@ -60,6 +58,7 @@ const EMPTY_UNREAD_CHANNEL_COUNTS: ReadonlyMap = new Map(), const SEARCH_SECTION_TITLE_CLASS = "px-2.5 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70"; const SEARCH_RESULT_SECTION_ORDER = [ + "current-channel-messages", "channels", "direct-messages", "people", @@ -217,7 +216,14 @@ function getSearchHitContextLabel( }; } -function getResultSectionKey(result: SearchResult): SearchResultSectionKey { +function getResultSectionKey( + result: SearchResult, + currentChannelId?: string | null, +): SearchResultSectionKey { + if (result.kind === "message" && result.hit.channelId === currentChannelId) { + return "current-channel-messages"; + } + if (result.kind === "channel") { return result.channel.channelType === "dm" ? "direct-messages" : "channels"; } @@ -235,6 +241,8 @@ function getResultSectionKey(result: SearchResult): SearchResultSectionKey { function getSectionTitle(sectionKey: SearchResultSectionKey) { switch (sectionKey) { + case "current-channel-messages": + return "In this conversation"; case "channels": return "Channels"; case "direct-messages": @@ -274,11 +282,14 @@ function SearchHitContextLine({ label }: { label: SearchHitContextLabel }) { ); } -function groupSearchResults(results: SearchResult[]): SearchResultSection[] { +function groupSearchResults( + results: SearchResult[], + currentChannelId?: string | null, +): SearchResultSection[] { const resultsBySection = new Map(); for (const result of results) { - const sectionKey = getResultSectionKey(result); + const sectionKey = getResultSectionKey(result, currentChannelId); const sectionResults = resultsBySection.get(sectionKey) ?? []; sectionResults.push(result); resultsBySection.set(sectionKey, sectionResults); @@ -390,6 +401,7 @@ export function TopbarSearch({ debouncedQuery, fuzzyUserCandidatesQuery, isWaitingOnFromResolution, + prioritizedChannelSearchQuery, query, resultProfiles, results, @@ -401,13 +413,11 @@ export function TopbarSearch({ channels, enabled: isOpen, limit: SEARCH_RESULT_LIMIT, + prioritizedChannelId: scopeChannelId ? null : currentChannelId, scopeChannelId, }); const trimmedQuery = query.trim(); const isIconVariant = variant === "icon"; - const currentChannel = currentChannelId - ? (channelLookup.get(currentChannelId) ?? null) - : null; const scopeChannel = scopeChannelId ? (channelLookup.get(scopeChannelId) ?? null) : null; @@ -416,7 +426,6 @@ export function TopbarSearch({ : null; const currentPubkeyNormalized = currentPubkey && normalizePubkey(currentPubkey); - const hasScopeAction = Boolean(currentChannel && !scopeChannel); const { suggestedResults, unreadResults } = React.useMemo( () => getSuggestedSearchResults( @@ -477,8 +486,12 @@ export function TopbarSearch({ [currentPubkeyNormalized, results], ); const searchResultSections = React.useMemo( - () => groupSearchResults(searchableResults), - [searchableResults], + () => + groupSearchResults( + searchableResults, + scopeChannel ? null : currentChannelId, + ), + [currentChannelId, scopeChannel, searchableResults], ); const groupedSearchResults = React.useMemo( () => searchResultSections.flatMap((section) => section.results), @@ -489,14 +502,10 @@ export function TopbarSearch({ ? [] : suggestionResults : groupedSearchResults; - const actionMenuIndex = getSearchActionMenuIndex({ - hasScopeAction, - isShowingSuggestions, - unreadResultCount: unreadResults.length, - }); const isSearchLoading = isWaitingOnFromResolution || searchQuery.isLoading || + prioritizedChannelSearchQuery.isLoading || fuzzyUserCandidatesQuery.isLoading || userSearchQuery.isLoading; @@ -599,13 +608,6 @@ export function TopbarSearch({ window.requestAnimationFrame(() => dialogInputRef.current?.focus()); }, []); - const activateCurrentChannelScope = React.useCallback(() => { - if (!currentChannel) return; - setScopeChannelId(currentChannel.id); - setSelectedMenuIndex(0); - focusDialogInput(); - }, [currentChannel, focusDialogInput]); - const removeChannelScope = React.useCallback(() => { setScopeChannelId(null); setSelectedMenuIndex(0); @@ -627,9 +629,7 @@ export function TopbarSearch({ }, [isOpen]); const handleDialogInputKeyDown = useSearchMenuKeyboardNavigation({ - actionMenuIndex, activeResults, - onActivateAction: activateCurrentChannelScope, onOpenResult: openResult, onRemoveScope: removeChannelScope, query, @@ -638,9 +638,7 @@ export function TopbarSearch({ setSelectedMenuIndex, }); - const renderSearchResultRow = (result: SearchResult, index: number) => { - const menuIndex = - actionMenuIndex !== null && index >= actionMenuIndex ? index + 1 : index; + const renderSearchResultRow = (result: SearchResult, menuIndex: number) => { const channelDisplayName = result.kind === "channel" ? getChannelDisplayName(result.channel, channelLabels) @@ -795,31 +793,10 @@ export function TopbarSearch({ )); }; - const currentChannelSearchAction = - currentChannel && !scopeChannel ? ( - setSelectedMenuIndex(actionMenuIndex ?? 0)} - /> - ) : null; const searchResultContent = isShowingSuggestions ? ( scopeChannel ? null : suggestionResults.length === 0 ? (
- {currentChannelSearchAction} -
+

No recent activity yet.

@@ -843,7 +820,6 @@ export function TopbarSearch({ )}
) : null} - {currentChannelSearchAction} {suggestedResults.length > 0 ? (
@@ -870,30 +846,17 @@ export function TopbarSearch({ ) ) : isSearchLoading && searchableResults.length === 0 ? (
- {currentChannelSearchAction}
) : searchQuery.error instanceof Error && searchableResults.length === 0 ? (
- {currentChannelSearchAction} -

+

{searchQuery.error.message}

) : searchableResults.length === 0 ? (
- {currentChannelSearchAction} -

+

No {scopeChannel ? "messages" : "matches"} for{" "} {trimmedQuery} {scopeLabel ? ( @@ -911,7 +874,6 @@ export function TopbarSearch({ data-testid="search-results-list" role="listbox" > - {currentChannelSearchAction}

{renderSearchResultSections(searchResultSections)}
diff --git a/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs b/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs index b22c32a140..06470942b7 100644 --- a/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs +++ b/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - getSearchActionMenuIndex, - getSuggestedChannels, -} from "./topbarSearchSuggestions.ts"; +import { getSuggestedChannels } from "./topbarSearchSuggestions.ts"; function channel(id, options = {}) { return { @@ -72,30 +69,3 @@ test("archived and unjoined non-DM conversations are omitted", () => { ["dm"], ); }); - -test("the scope action follows unread rows while suggestions are visible", () => { - assert.equal( - getSearchActionMenuIndex({ - hasScopeAction: true, - isShowingSuggestions: true, - unreadResultCount: 2, - }), - 2, - ); - assert.equal( - getSearchActionMenuIndex({ - hasScopeAction: true, - isShowingSuggestions: true, - unreadResultCount: 0, - }), - 0, - ); - assert.equal( - getSearchActionMenuIndex({ - hasScopeAction: false, - isShowingSuggestions: true, - unreadResultCount: 2, - }), - null, - ); -}); diff --git a/desktop/src/features/search/ui/topbarSearchSuggestions.ts b/desktop/src/features/search/ui/topbarSearchSuggestions.ts index 61cb1a5b5f..329df44237 100644 --- a/desktop/src/features/search/ui/topbarSearchSuggestions.ts +++ b/desktop/src/features/search/ui/topbarSearchSuggestions.ts @@ -64,16 +64,3 @@ export function getSuggestedSearchResults( unreadResults: unreadChannels.map(toResult), }; } - -export function getSearchActionMenuIndex({ - hasScopeAction, - isShowingSuggestions, - unreadResultCount, -}: { - hasScopeAction: boolean; - isShowingSuggestions: boolean; - unreadResultCount: number; -}) { - if (!hasScopeAction) return null; - return isShowingSuggestions && unreadResultCount > 0 ? unreadResultCount : 0; -} diff --git a/desktop/src/features/search/ui/useSearchMenuKeyboardNavigation.ts b/desktop/src/features/search/ui/useSearchMenuKeyboardNavigation.ts index b29258fece..d729b1b16d 100644 --- a/desktop/src/features/search/ui/useSearchMenuKeyboardNavigation.ts +++ b/desktop/src/features/search/ui/useSearchMenuKeyboardNavigation.ts @@ -3,9 +3,7 @@ import * as React from "react"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; export function useSearchMenuKeyboardNavigation({ - actionMenuIndex, activeResults, - onActivateAction, onOpenResult, onRemoveScope, query, @@ -13,9 +11,7 @@ export function useSearchMenuKeyboardNavigation({ selectedMenuIndex, setSelectedMenuIndex, }: { - actionMenuIndex: number | null; activeResults: SearchResult[]; - onActivateAction: () => void; onOpenResult: (result: SearchResult) => void; onRemoveScope: () => void; query: string; @@ -23,8 +19,7 @@ export function useSearchMenuKeyboardNavigation({ selectedMenuIndex: number; setSelectedMenuIndex: React.Dispatch>; }) { - const selectableCount = - activeResults.length + (actionMenuIndex === null ? 0 : 1); + const selectableCount = activeResults.length; React.useEffect(() => { setSelectedMenuIndex((current) => { @@ -64,22 +59,12 @@ export function useSearchMenuKeyboardNavigation({ if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); - if (actionMenuIndex === selectedMenuIndex) { - onActivateAction(); - return; - } - const resultIndex = - actionMenuIndex !== null && selectedMenuIndex > actionMenuIndex - ? selectedMenuIndex - 1 - : selectedMenuIndex; - const result = activeResults[resultIndex]; + const result = activeResults[selectedMenuIndex]; if (result) onOpenResult(result); } }, [ - actionMenuIndex, activeResults, - onActivateAction, onOpenResult, onRemoveScope, query.length, diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index a8da5a57f9..b1e1cb1a8f 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -104,12 +104,14 @@ export function useSearchResults({ channels: memberChannels, enabled, limit = 12, + prioritizedChannelId, scopeChannelId, }: { channelLabels?: Record; channels: Channel[]; enabled: boolean; limit?: number; + prioritizedChannelId?: string | null; scopeChannelId?: string | null; }) { const [query, setQuery] = React.useState(""); @@ -255,13 +257,40 @@ export function useSearchResults({ unresolvedOperator: hasUnresolvedOperator, minimumQueryLength, }); + const prioritizedChannelSearchQuery = useSearchMessagesQuery(ftsQuery, { + enabled: + enabled && + !scopeChannelId && + Boolean(prioritizedChannelId) && + channelResolution.status === "none" && + !hasUnresolvedOperator && + !waitingOnFromResolution && + ftsQuery.length >= minimumQueryLength, + limit, + channelId: prioritizedChannelId ?? undefined, + authors: + authorResolution.status === "resolved" + ? [authorResolution.value] + : undefined, + since: parsedQuery.since, + until: parsedQuery.until, + unresolvedOperator: hasUnresolvedOperator, + minimumQueryLength, + }); const messageResults = React.useMemo(() => { if (hasUnresolvedOperator) { return []; } - return dedupeSearchHits(searchQuery.data?.hits ?? []); - }, [hasUnresolvedOperator, searchQuery.data?.hits]); + return dedupeSearchHits([ + ...(prioritizedChannelSearchQuery.data?.hits ?? []), + ...(searchQuery.data?.hits ?? []), + ]); + }, [ + hasUnresolvedOperator, + prioritizedChannelSearchQuery.data?.hits, + searchQuery.data?.hits, + ]); const channelResults = React.useMemo(() => { if (scopeChannelId || ftsQuery.length < MIN_SEARCH_QUERY_LENGTH) { return []; @@ -498,6 +527,7 @@ export function useSearchResults({ isWaitingOnFromResolution: waitingOnFromResolution, messageResults, query, + prioritizedChannelSearchQuery, resultProfiles: resultProfilesQuery.data?.profiles, results, searchQuery, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 035ffc8be5..a9d4b6010c 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -441,9 +441,9 @@ test("Cmd+K bubbles unread conversations to the top and Enter opens the first", page.getByTestId(`search-unread-count-${directMessageId}`), ).toHaveText("2"); await expect(firstUnread.getByText("Enter", { exact: true })).toBeVisible(); - await expect( - page.getByTestId("search-current-channel-control"), - ).toHaveAttribute("data-search-result-index", "1"); + await expect(page.getByTestId("search-current-channel-control")).toHaveCount( + 0, + ); await page.keyboard.press("Enter"); await expect(page).toHaveURL( @@ -452,146 +452,29 @@ test("Cmd+K bubbles unread conversations to the top and Enter opens the first", await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); }); -test("global search offers an optional current-channel scope", async ({ +test("global search prioritizes messages from the current channel", async ({ page, }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - await expect(page).toHaveURL( - /#\/channels\/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50$/, - ); await focusSidebarSearchWithShortcut(page); - const scopeControl = page.getByTestId("search-current-channel-control"); const input = page.getByTestId("search-dialog-input"); - await expect - .poll(() => - scopeControl - .getByTestId("search-current-scope-label") - .evaluate((element) => element.textContent), - ) - .toBe("Search in #general"); - await expect(scopeControl).toContainText("Search in"); - await expect(scopeControl).toContainText("#general"); - await expect(scopeControl).toContainText("Search messages in this channel"); - await expect(page.getByTestId("search-dialog-input-row")).toHaveCSS( - "border-bottom-width", - "1px", + await expect(page.getByTestId("search-current-channel-control")).toHaveCount( + 0, ); - await expect(scopeControl.locator("..")).toHaveCSS( - "border-bottom-width", - "0px", - ); - await expect(scopeControl.locator("..")).toHaveCSS("padding-top", "14px"); - await expect(scopeControl.locator("..")).toHaveCSS("padding-bottom", "14px"); - const [controlBox, dialogBox] = await Promise.all([ - scopeControl.boundingBox(), - page.getByTestId("search-results").boundingBox(), - ]); - expect(controlBox).not.toBeNull(); - expect(dialogBox).not.toBeNull(); - expect(controlBox?.width ?? 0).toBeGreaterThan((dialogBox?.width ?? 0) * 0.9); - expect(controlBox?.width ?? 0).toBeLessThan(dialogBox?.width ?? 0); - await expect(scopeControl).toHaveAttribute("aria-selected", "true"); - const firstRecentResult = page.locator(".search-result-row").first(); - await input.press("ArrowDown"); - await expect(firstRecentResult).toHaveAttribute("aria-selected", "true"); - await input.press("ArrowUp"); - await expect(scopeControl).toHaveAttribute("aria-selected", "true"); - await input.press("Enter"); - - const scopeChip = page.getByTestId("search-channel-scope-chip"); - await expect(scopeChip).toHaveText(/#general/); - await expect(input).toBeFocused(); - await input.fill("w"); - const relevantHeader = page.getByText("Most relevant", { exact: true }); - const firstScopedResult = page - .locator('[data-search-section="messages"] .search-result-row') - .first(); - await expect(page.getByText("Welcome to general")).toBeVisible(); - await expect(page.getByText(/Searching messages in/)).toHaveCount(0); - await expect(relevantHeader).toBeVisible(); - await expect(firstScopedResult).toBeVisible(); - const contentStart = (element: HTMLElement) => { - const styles = window.getComputedStyle(element); - return ( - element.getBoundingClientRect().left + - Number.parseFloat(styles.paddingLeft) - ); - }; - const [inputStart, headerStart, resultStart] = await Promise.all([ - page.getByTestId("search-dialog-input-row").evaluate(contentStart), - relevantHeader.evaluate(contentStart), - firstScopedResult.evaluate(contentStart), - ]); - expect(Math.abs(inputStart - headerStart)).toBeLessThanOrEqual(1); - expect(Math.abs(inputStart - resultStart)).toBeLessThanOrEqual(1); - - await input.fill("x"); - await expect(page.getByTestId("search-results")).toContainText( - "No messages for x in #general.", - ); - - await scopeChip.click(); - await expect(scopeChip).toHaveCount(0); - await expect(input).toBeFocused(); - await input.fill("shipped"); - await expect(page.getByTestId("search-results")).toContainText( - "Engineering shipped the desktop build.", - ); -}); - -test("global search offers a conversation-specific scope in direct messages", async ({ - page, -}) => { - const directMessageId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; - - await page.goto("/"); - await page.getByTestId("channel-alice-tyler").click(); - await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); - await expect(page).toHaveURL( - new RegExp(`#\\/channels\\/${directMessageId}$`), - ); - - await focusSidebarSearchWithShortcut(page); - - const scopeControl = page.getByTestId("search-current-channel-control"); - await expect - .poll(() => - scopeControl - .getByTestId("search-current-scope-label") - .evaluate((element) => element.textContent), - ) - .toBe("Search conversation with alice"); - await expect(scopeControl).toContainText("Search conversation with alice"); - await expect(scopeControl).toContainText( - "Search messages in this conversation.", - ); - await expect(scopeControl).not.toContainText("channel"); - await expect(page.getByTestId("search-dialog-input-row")).toHaveCSS( - "border-bottom-width", - "1px", - ); - await expect(scopeControl.locator("..")).toHaveCSS( - "border-bottom-width", - "0px", - ); - await expect(scopeControl.locator("..")).toHaveCSS("padding-top", "14px"); - await expect(scopeControl.locator("..")).toHaveCSS("padding-bottom", "14px"); - - await scopeControl.click(); - - const scopeChip = page.getByTestId("search-channel-scope-chip"); - const input = page.getByTestId("search-dialog-input"); - await expect(scopeChip).toHaveText(/^alice$/); - await expect(scopeChip).not.toContainText("#"); - await expect(input).toBeFocused(); - await input.fill("a"); - await expect(page.getByTestId("search-results")).toContainText( - "No messages for a in alice.", + await input.fill("welcome"); + + const currentChannelSection = page.locator( + '[data-search-section="current-channel-messages"]', ); + await expect(currentChannelSection).toContainText("In this conversation"); + await expect(currentChannelSection).toContainText("Welcome to #general"); + await expect( + currentChannelSection.locator(".search-result-row").first(), + ).toHaveAttribute("aria-selected", "true"); await expect .poll(() => page.evaluate(() => { @@ -600,28 +483,27 @@ test("global search offers a conversation-specific scope in direct messages", as window as Window & { __BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string; - payload: unknown; + payload: { channelId?: string; q?: string }; }>; } ).__BUZZ_E2E_COMMAND_LOG__ ?? []; - return calls.findLast((entry) => entry.command === "search_messages") - ?.payload; + return calls + .filter( + (entry) => + entry.command === "search_messages" && + entry.payload.q === "welcome", + ) + .map((entry) => entry.payload.channelId ?? null); }), ) - .toMatchObject({ - channelId: directMessageId, - q: "a", - }); + .toEqual( + expect.arrayContaining([null, "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]), + ); - await page.keyboard.press("Escape"); - await page.keyboard.press("ControlOrMeta+f"); - await expect(page.getByTestId("search-results")).toBeVisible(); - await expect(page.getByTestId("search-channel-scope-chip")).toHaveText( - /^alice$/, - ); - await expect(page.getByTestId("search-current-channel-control")).toHaveCount( - 0, + await input.fill("shipped"); + await expect(page.getByTestId("search-results")).toContainText( + "Engineering shipped the desktop build.", ); }); @@ -710,15 +592,14 @@ test("global search exposes a larger scrollable result window", async ({ await page.getByTestId("search-dialog-input").fill("deep history message"); const resultRows = page.locator( - '[data-search-section="messages"] .search-result-row', + '[data-search-section="current-channel-messages"] .search-result-row', ); await expect(resultRows).toHaveCount(40); const resultList = page.getByTestId("search-results-list"); await expect(resultList).toBeVisible(); - const scopeControl = page.getByTestId("search-current-channel-control"); - await expect( - resultList.getByTestId("search-current-channel-control"), - ).toBeVisible(); + await expect(page.getByTestId("search-current-channel-control")).toHaveCount( + 0, + ); const dimensions = await resultList.evaluate((element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, @@ -727,12 +608,11 @@ test("global search exposes a larger scrollable result window", async ({ await resultList.evaluate((element) => { element.scrollTop = element.scrollHeight; }); - await expect(scopeControl).not.toBeInViewport(); await resultList.evaluate((element) => { element.scrollTop = 0; }); - for (let index = 0; index < 14; index += 1) { + for (let index = 0; index < 13; index += 1) { await page.keyboard.press("ArrowDown"); } await expect(resultRows.nth(13)).toHaveAttribute("aria-selected", "true");