feat(desktop): prioritize unread conversations 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:24:02 -04:00
committed by Arjun Mahanti
parent 8d2d0ff5ad
commit 6115678f64
6 changed files with 273 additions and 44 deletions

View File

@@ -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<void>;
onCreateChannel?: () => void | Promise<void>;
suggestionChannels?: Channel[];
unreadChannelIds?: ReadonlySet<string>;
scopeFocusRequest?: number;
variant?: "bar" | "icon";
};
const MAX_SEARCH_SUGGESTIONS = 4;
const SEARCH_RESULT_LIMIT = 40;
const 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 = [
@@ -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 ? (
<div data-search-section="unread">
<div className={SEARCH_SECTION_TITLE_CLASS}>Unread</div>
{unreadResults.map((result) =>
renderSearchResultRow(result, resultIndex++),
)}
</div>
) : null}
{suggestedResults.length > 0 ? (
<div>
<div data-search-section="recent-activity">
<div className={SEARCH_SECTION_TITLE_CLASS}>
Recent activity
</div>

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ type AppSidebarPinnedHeaderProps = {
searchFocusRequest: number;
scopeSearchFocusRequest: number;
suggestionChannels: Channel[];
unreadChannelIds: ReadonlySet<string>;
};
type AppSidebarPrimaryMenuProps = {
@@ -63,6 +64,7 @@ export function AppSidebarPinnedHeader({
searchFocusRequest,
scopeSearchFocusRequest,
suggestionChannels,
unreadChannelIds,
}: AppSidebarPinnedHeaderProps) {
return (
<div
@@ -83,6 +85,7 @@ export function AppSidebarPinnedHeader({
onCreateChannel={onCreateChannel}
scopeFocusRequest={scopeSearchFocusRequest}
suggestionChannels={suggestionChannels}
unreadChannelIds={unreadChannelIds}
/>
</div>
);

View File

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