From 422c90c26ba15ca07cadad811fc45af52912b99d Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Wed, 22 Jul 2026 11:02:31 -0700 Subject: [PATCH] fix(desktop): rank topbar channel results by relevance, not alphabetically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⌘K topbar channel section used naive substring matching sorted alphabetically, then capped at 5 — so a query like "buzz" over 61 buzz-* channels could never surface buzz-security (49th alphabetically), and separator-tolerant queries ("buzz security", "buzzsec") matched nothing at all. The channel browser already solved both with its banded fuzzy scorer. Reuse scoreChannelMatch for the topbar section via a small pure ranking helper: relevance-ordered (score band, alphabetical tiebreak), same visibility rules (archived only for members; open or member), label overrides scored as the display name with the raw channel name kept searchable, cap of 5 unchanged. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../search/lib/channelResultRanking.test.mjs | 121 ++++++++++++++++++ .../search/lib/channelResultRanking.ts | 66 ++++++++++ .../src/features/search/useSearchResults.ts | 37 +----- 3 files changed, 193 insertions(+), 31 deletions(-) create mode 100644 desktop/src/features/search/lib/channelResultRanking.test.mjs create mode 100644 desktop/src/features/search/lib/channelResultRanking.ts diff --git a/desktop/src/features/search/lib/channelResultRanking.test.mjs b/desktop/src/features/search/lib/channelResultRanking.test.mjs new file mode 100644 index 0000000000..47bed7ee68 --- /dev/null +++ b/desktop/src/features/search/lib/channelResultRanking.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { rankTopbarChannelResults } from "./channelResultRanking.ts"; + +function makeChannel(name, overrides = {}) { + return { + id: `id-${name}`, + name, + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: 1, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: false, + ttlSeconds: null, + ttlDeadline: null, + ...overrides, + }; +} + +function names(results) { + return results.map((channel) => channel.name); +} + +test("relevance beats alphabetical order under the cap", () => { + // The bug: query "buzz" over many buzz-* channels ranked alphabetically + // could never surface an exact/prefix match buried late in the alphabet. + const filler = ["buzz-acp", "buzz-bugs", "buzz-ci", "buzz-dev", "buzz-docs"]; + const channels = [ + ...filler.map((name) => makeChannel(name)), + makeChannel("buzz"), + ]; + + const results = rankTopbarChannelResults({ channels, lowerQuery: "buzz" }); + assert.equal(results.length, 5); + assert.equal(results[0].name, "buzz", "exact match must rank first"); +}); + +test("separator-tolerant matching: 'buzz security' finds buzz-security", () => { + const channels = [makeChannel("buzz-security"), makeChannel("general")]; + assert.deepEqual( + names(rankTopbarChannelResults({ channels, lowerQuery: "buzz security" })), + ["buzz-security"], + ); + assert.deepEqual( + names(rankTopbarChannelResults({ channels, lowerQuery: "buzzsec" })), + ["buzz-security"], + ); +}); + +test("caps results at 5 by default", () => { + const channels = Array.from({ length: 9 }, (_, i) => + makeChannel(`chan-${i}`), + ); + const results = rankTopbarChannelResults({ channels, lowerQuery: "chan" }); + assert.equal(results.length, 5); +}); + +test("visibility: private non-member hidden, archived only for members", () => { + const channels = [ + makeChannel("secret", { visibility: "private" }), + makeChannel("secret-mine", { visibility: "private", isMember: true }), + makeChannel("secret-old", { archivedAt: "2026-01-01T00:00:00Z" }), + makeChannel("secret-old-mine", { + archivedAt: "2026-01-01T00:00:00Z", + isMember: true, + }), + ]; + assert.deepEqual( + names(rankTopbarChannelResults({ channels, lowerQuery: "secret" })), + ["secret-mine", "secret-old-mine"], + ); +}); + +test("label override is scored as the display name", () => { + const channels = [ + makeChannel("dm-abc123", { channelType: "dm", isMember: true }), + ]; + const results = rankTopbarChannelResults({ + channels, + channelLabels: { "id-dm-abc123": "Tyler" }, + lowerQuery: "tyler", + }); + assert.deepEqual(names(results), ["dm-abc123"]); +}); + +test("raw name stays searchable when a label overrides it", () => { + const channels = [makeChannel("release-notes")]; + const results = rankTopbarChannelResults({ + channels, + channelLabels: { "id-release-notes": "Announcements" }, + lowerQuery: "release", + }); + assert.deepEqual(names(results), ["release-notes"]); +}); + +test("description matches rank below name matches", () => { + const channels = [ + makeChannel("random", { description: "security chatter" }), + makeChannel("buzz-security"), + ]; + assert.deepEqual( + names(rankTopbarChannelResults({ channels, lowerQuery: "security" })), + ["buzz-security", "random"], + ); +}); + +test("no match returns empty", () => { + const channels = [makeChannel("general")]; + assert.deepEqual( + rankTopbarChannelResults({ channels, lowerQuery: "zzzzqq" }), + [], + ); +}); diff --git a/desktop/src/features/search/lib/channelResultRanking.ts b/desktop/src/features/search/lib/channelResultRanking.ts new file mode 100644 index 0000000000..829ce92831 --- /dev/null +++ b/desktop/src/features/search/lib/channelResultRanking.ts @@ -0,0 +1,66 @@ +import { + scoreChannelMatch, + scoreChannelName, +} from "@/features/channels/lib/channelSearchScore"; +import type { Channel } from "@/shared/api/types"; + +/** Topbar search shows a short typeahead list; narrowing is done by typing. */ +export const TOPBAR_CHANNEL_RESULT_LIMIT = 5; + +/** + * Rank channels for the topbar (⌘K) typeahead using the same fuzzy scorer as + * the channel browser, so `buzz security` / `buzzsec` find `buzz-security` + * from either surface. Relevance-ordered (not alphabetical) — with a hard cap + * on results, alphabetical order buries good matches behind early-alphabet + * ones (e.g. `buzz` could never surface `buzz-security` behind 48 other + * `buzz-*` channels). + * + * Visibility rules match the previous behavior: archived channels only for + * members; otherwise open channels or memberships. + */ +export function rankTopbarChannelResults({ + channels, + channelLabels, + lowerQuery, + limit = TOPBAR_CHANNEL_RESULT_LIMIT, +}: { + channels: Channel[]; + channelLabels?: Record; + lowerQuery: string; + limit?: number; +}): Channel[] { + const scored: { channel: Channel; displayName: string; score: number }[] = []; + + for (const channel of channels) { + const visible = channel.archivedAt + ? channel.isMember + : channel.visibility === "open" || channel.isMember; + if (!visible) continue; + + const displayName = channelLabels?.[channel.id]?.trim() || channel.name; + + // Score against the display name (label override wins), with description + // as the lowest band. When a label overrides the name, the raw name must + // stay searchable too — take the best of the two. + let score = scoreChannelMatch( + { name: displayName, description: channel.description }, + lowerQuery, + ); + if (displayName !== channel.name) { + const rawNameScore = scoreChannelName(channel.name, lowerQuery); + if (rawNameScore !== null && (score === null || rawNameScore < score)) { + score = rawNameScore; + } + } + if (score === null) continue; + + scored.push({ channel, displayName, score }); + } + + return scored + .sort( + (a, b) => a.score - b.score || a.displayName.localeCompare(b.displayName), + ) + .slice(0, limit) + .map((entry) => entry.channel); +} diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index e90c5c5f8f..98ee686739 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -11,6 +11,7 @@ import { } from "@/features/profile/hooks"; import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useSearchMessagesQuery } from "@/features/search/hooks"; +import { rankTopbarChannelResults } from "@/features/search/lib/channelResultRanking"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -69,37 +70,11 @@ export function useSearchResults({ return []; } - const normalizedQuery = debouncedQuery.toLowerCase(); - - return channels - .filter( - (channel) => - (channel.archivedAt - ? channel.isMember - : channel.visibility === "open" || channel.isMember) && - [ - channel.name, - channel.description, - channelLabels?.[channel.id] ?? "", - ].some((value) => value.toLowerCase().includes(normalizedQuery)), - ) - .sort((a, b) => { - const aDisplayName = channelLabels?.[a.id]?.trim() || a.name; - const bDisplayName = channelLabels?.[b.id]?.trim() || b.name; - const aNameMatches = aDisplayName - .toLowerCase() - .includes(normalizedQuery); - const bNameMatches = bDisplayName - .toLowerCase() - .includes(normalizedQuery); - - if (aNameMatches !== bNameMatches) { - return aNameMatches ? -1 : 1; - } - - return aDisplayName.localeCompare(bDisplayName); - }) - .slice(0, 5); + return rankTopbarChannelResults({ + channels, + channelLabels, + lowerQuery: debouncedQuery.toLowerCase(), + }); }, [channelLabels, channels, debouncedQuery]); const hasSearchQuery = debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH;