mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
76
desktop/src/features/search/ui/topbarSearchSuggestions.ts
Normal file
76
desktop/src/features/search/ui/topbarSearchSuggestions.ts
Normal 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;
|
||||
}
|
||||
@@ -477,6 +477,7 @@ export function AppSidebar({
|
||||
searchFocusRequest={searchFocusRequests[0]}
|
||||
scopeSearchFocusRequest={searchFocusRequests[1]}
|
||||
suggestionChannels={channels}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user