diff --git a/desktop/src/features/search/ui/SearchResultTrailing.tsx b/desktop/src/features/search/ui/SearchResultTrailing.tsx new file mode 100644 index 0000000000..b31950a6b7 --- /dev/null +++ b/desktop/src/features/search/ui/SearchResultTrailing.tsx @@ -0,0 +1,35 @@ +export function SearchResultTrailing({ + channelId, + isSelected, + trailingLabel, + unreadCount, +}: { + channelId?: string; + isSelected: boolean; + trailingLabel: string | null; + unreadCount: number; +}) { + return ( + <> + {channelId && unreadCount > 0 ? ( + + {Math.min(unreadCount, 99)} + + ) : null} + {isSelected ? ( + + Enter + + ) : null} + {trailingLabel ? ( + + {trailingLabel} + + ) : null} + + ); +} diff --git a/desktop/src/features/search/ui/SearchScopeControls.tsx b/desktop/src/features/search/ui/SearchScopeControls.tsx index 4a2aa461b9..a3c9fc98bf 100644 --- a/desktop/src/features/search/ui/SearchScopeControls.tsx +++ b/desktop/src/features/search/ui/SearchScopeControls.tsx @@ -97,6 +97,7 @@ type CurrentChannelSearchActionProps = { channelLabel: string; channelType: Channel["channelType"]; isSelected: boolean; + menuIndex: number; onActivate: () => void; onMouseEnter: () => void; }; @@ -105,6 +106,7 @@ export function CurrentChannelSearchAction({ channelLabel, channelType, isSelected, + menuIndex, onActivate, onMouseEnter, }: CurrentChannelSearchActionProps) { @@ -117,7 +119,7 @@ export function CurrentChannelSearchAction({ className={`flex w-full items-center gap-4 rounded-lg border border-border/75 px-3 py-3 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring ${ isSelected ? "bg-muted/55" : "bg-muted/30 hover:bg-muted/55" }`} - data-search-result-index="0" + data-search-result-index={menuIndex} data-testid="search-current-channel-control" onClick={onActivate} onMouseEnter={onMouseEnter} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 54257a28f6..116e629dba 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -15,9 +15,11 @@ import { getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; +import { SearchResultTrailing } from "@/features/search/ui/SearchResultTrailing"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import { - getInitialSearchSelection, + getChannelActivityTime, + getSearchActionMenuIndex, getSuggestedSearchResults, } from "@/features/search/ui/topbarSearchSuggestions"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; @@ -46,13 +48,15 @@ type TopbarSearchProps = { onCreateAgent?: () => void | Promise; onCreateChannel?: () => void | Promise; suggestionChannels?: Channel[]; + unreadChannelCounts?: ReadonlyMap; unreadChannelIds?: ReadonlySet; scopeFocusRequest?: number; variant?: "bar" | "icon"; }; const SEARCH_RESULT_LIMIT = 40; -const EMPTY_UNREAD_CHANNEL_IDS: ReadonlySet = new Set(); +const EMPTY_UNREAD_CHANNEL_COUNTS: ReadonlyMap = new Map(), + EMPTY_UNREAD_CHANNEL_IDS: ReadonlySet = new Set(); 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 = [ @@ -115,15 +119,6 @@ function formatRelativeTime(unixSeconds: number) { }).format(new Date(unixSeconds * 1_000)); } -function getChannelActivityTime(channel: Channel) { - if (!channel.lastMessageAt) { - return 0; - } - - const timestamp = Date.parse(channel.lastMessageAt); - return Number.isFinite(timestamp) ? timestamp : 0; -} - function getChannelSuggestionMeta(channel: Channel) { const activityTime = getChannelActivityTime(channel); @@ -377,6 +372,7 @@ export function TopbarSearch({ onCreateChannel, scopeFocusRequest = 0, suggestionChannels, + unreadChannelCounts = EMPTY_UNREAD_CHANNEL_COUNTS, unreadChannelIds = EMPTY_UNREAD_CHANNEL_IDS, variant = "bar", }: TopbarSearchProps) { @@ -493,6 +489,11 @@ export function TopbarSearch({ ? [] : suggestionResults : groupedSearchResults; + const actionMenuIndex = getSearchActionMenuIndex({ + hasScopeAction, + isShowingSuggestions, + unreadResultCount: unreadResults.length, + }); const isSearchLoading = isWaitingOnFromResolution || searchQuery.isLoading || @@ -502,17 +503,10 @@ export function TopbarSearch({ const openSearchDialog = React.useCallback( (nextScopeChannelId: string | null = null) => { setScopeChannelId(nextScopeChannelId); - setSelectedMenuIndex( - nextScopeChannelId - ? 0 - : getInitialSearchSelection({ - hasLeadingAction: hasScopeAction, - hasUnreadChannels: unreadResults.length > 0, - }), - ); + setSelectedMenuIndex(0); openNextFrame(() => setIsOpen(true)); }, - [hasScopeAction, openNextFrame, unreadResults.length], + [openNextFrame], ); const handleSearchOpenChange = React.useCallback( @@ -633,9 +627,9 @@ export function TopbarSearch({ }, [isOpen]); const handleDialogInputKeyDown = useSearchMenuKeyboardNavigation({ + actionMenuIndex, activeResults, - hasLeadingAction: hasScopeAction, - onActivateLeadingAction: activateCurrentChannelScope, + onActivateAction: activateCurrentChannelScope, onOpenResult: openResult, onRemoveScope: removeChannelScope, query, @@ -645,7 +639,8 @@ export function TopbarSearch({ }); const renderSearchResultRow = (result: SearchResult, index: number) => { - const menuIndex = index + (hasScopeAction ? 1 : 0); + const menuIndex = + actionMenuIndex !== null && index >= actionMenuIndex ? index + 1 : index; const channelDisplayName = result.kind === "channel" ? getChannelDisplayName(result.channel, channelLabels) @@ -681,6 +676,11 @@ export function TopbarSearch({ : result.kind === "user" ? getUserSecondaryLabel(result.user) : truncateResultText(result.hit.content); + const unreadCount = + result.kind === "channel" + ? (unreadChannelCounts.get(result.channel.id) ?? 0) + : 0; + const isUnreadResult = result.kind === "channel" && unreadCount > 0; const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -771,10 +771,13 @@ export function TopbarSearch({ )} - {result.kind !== "message" && trailingLabel ? ( - - {trailingLabel} - + {result.kind !== "message" ? ( + ) : null} ); @@ -801,9 +804,10 @@ export function TopbarSearch({ currentPubkey, )} channelType={currentChannel.channelType} - isSelected={selectedMenuIndex === 0} + isSelected={selectedMenuIndex === actionMenuIndex} + menuIndex={actionMenuIndex ?? 0} onActivate={activateCurrentChannelScope} - onMouseEnter={() => setSelectedMenuIndex(0)} + onMouseEnter={() => setSelectedMenuIndex(actionMenuIndex ?? 0)} /> ) : null; const searchResultContent = isShowingSuggestions ? ( @@ -825,7 +829,6 @@ export function TopbarSearch({ className="max-h-96 overflow-y-auto" role="listbox" > - {currentChannelSearchAction}
{(() => { let resultIndex = 0; @@ -840,6 +843,7 @@ export function TopbarSearch({ )}
) : null} + {currentChannelSearchAction} {suggestedResults.length > 0 ? (
diff --git a/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs b/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs index c6a8090605..b22c32a140 100644 --- a/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs +++ b/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getInitialSearchSelection, + getSearchActionMenuIndex, getSuggestedChannels, } from "./topbarSearchSuggestions.ts"; @@ -73,26 +73,29 @@ test("archived and unjoined non-DM conversations are omitted", () => { ); }); -test("the first unread row is selected even when a scope action leads the menu", () => { +test("the scope action follows unread rows while suggestions are visible", () => { assert.equal( - getInitialSearchSelection({ - hasLeadingAction: true, - hasUnreadChannels: true, + getSearchActionMenuIndex({ + hasScopeAction: true, + isShowingSuggestions: true, + unreadResultCount: 2, }), - 1, + 2, ); assert.equal( - getInitialSearchSelection({ - hasLeadingAction: true, - hasUnreadChannels: false, + getSearchActionMenuIndex({ + hasScopeAction: true, + isShowingSuggestions: true, + unreadResultCount: 0, }), 0, ); assert.equal( - getInitialSearchSelection({ - hasLeadingAction: false, - hasUnreadChannels: true, + getSearchActionMenuIndex({ + hasScopeAction: false, + isShowingSuggestions: true, + unreadResultCount: 2, }), - 0, + null, ); }); diff --git a/desktop/src/features/search/ui/topbarSearchSuggestions.ts b/desktop/src/features/search/ui/topbarSearchSuggestions.ts index 733f4e9c32..61cb1a5b5f 100644 --- a/desktop/src/features/search/ui/topbarSearchSuggestions.ts +++ b/desktop/src/features/search/ui/topbarSearchSuggestions.ts @@ -2,7 +2,7 @@ import type { Channel } from "@/shared/api/types"; export const MAX_RECENT_SEARCH_SUGGESTIONS = 4; -function getChannelActivityTime(channel: Channel) { +export function getChannelActivityTime(channel: Channel) { if (!channel.lastMessageAt) return 0; const timestamp = Date.parse(channel.lastMessageAt); @@ -65,12 +65,15 @@ export function getSuggestedSearchResults( }; } -export function getInitialSearchSelection({ - hasLeadingAction, - hasUnreadChannels, +export function getSearchActionMenuIndex({ + hasScopeAction, + isShowingSuggestions, + unreadResultCount, }: { - hasLeadingAction: boolean; - hasUnreadChannels: boolean; + hasScopeAction: boolean; + isShowingSuggestions: boolean; + unreadResultCount: number; }) { - return hasUnreadChannels && hasLeadingAction ? 1 : 0; + 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 66832ac7a9..b29258fece 100644 --- a/desktop/src/features/search/ui/useSearchMenuKeyboardNavigation.ts +++ b/desktop/src/features/search/ui/useSearchMenuKeyboardNavigation.ts @@ -3,9 +3,9 @@ import * as React from "react"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; export function useSearchMenuKeyboardNavigation({ + actionMenuIndex, activeResults, - hasLeadingAction, - onActivateLeadingAction, + onActivateAction, onOpenResult, onRemoveScope, query, @@ -13,9 +13,9 @@ export function useSearchMenuKeyboardNavigation({ selectedMenuIndex, setSelectedMenuIndex, }: { + actionMenuIndex: number | null; activeResults: SearchResult[]; - hasLeadingAction: boolean; - onActivateLeadingAction: () => void; + onActivateAction: () => void; onOpenResult: (result: SearchResult) => void; onRemoveScope: () => void; query: string; @@ -23,7 +23,8 @@ export function useSearchMenuKeyboardNavigation({ selectedMenuIndex: number; setSelectedMenuIndex: React.Dispatch>; }) { - const selectableCount = activeResults.length + (hasLeadingAction ? 1 : 0); + const selectableCount = + activeResults.length + (actionMenuIndex === null ? 0 : 1); React.useEffect(() => { setSelectedMenuIndex((current) => { @@ -63,19 +64,22 @@ export function useSearchMenuKeyboardNavigation({ if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); - if (hasLeadingAction && selectedMenuIndex === 0) { - onActivateLeadingAction(); + if (actionMenuIndex === selectedMenuIndex) { + onActivateAction(); return; } - const result = - activeResults[selectedMenuIndex - (hasLeadingAction ? 1 : 0)]; + const resultIndex = + actionMenuIndex !== null && selectedMenuIndex > actionMenuIndex + ? selectedMenuIndex - 1 + : selectedMenuIndex; + const result = activeResults[resultIndex]; if (result) onOpenResult(result); } }, [ + actionMenuIndex, activeResults, - hasLeadingAction, - onActivateLeadingAction, + onActivateAction, onOpenResult, onRemoveScope, query.length, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 5937b6106b..ee6d886a51 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -477,6 +477,7 @@ export function AppSidebar({ searchFocusRequest={searchFocusRequests[0]} scopeSearchFocusRequest={searchFocusRequests[1]} suggestionChannels={channels} + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} /> diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index f793b61dcc..4a553ae40e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -36,6 +36,7 @@ type AppSidebarPinnedHeaderProps = { searchFocusRequest: number; scopeSearchFocusRequest: number; suggestionChannels: Channel[]; + unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; }; @@ -64,6 +65,7 @@ export function AppSidebarPinnedHeader({ searchFocusRequest, scopeSearchFocusRequest, suggestionChannels, + unreadChannelCounts, unreadChannelIds, }: AppSidebarPinnedHeaderProps) { return ( @@ -85,6 +87,7 @@ export function AppSidebarPinnedHeader({ onCreateChannel={onCreateChannel} scopeFocusRequest={scopeSearchFocusRequest} suggestionChannels={suggestionChannels} + unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} />
diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index dedc402cf8..035ffc8be5 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -404,39 +404,52 @@ test("Cmd+K bubbles unread conversations to the top and Enter opens the first", page, }) => { await page.goto("/"); - await page.getByTestId("channel-random").click(); - await waitForMockLiveSubscription(page, "random"); + await page.getByTestId("channel-alice-tyler").click(); + await waitForMockLiveSubscription(page, "alice-tyler"); await page.getByTestId("channel-general").click(); await page.evaluate((pubkey) => { - window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "random", - content: "Unread shortcut from Cmd+K", - kind: 40002, - pubkey, - }); + for (const content of [ + "First unread DM shortcut from Cmd+K", + "Second unread DM shortcut from Cmd+K", + ]) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "alice-tyler", + content, + kind: 40002, + pubkey, + }); + } }, TEST_IDENTITIES.alice.pubkey); - await expect(page.getByTestId("channel-random")).toHaveCSS( + await expect(page.getByTestId("channel-alice-tyler")).toHaveCSS( "font-weight", "700", ); await focusSidebarSearchWithShortcut(page); + const directMessageId = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; const unreadSection = page.locator('[data-search-section="unread"]'); const firstUnread = unreadSection.locator(".search-result-row").first(); await expect(unreadSection).toContainText("Unread"); await expect(firstUnread).toHaveAttribute( "data-testid", - "search-result-channel-9dae0116-799b-5071-a0a8-fdd30a91a35d", + `search-result-channel-${directMessageId}`, ); await expect(firstUnread).toHaveAttribute("aria-selected", "true"); + await expect( + 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 page.keyboard.press("Enter"); await expect(page).toHaveURL( - /#\/channels\/9dae0116-799b-5071-a0a8-fdd30a91a35d$/, + new RegExp(`#\\/channels\\/${directMessageId}$`), ); - await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); }); test("global search offers an optional current-channel scope", async ({