mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
35
desktop/src/features/search/ui/SearchResultTrailing.tsx
Normal file
35
desktop/src/features/search/ui/SearchResultTrailing.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -477,6 +477,7 @@ export function AppSidebar({
|
||||
searchFocusRequest={searchFocusRequests[0]}
|
||||
scopeSearchFocusRequest={searchFocusRequests[1]}
|
||||
suggestionChannels={channels}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user