diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42..54257a28f6 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -16,6 +16,10 @@ import { SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; +import { + getInitialSearchSelection, + getSuggestedSearchResults, +} from "@/features/search/ui/topbarSearchSuggestions"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; @@ -42,12 +46,13 @@ type TopbarSearchProps = { onCreateAgent?: () => void | Promise; onCreateChannel?: () => void | Promise; suggestionChannels?: Channel[]; + unreadChannelIds?: ReadonlySet; scopeFocusRequest?: number; variant?: "bar" | "icon"; }; -const MAX_SEARCH_SUGGESTIONS = 4; const SEARCH_RESULT_LIMIT = 40; +const 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 = [ @@ -301,40 +306,6 @@ function groupSearchResults(results: SearchResult[]): SearchResultSection[] { }); } -function getSuggestedSearchResults(channels: Channel[]) { - return channels - .filter( - (channel) => - !channel.archivedAt && - (channel.isMember || channel.channelType === "dm"), - ) - .sort((a, b) => { - const activityDiff = - getChannelActivityTime(b) - getChannelActivityTime(a); - if (activityDiff !== 0) { - return activityDiff; - } - - const typeRank = (channel: Channel) => - channel.channelType === "dm" - ? 0 - : channel.channelType === "stream" - ? 1 - : 2; - const rankDiff = typeRank(a) - typeRank(b); - if (rankDiff !== 0) { - return rankDiff; - } - - return a.name.localeCompare(b.name); - }) - .slice(0, MAX_SEARCH_SUGGESTIONS) - .map((channel) => ({ - kind: "channel" as const, - channel, - })); -} - const searchSkeletonRows = [ { iconShape: "rounded-md", @@ -406,6 +377,7 @@ export function TopbarSearch({ onCreateChannel, scopeFocusRequest = 0, suggestionChannels, + unreadChannelIds = EMPTY_UNREAD_CHANNEL_IDS, variant = "bar", }: TopbarSearchProps) { const [isOpen, setIsOpen] = React.useState(false); @@ -449,9 +421,13 @@ export function TopbarSearch({ const currentPubkeyNormalized = currentPubkey && normalizePubkey(currentPubkey); const hasScopeAction = Boolean(currentChannel && !scopeChannel); - const suggestedResults = React.useMemo( - () => getSuggestedSearchResults(suggestionChannels ?? channels), - [channels, suggestionChannels], + const { suggestedResults, unreadResults } = React.useMemo( + () => + getSuggestedSearchResults( + suggestionChannels ?? channels, + unreadChannelIds, + ), + [channels, suggestionChannels, unreadChannelIds], ); const suggestionActionResults = React.useMemo(() => { const actions: SearchResult[] = []; @@ -489,8 +465,8 @@ export function TopbarSearch({ return actions; }, [onBrowseChannels, onCreateAgent, onCreateChannel]); const suggestionResults = React.useMemo( - () => [...suggestedResults, ...suggestionActionResults], - [suggestedResults, suggestionActionResults], + () => [...unreadResults, ...suggestedResults, ...suggestionActionResults], + [unreadResults, suggestedResults, suggestionActionResults], ); const minimumQueryLength = getMinimumSearchQueryLength(scopeChannelId); const isShowingSuggestions = @@ -526,10 +502,17 @@ export function TopbarSearch({ const openSearchDialog = React.useCallback( (nextScopeChannelId: string | null = null) => { setScopeChannelId(nextScopeChannelId); - setSelectedMenuIndex(0); + setSelectedMenuIndex( + nextScopeChannelId + ? 0 + : getInitialSearchSelection({ + hasLeadingAction: hasScopeAction, + hasUnreadChannels: unreadResults.length > 0, + }), + ); openNextFrame(() => setIsOpen(true)); }, - [openNextFrame], + [hasScopeAction, openNextFrame, unreadResults.length], ); const handleSearchOpenChange = React.useCallback( @@ -849,8 +832,16 @@ export function TopbarSearch({ return ( <> + {unreadResults.length > 0 ? ( +
+
Unread
+ {unreadResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} {suggestedResults.length > 0 ? ( -
+
Recent activity
diff --git a/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs b/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs new file mode 100644 index 0000000000..c6a8090605 --- /dev/null +++ b/desktop/src/features/search/ui/topbarSearchSuggestions.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getInitialSearchSelection, + getSuggestedChannels, +} from "./topbarSearchSuggestions.ts"; + +function channel(id, options = {}) { + return { + archivedAt: null, + channelType: "stream", + description: "", + id, + isMember: true, + lastMessageAt: null, + name: id, + ...options, + }; +} + +test("unread conversations come before recent conversations in activity order", () => { + const channels = [ + channel("recent", { lastMessageAt: "2026-08-24T12:00:00Z" }), + channel("older-unread", { lastMessageAt: "2026-08-24T10:00:00Z" }), + channel("newer-unread", { lastMessageAt: "2026-08-24T11:00:00Z" }), + ]; + + const result = getSuggestedChannels( + channels, + new Set(["older-unread", "newer-unread"]), + ); + + assert.deepEqual( + result.unreadChannels.map(({ id }) => id), + ["newer-unread", "older-unread"], + ); + assert.deepEqual( + result.recentChannels.map(({ id }) => id), + ["recent"], + ); +}); + +test("all unread conversations remain visible while recent results stay capped", () => { + const channels = Array.from({ length: 10 }, (_, index) => + channel(`channel-${index}`, { + lastMessageAt: `2026-08-24T${String(index).padStart(2, "0")}:00:00Z`, + }), + ); + + const result = getSuggestedChannels( + channels, + new Set(channels.slice(0, 6).map(({ id }) => id)), + ); + + assert.equal(result.unreadChannels.length, 6); + assert.equal(result.recentChannels.length, 4); +}); + +test("archived and unjoined non-DM conversations are omitted", () => { + const result = getSuggestedChannels( + [ + channel("archived", { archivedAt: "2026-08-24T12:00:00Z" }), + channel("unjoined", { isMember: false }), + channel("dm", { channelType: "dm", isMember: false }), + ], + new Set(["archived", "unjoined", "dm"]), + ); + + assert.deepEqual( + result.unreadChannels.map(({ id }) => id), + ["dm"], + ); +}); + +test("the first unread row is selected even when a scope action leads the menu", () => { + assert.equal( + getInitialSearchSelection({ + hasLeadingAction: true, + hasUnreadChannels: true, + }), + 1, + ); + assert.equal( + getInitialSearchSelection({ + hasLeadingAction: true, + hasUnreadChannels: false, + }), + 0, + ); + assert.equal( + getInitialSearchSelection({ + hasLeadingAction: false, + hasUnreadChannels: true, + }), + 0, + ); +}); diff --git a/desktop/src/features/search/ui/topbarSearchSuggestions.ts b/desktop/src/features/search/ui/topbarSearchSuggestions.ts new file mode 100644 index 0000000000..733f4e9c32 --- /dev/null +++ b/desktop/src/features/search/ui/topbarSearchSuggestions.ts @@ -0,0 +1,76 @@ +import type { Channel } from "@/shared/api/types"; + +export const MAX_RECENT_SEARCH_SUGGESTIONS = 4; + +function getChannelActivityTime(channel: Channel) { + if (!channel.lastMessageAt) return 0; + + const timestamp = Date.parse(channel.lastMessageAt); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function channelTypeRank(channel: Channel) { + return channel.channelType === "dm" + ? 0 + : channel.channelType === "stream" + ? 1 + : 2; +} + +function compareSuggestedChannels(a: Channel, b: Channel) { + const activityDiff = getChannelActivityTime(b) - getChannelActivityTime(a); + if (activityDiff !== 0) return activityDiff; + + const rankDiff = channelTypeRank(a) - channelTypeRank(b); + if (rankDiff !== 0) return rankDiff; + + return a.name.localeCompare(b.name); +} + +export function getSuggestedChannels( + channels: Channel[], + unreadChannelIds: ReadonlySet, +) { + const eligibleChannels = channels.filter( + (channel) => + !channel.archivedAt && (channel.isMember || channel.channelType === "dm"), + ); + const unreadChannels = eligibleChannels + .filter((channel) => unreadChannelIds.has(channel.id)) + .sort(compareSuggestedChannels); + const recentChannels = eligibleChannels + .filter((channel) => !unreadChannelIds.has(channel.id)) + .sort(compareSuggestedChannels) + .slice(0, MAX_RECENT_SEARCH_SUGGESTIONS); + + return { recentChannels, unreadChannels }; +} + +export function getSuggestedSearchResults( + channels: Channel[], + unreadChannelIds: ReadonlySet, +) { + const { recentChannels, unreadChannels } = getSuggestedChannels( + channels, + unreadChannelIds, + ); + const toResult = (channel: Channel) => ({ + kind: "channel" as const, + channel, + }); + + return { + suggestedResults: recentChannels.map(toResult), + unreadResults: unreadChannels.map(toResult), + }; +} + +export function getInitialSearchSelection({ + hasLeadingAction, + hasUnreadChannels, +}: { + hasLeadingAction: boolean; + hasUnreadChannels: boolean; +}) { + return hasUnreadChannels && hasLeadingAction ? 1 : 0; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 6ca36bdc84..5937b6106b 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} + unreadChannelIds={unreadChannelIds} />
; }; type AppSidebarPrimaryMenuProps = { @@ -63,6 +64,7 @@ export function AppSidebarPinnedHeader({ searchFocusRequest, scopeSearchFocusRequest, suggestionChannels, + unreadChannelIds, }: AppSidebarPinnedHeaderProps) { return (
); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 14fc299e9b..dedc402cf8 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; -import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge"; +import { + installMockBridge, + openCreateChannelDialog, + TEST_IDENTITIES, +} from "../helpers/bridge"; async function getTimelineMetrics(page: import("@playwright/test").Page) { return page.getByTestId("message-timeline").evaluate((element) => { @@ -63,6 +67,23 @@ async function focusSidebarSearchWithShortcut( await expect(page.getByTestId("search-dialog-input")).toBeFocused(); } +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(() => + page.evaluate( + (name) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + async function expectHomeView(page: import("@playwright/test").Page) { await expect(page.getByTestId("home-inbox-list")).toBeVisible(); } @@ -379,6 +400,45 @@ test("opens channel matches from search", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("engineering"); }); +test("Cmd+K bubbles unread conversations to the top and Enter opens the first", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await waitForMockLiveSubscription(page, "random"); + 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, + }); + }, TEST_IDENTITIES.alice.pubkey); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "700", + ); + + await focusSidebarSearchWithShortcut(page); + + 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", + ); + await expect(firstUnread).toHaveAttribute("aria-selected", "true"); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL( + /#\/channels\/9dae0116-799b-5071-a0a8-fdd30a91a35d$/, + ); + await expect(page.getByTestId("chat-title")).toHaveText("random"); +}); + test("global search offers an optional current-channel scope", async ({ page, }) => {