Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions desktop/src/features/search/lib/channelResultRanking.test.mjs
Original file line number Diff line number Diff line change
@@ -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" }),
[],
);
});
66 changes: 66 additions & 0 deletions desktop/src/features/search/lib/channelResultRanking.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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);
}
37 changes: 6 additions & 31 deletions desktop/src/features/search/useSearchResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
Loading