fix(desktop,mobile): user pickers search by substring, not prefix

Flip the user-search filters from search_mode="prefix" to "contains" so
every picker surface (member add, @mentions, DM recipients, topbar
people) matches any typed fragment of a name. Message typeahead stays on
prefix.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Tom Bates
2026-07-23 11:14:04 -03:00
co-authored by Cursor
parent 014364e133
commit 79c529091c
2 changed files with 23 additions and 18 deletions
+16 -12
View File
@@ -172,7 +172,7 @@ fn build_user_search_filter(query: &str, limit: usize, page: u32) -> serde_json:
serde_json::json!({
"kinds": [0],
"search": query,
"search_mode": "prefix",
"search_mode": "contains",
"limit": limit,
"page": page,
})
@@ -236,12 +236,14 @@ pub async fn search_users(
// than a substring hit in `about`. The caller can request later pages via the
// cursor so the UI cap is only a page size, not a terminal directory ceiling.
//
// `search_mode: "prefix"` matters: every caller of this command is a
// `search_mode: "contains"` matters: every caller of this command is a
// typeahead surface (member picker, @mention popup, DM recipient search,
// topbar people results), so a partially typed name must match. Without it
// the relay runs whole-word `websearch_to_tsquery` matching and "tyl"
// returns zero results for "Tyler". Same bridge-only extension the topbar
// message search uses (see `build_search_messages_filter`).
// topbar people results), so any typed fragment of a name must match.
// Whole-word FTS returns zero results for "tyl" → "Tyler", and prefix
// mode returns zero results for "kurs" → "mattkursmark" because the name
// is a single lexeme. Contains mode is ILIKE substring matching on the
// relay (same bridge-only `search_mode` extension family the topbar
// message search uses via `build_search_messages_filter`).
let events = query_relay(&state, &[build_user_search_filter(trimmed, max, page)]).await?;
let mut response = nostr_convert::rank_user_search_results(&events, trimmed, max);
@@ -336,15 +338,17 @@ mod tests {
use super::*;
#[test]
fn user_search_filter_requests_prefix_mode_for_typeahead() {
fn user_search_filter_requests_contains_mode_for_typeahead() {
// Every caller of `search_users` is a typeahead surface. Whole-word
// FTS matching returns zero results for a partially typed name
// ("tyl" for "Tyler"), which reads as "user doesn't exist" in the
// member picker and @mention popup. Pin the mode so it can't drift.
let filter = build_user_search_filter("tyl", 25, 1);
// ("tyl" for "Tyler"), and prefix matching returns zero results for
// a mid-name fragment ("kurs" for "mattkursmark"), which reads as
// "user doesn't exist" in the member picker and @mention popup. Pin
// the mode so it can't drift.
let filter = build_user_search_filter("kurs", 25, 1);
assert_eq!(filter["search"], serde_json::json!("tyl"));
assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
assert_eq!(filter["search"], serde_json::json!("kurs"));
assert_eq!(filter["search_mode"], serde_json::json!("contains"));
assert_eq!(filter["limit"], serde_json::json!(25));
assert_eq!(filter["page"], serde_json::json!(1));
}
+7 -6
View File
@@ -134,16 +134,17 @@ abstract final class NostrFilters {
/// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge).
///
/// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is
/// a typeahead surface, so a partially typed name must match ("rac" →
/// "raccoon"). Mirrors desktop's `build_user_search_filter`
/// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through
/// `queryRelay`, not a WebSocket REQ.
/// `search_mode: "contains"` is a Buzz bridge-only extension: every caller
/// is a typeahead surface, so any typed fragment of a name must match
/// ("kurs" → "mattkursmark" — prefix matching misses mid-name fragments
/// because names are single FTS lexemes). Mirrors desktop's
/// `build_user_search_filter` (desktop/src-tauri/src/commands/profile.rs).
/// Bridge-only — send through `queryRelay`, not a WebSocket REQ.
static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter(
kinds: [0],
search: query,
limit: limit,
extensions: const {'search_mode': 'prefix'},
extensions: const {'search_mode': 'contains'},
);
/// Deletions (kind:5) targeting event IDs.