fix(desktop): surface unread DMs first in search

Co-authored-by: Fizz <f2701065ae39f701786d464e4c06b5fbc2fa3dda9f31f9439b3d077d8fd7fb0d@buzz.block.builderlab.xyz>
Signed-off-by: Fizz <f2701065ae39f701786d464e4c06b5fbc2fa3dda9f31f9439b3d077d8fd7fb0d@buzz.block.builderlab.xyz>
Co-authored-by: Arjun Mahanti <arjun@squareup.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Fizz
2026-08-24 10:53:06 -04:00
committed by Arjun Mahanti
parent 6115678f64
commit 30ccc8bf6e
9 changed files with 142 additions and 74 deletions

View File

@@ -0,0 +1,35 @@
export function SearchResultTrailing({
channelId,
isSelected,
trailingLabel,
unreadCount,
}: {
channelId?: string;
isSelected: boolean;
trailingLabel: string | null;
unreadCount: number;
}) {
return (
<>
{channelId && unreadCount > 0 ? (
<span
className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-2xs font-semibold text-primary-foreground"
data-testid={`search-unread-count-${channelId}`}
title={`${unreadCount} unread message${unreadCount === 1 ? "" : "s"}`}
>
{Math.min(unreadCount, 99)}
</span>
) : null}
{isSelected ? (
<kbd className="shrink-0 rounded border border-border/70 bg-background/70 px-1.5 py-0.5 text-2xs text-muted-foreground">
Enter
</kbd>
) : null}
{trailingLabel ? (
<span className="shrink-0 text-2xs text-muted-foreground/75">
{trailingLabel}
</span>
) : null}
</>
);
}

View File

@@ -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}

View File

@@ -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<void>;
onCreateChannel?: () => void | Promise<void>;
suggestionChannels?: Channel[];
unreadChannelCounts?: ReadonlyMap<string, number>;
unreadChannelIds?: ReadonlySet<string>;
scopeFocusRequest?: number;
variant?: "bar" | "icon";
};
const SEARCH_RESULT_LIMIT = 40;
const EMPTY_UNREAD_CHANNEL_IDS: ReadonlySet<string> = new Set();
const EMPTY_UNREAD_CHANNEL_COUNTS: ReadonlyMap<string, number> = new Map(),
EMPTY_UNREAD_CHANNEL_IDS: ReadonlySet<string> = 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({
</span>
)}
</span>
{result.kind !== "message" && trailingLabel ? (
<span className="shrink-0 text-2xs text-muted-foreground/75">
{trailingLabel}
</span>
{result.kind !== "message" ? (
<SearchResultTrailing
channelId={isUnreadResult ? result.channel.id : undefined}
isSelected={menuIndex === selectedMenuIndex}
trailingLabel={trailingLabel}
unreadCount={unreadCount}
/>
) : null}
</button>
);
@@ -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}
<div className="p-1.5">
{(() => {
let resultIndex = 0;
@@ -840,6 +843,7 @@ export function TopbarSearch({
)}
</div>
) : null}
{currentChannelSearchAction}
{suggestedResults.length > 0 ? (
<div data-search-section="recent-activity">
<div className={SEARCH_SECTION_TITLE_CLASS}>

View File

@@ -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,
);
});

View File

@@ -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;
}

View File

@@ -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<React.SetStateAction<number>>;
}) {
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,

View File

@@ -477,6 +477,7 @@ export function AppSidebar({
searchFocusRequest={searchFocusRequests[0]}
scopeSearchFocusRequest={searchFocusRequests[1]}
suggestionChannels={channels}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
/>

View File

@@ -36,6 +36,7 @@ type AppSidebarPinnedHeaderProps = {
searchFocusRequest: number;
scopeSearchFocusRequest: number;
suggestionChannels: Channel[];
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
};
@@ -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}
/>
</div>

View File

@@ -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 ({