diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index fdd72c3c32..1f4cff4074 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -1833,16 +1833,6 @@ impl Db {
         user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await
     }
 
-    /// Search users by display name, NIP-05 handle, or pubkey prefix.
-    pub async fn search_users(
-        &self,
-        community_id: CommunityId,
-        query: &str,
-        limit: u32,
-    ) -> Result<Vec<user::UserSearchProfile>> {
-        user::search_users(&self.pool, community_id, query, limit).await
-    }
-
     /// Atomically set agent owner — only if no owner is currently assigned.
     /// Returns Ok(true) if set, Ok(false) if an owner already exists.
     pub async fn set_agent_owner(
diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs
index 066fb5f5c0..95dea2d742 100644
--- a/crates/buzz-db/src/user.rs
+++ b/crates/buzz-db/src/user.rs
@@ -20,19 +20,6 @@ pub struct UserProfile {
     pub nip05_handle: Option<String>,
 }
 
-/// Lightweight user record returned from search.
-#[derive(Debug, Clone)]
-pub struct UserSearchProfile {
-    /// Raw 32-byte compressed public key.
-    pub pubkey: Vec<u8>,
-    /// Human-readable display name chosen by the user.
-    pub display_name: Option<String>,
-    /// URL of the user's avatar image.
-    pub avatar_url: Option<String>,
-    /// NIP-05 identifier (user@domain).
-    pub nip05_handle: Option<String>,
-}
-
 /// Ensure a user record exists for the given pubkey (upsert).
 /// Creates with minimal fields if not present; no-op if already exists.
 ///
@@ -206,80 +193,6 @@ pub async fn get_user_by_nip05(
     ))
 }
 
-/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated
-/// as literal text.  Used with `ESCAPE '\'` in the query.
-///
-/// Without this, a search query of `"%"` would match every row (full table
-/// scan) and `"_"` would act as a single-character wildcard.
-fn escape_like(input: &str) -> String {
-    input
-        .replace('\\', "\\\\")
-        .replace('%', "\\%")
-        .replace('_', "\\_")
-}
-
-/// Search users by display name, NIP-05 handle, or pubkey prefix.
-///
-/// Empty queries return an empty vec and do not hit the database.
-pub async fn search_users(
-    pool: &PgPool,
-    community_id: CommunityId,
-    query: &str,
-    limit: u32,
-) -> Result<Vec<UserSearchProfile>> {
-    let normalized = query.trim().to_lowercase();
-    if normalized.is_empty() {
-        return Ok(Vec::new());
-    }
-
-    let escaped = escape_like(&normalized);
-    let contains_pattern = format!("%{escaped}%");
-    let prefix_pattern = format!("{escaped}%");
-    let limit = limit.clamp(1, 500) as i64;
-
-    let rows = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
-        r#"
-        SELECT pubkey, display_name, avatar_url, nip05_handle
-        FROM users
-        WHERE community_id = $1
-          AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\'
-           OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\'
-           OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\')
-        ORDER BY
-            CASE
-                WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0
-                WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1
-                WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2
-                WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3
-                WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4
-                WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5
-                ELSE 6
-            END,
-            COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex')))
-        LIMIT $5
-        "#,
-    )
-    .bind(community_id.as_uuid())
-    .bind(&contains_pattern)
-    .bind(&normalized)
-    .bind(&prefix_pattern)
-    .bind(limit)
-    .fetch_all(pool)
-    .await?;
-
-    Ok(rows
-        .into_iter()
-        .map(
-            |(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile {
-                pubkey,
-                display_name,
-                avatar_url,
-                nip05_handle,
-            },
-        )
-        .collect())
-}
-
 /// Set the owner pubkey for an agent user.
 /// The owner pubkey must already exist in the users table (FK constraint).
 /// Returns an error if the agent pubkey is not found (rows_affected == 0).
@@ -612,40 +525,6 @@ mod tests {
         assert!(result.is_err(), "should reject invalid policy value");
     }
 
-    // Use the production `escape_like` function directly — no local mirror.
-    use super::escape_like;
-
-    #[test]
-    fn like_escape_percent() {
-        assert_eq!(escape_like("%"), "\\%");
-        assert_eq!(escape_like("100%match"), "100\\%match");
-    }
-
-    #[test]
-    fn like_escape_underscore() {
-        assert_eq!(escape_like("_"), "\\_");
-        assert_eq!(escape_like("a_b"), "a\\_b");
-    }
-
-    #[test]
-    fn like_escape_backslash() {
-        assert_eq!(escape_like("\\"), "\\\\");
-        assert_eq!(escape_like("a\\b"), "a\\\\b");
-    }
-
-    #[test]
-    fn like_escape_combined() {
-        // All three metacharacters in one string
-        assert_eq!(escape_like("%_\\"), "\\%\\_\\\\");
-    }
-
-    #[test]
-    fn like_escape_normal_input_unchanged() {
-        assert_eq!(escape_like("alice"), "alice");
-        assert_eq!(escape_like("bob@example.com"), "bob@example.com");
-        assert_eq!(escape_like(""), "");
-    }
-
     /// A user with "owner_only" policy but no agent_owner_pubkey set should
     /// return Some(("owner_only", None)).
     #[tokio::test]
diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs
index 8372e49a2a..9e0d5a1e81 100644
--- a/crates/buzz-relay/src/api/bridge.rs
+++ b/crates/buzz-relay/src/api/bridge.rs
@@ -341,10 +341,31 @@ fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode {
         .and_then(Value::as_str)
     {
         Some("prefix") => buzz_search::SearchMode::Prefix,
+        Some("contains") => buzz_search::SearchMode::Contains,
         _ => buzz_search::SearchMode::FullText,
     }
 }
 
+/// Upgrade profile-only prefix typeahead to substring matching.
+///
+/// Shipped clients (desktop, mobile) send `search_mode: "prefix"` for their
+/// user-picker typeahead over exactly `kinds:[0]` and expect any typed
+/// fragment of a name to match — but names are single FTS lexemes, so prefix
+/// matching misses mid-name fragments ("kurs" never finds "mattkursmark").
+/// Every prefix hit is a substring hit, so mapping these queries to
+/// `Contains` fixes installed apps without a client update. Message
+/// typeahead queries other kinds and keeps true prefix matching.
+fn effective_search_mode(
+    mode: buzz_search::SearchMode,
+    kinds: Option<&[i32]>,
+) -> buzz_search::SearchMode {
+    if mode == buzz_search::SearchMode::Prefix && kinds == Some(&[0]) {
+        buzz_search::SearchMode::Contains
+    } else {
+        mode
+    }
+}
+
 fn extract_search_page(raw: &Value) -> u32 {
     raw.get("page")
         .or_else(|| raw.get("search_page"))
@@ -1683,6 +1704,7 @@ async fn handle_bridge_search(
         });
         let since = filter.since.map(|s| s.as_secs() as i64);
         let until = filter.until.map(|u| u.as_secs() as i64);
+        let mode = effective_search_mode(search_mode, kinds.as_deref());
 
         let search_query = buzz_search::SearchQuery {
             community: tenant.community(),
@@ -1694,7 +1716,7 @@ async fn handle_bridge_search(
             until,
             page: search_page,
             per_page: limit,
-            mode: search_mode,
+            mode,
         };
 
         let search_result = state
@@ -2263,6 +2285,54 @@ mod tests {
         );
     }
 
+    #[test]
+    fn profile_only_prefix_typeahead_upgrades_to_contains() {
+        use buzz_search::SearchMode;
+
+        assert_eq!(
+            effective_search_mode(SearchMode::Prefix, Some(&[0])),
+            SearchMode::Contains,
+            "user-picker typeahead from shipped clients gets substring matching"
+        );
+        assert_eq!(
+            effective_search_mode(SearchMode::Prefix, Some(&[9, 40002])),
+            SearchMode::Prefix,
+            "message typeahead keeps true prefix"
+        );
+        assert_eq!(
+            effective_search_mode(SearchMode::Prefix, Some(&[0, 9])),
+            SearchMode::Prefix,
+            "mixed-kind searches keep true prefix"
+        );
+        assert_eq!(
+            effective_search_mode(SearchMode::Prefix, None),
+            SearchMode::Prefix
+        );
+        assert_eq!(
+            effective_search_mode(SearchMode::FullText, Some(&[0])),
+            SearchMode::FullText,
+            "whole-word search is never silently widened"
+        );
+        assert_eq!(
+            effective_search_mode(SearchMode::Contains, Some(&[0])),
+            SearchMode::Contains
+        );
+    }
+
+    #[test]
+    fn bridge_search_mode_extension_accepts_contains() {
+        assert_eq!(
+            extract_search_mode(
+                &serde_json::json!({ "search": "kurs", "search_mode": "contains" })
+            ),
+            buzz_search::SearchMode::Contains
+        );
+        assert_eq!(
+            extract_search_mode(&serde_json::json!({ "search": "kurs", "searchMode": "contains" })),
+            buzz_search::SearchMode::Contains
+        );
+    }
+
     /// Attack 3 proof: two stateless relay pods sharing Redis must share one
     /// community-scoped NIP-98 seen-set. Pod A's first claim succeeds; pod B's
     /// replay of the same event id in the same community is rejected. The same
diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs
index 7f33b660c4..5372ab8f50 100644
--- a/crates/buzz-search/src/query.rs
+++ b/crates/buzz-search/src/query.rs
@@ -63,6 +63,16 @@ pub enum SearchMode {
     /// relay still refetches and re-authorizes every hit; this mode changes only
     /// the candidate tsquery, not the access boundary.
     Prefix,
+    /// Case-insensitive substring match over the raw `content` (`kurs` matches
+    /// `mattkursmark`).
+    ///
+    /// Intended for user-picker typeahead over kind:0 profiles, where names are
+    /// single FTS lexemes that neither `FullText` nor `Prefix` can match
+    /// mid-token. Uses `ILIKE`, not tsquery, so hits carry no `ts_rank_cd`
+    /// relevance (rank is 0; ordering falls back to recency) — picker surfaces
+    /// re-rank client-side. Rows excluded from the search allowlist
+    /// (`search_tsv IS NULL`, e.g. gift wraps) stay excluded.
+    Contains,
 }
 
 /// A community-scoped FTS query.
@@ -137,8 +147,49 @@ const SEARCH_TEXT_MAX_CHARS: usize = 4096;
 /// wire untrusted input into a multi-trillion-row OFFSET.
 const PAGE_MAX: u32 = 1000;
 
-fn push_tsquery(qb: &mut QueryBuilder<sqlx::Postgres>, mode: SearchMode, search_text: &str) {
-    match mode {
+/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated
+/// as literal text. Used with `ESCAPE '\'` in the query.
+///
+/// Without this, a search query of `"%"` would match every row and `"_"`
+/// would act as a single-character wildcard.
+fn escape_like(input: &str) -> String {
+    input
+        .replace('\\', "\\\\")
+        .replace('%', "\\%")
+        .replace('_', "\\_")
+}
+
+/// Push the SELECT head, community fence, and mode-specific match predicate.
+///
+/// The two tsquery modes share the `search_tsv @@ query` shape with a
+/// `ts_rank_cd` relevance score. `Contains` has no tsquery: it matches the raw
+/// `content` with a LIKE-escaped `ILIKE` pattern and a constant rank, keeping
+/// `search_tsv IS NOT NULL` so kinds excluded from the search allowlist stay
+/// invisible to it.
+fn push_query_head(qb: &mut QueryBuilder<sqlx::Postgres>, query: &SearchQuery, search_text: &str) {
+    if query.mode == SearchMode::Contains {
+        qb.push(
+            "SELECT id, kind, pubkey, channel_id, \
+             EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \
+             0::real AS rank \
+             FROM events WHERE community_id = ",
+        );
+        qb.push_bind(*query.community.as_uuid());
+        qb.push(" AND deleted_at IS NULL AND search_tsv IS NOT NULL AND content ILIKE ");
+        qb.push_bind(format!("%{}%", escape_like(search_text)));
+        qb.push(" ESCAPE '\\'");
+        return;
+    }
+
+    qb.push(
+        "SELECT id, kind, pubkey, channel_id, \
+         EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \
+         ts_rank_cd(search_tsv, search_query.query) AS rank \
+         FROM events CROSS JOIN LATERAL (SELECT ",
+    );
+    match query.mode {
+        // Returned early above; this arm exists only for match totality.
+        SearchMode::Contains => {}
         SearchMode::FullText => {
             qb.push("websearch_to_tsquery('simple', ");
             qb.push_bind(search_text);
@@ -175,6 +226,9 @@ fn push_tsquery(qb: &mut QueryBuilder<sqlx::Postgres>, mode: SearchMode, search_
             );
         }
     }
+    qb.push(" AS query) AS search_query WHERE community_id = ");
+    qb.push_bind(*query.community.as_uuid());
+    qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query");
 }
 fn normalized_search_text(q: &str) -> Option<String> {
     let trimmed = q.trim();
@@ -197,7 +251,7 @@ fn normalized_search_text(q: &str) -> Option<String> {
 
 /// Execute a community-scoped FTS query.
 ///
-/// SQL shape (always):
+/// SQL shape (`FullText` and `Prefix`):
 /// ```sql
 /// SELECT id, kind, pubkey, channel_id, EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s,
 ///        ts_rank_cd(search_tsv, query) AS rank
@@ -211,6 +265,11 @@ fn normalized_search_text(q: &str) -> Option<String> {
 /// LIMIT $per_page OFFSET (($page - 1) * $per_page)
 /// ```
 ///
+/// `Contains` swaps the match arm for `search_tsv IS NOT NULL AND content
+/// ILIKE '%<escaped>%'` with a constant `0` rank (ordering falls back to
+/// recency); everything else — community fence, scopes, pagination — is the
+/// same builder tail.
+///
 /// `community_id = $ctx` is the first predicate and is non-negotiable. There
 /// is no code path through this function that omits it.
 pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult, SearchError> {
@@ -230,16 +289,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
     let page = query.page.clamp(1, PAGE_MAX);
     let offset = ((page - 1) as i64) * (per_page_actual as i64);
 
-    let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
-        "SELECT id, kind, pubkey, channel_id, \
-         EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \
-         ts_rank_cd(search_tsv, search_query.query) AS rank \
-         FROM events CROSS JOIN LATERAL (SELECT ",
-    );
-    push_tsquery(&mut qb, query.mode, &search_text);
-    qb.push(" AS query) AS search_query WHERE community_id = ");
-    qb.push_bind(*query.community.as_uuid());
-    qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query");
+    let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("");
+    push_query_head(&mut qb, query, &search_text);
 
     // Channel scope — see `ChannelScope` doc for the four-case mapping. The
     // emitted SQL fragments are identical to the legacy 2x2 tuple for the
@@ -349,4 +400,13 @@ mod tests {
         let cleaned = normalized_search_text(&long).expect("non-empty");
         assert_eq!(cleaned.chars().count(), SEARCH_TEXT_MAX_CHARS);
     }
+
+    #[test]
+    fn escape_like_treats_metacharacters_as_literals() {
+        assert_eq!(escape_like("%"), "\\%");
+        assert_eq!(escape_like("a_b"), "a\\_b");
+        assert_eq!(escape_like("a\\b"), "a\\\\b");
+        assert_eq!(escape_like("%_\\"), "\\%\\_\\\\");
+        assert_eq!(escape_like("kurs"), "kurs");
+    }
 }
diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs
index 675d15db8b..92ac4ffdf7 100644
--- a/crates/buzz-search/tests/fts_integration.rs
+++ b/crates/buzz-search/tests/fts_integration.rs
@@ -258,6 +258,135 @@ async fn search_does_not_return_other_community_events() {
     teardown(pool, &schema).await;
 }
 
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn contains_mode_matches_mid_token_substring() {
+    // Repro for "Member search should match substrings, not just prefixes":
+    // a member picker query for "kurs" must find the profile whose
+    // display_name is "mattkursmark". The name is a single `simple` lexeme,
+    // so neither FullText nor Prefix can match it mid-token — only Contains.
+    let (pool, schema) = setup().await;
+
+    let c = mk_community(&pool, "sub.example").await;
+    let evt_id = rand_bytes32();
+    insert_event(
+        &pool,
+        c,
+        evt_id,
+        rand_bytes32(),
+        0,
+        r#"{"name":"mattkursmark","display_name":"mattkursmark","about":"hello"}"#,
+        None,
+        1700000000,
+    )
+    .await;
+
+    let svc = SearchService::new(pool.clone());
+    let query = |q: &str, mode: buzz_search::SearchMode| SearchQuery {
+        community: c,
+        q: q.into(),
+        channel_scope: ChannelScope::Any,
+        kinds: Some(vec![0]),
+        authors: None,
+        since: None,
+        until: None,
+        page: 1,
+        per_page: 10,
+        mode,
+    };
+    let contains = buzz_search::SearchMode::Contains;
+    let prefix = buzz_search::SearchMode::Prefix;
+
+    let by_prefix = svc.search(&query("kurs", prefix)).await.expect("search ok");
+    assert_eq!(
+        by_prefix.hits.len(),
+        0,
+        "prefix mode cannot match mid-token — the gap contains mode closes"
+    );
+
+    let by_substring = svc
+        .search(&query("kurs", contains))
+        .await
+        .expect("search ok");
+    assert_eq!(by_substring.hits.len(), 1, "mid-token substring matches");
+    assert_eq!(by_substring.hits[0].event_id, evt_id);
+
+    let case_insensitive = svc
+        .search(&query("KURS", contains))
+        .await
+        .expect("search ok");
+    assert_eq!(case_insensitive.hits.len(), 1, "ILIKE is case-insensitive");
+
+    let wildcard = svc.search(&query("%", contains)).await.expect("search ok");
+    assert_eq!(
+        wildcard.hits.len(),
+        0,
+        "LIKE metacharacters are escaped, not wildcards"
+    );
+
+    teardown(pool, &schema).await;
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn contains_mode_stays_community_scoped_and_respects_search_allowlist() {
+    let (pool, schema) = setup().await;
+
+    let c_a = mk_community(&pool, "contains-a.example").await;
+    let c_b = mk_community(&pool, "contains-b.example").await;
+    insert_event(
+        &pool,
+        c_a,
+        rand_bytes32(),
+        rand_bytes32(),
+        0,
+        r#"{"display_name":"mattkursmark"}"#,
+        None,
+        1700000000,
+    )
+    .await;
+    // Kind 1059 (gift wrap) is excluded from the search allowlist:
+    // `search_tsv` is NULL and contains mode must not see its content.
+    insert_event(
+        &pool,
+        c_a,
+        rand_bytes32(),
+        rand_bytes32(),
+        1059,
+        "wrapped-kursmark-payload",
+        None,
+        1700000000,
+    )
+    .await;
+
+    let svc = SearchService::new(pool.clone());
+    let query = |community: CommunityId, kinds: Option<Vec<i32>>| SearchQuery {
+        community,
+        q: "kurs".into(),
+        channel_scope: ChannelScope::Any,
+        kinds,
+        authors: None,
+        since: None,
+        until: None,
+        page: 1,
+        per_page: 10,
+        mode: buzz_search::SearchMode::Contains,
+    };
+
+    let in_a = svc.search(&query(c_a, None)).await.expect("search ok");
+    assert_eq!(
+        in_a.hits.len(),
+        1,
+        "profile matches; allowlist-excluded gift wrap does not"
+    );
+    assert_eq!(in_a.hits[0].kind, 0);
+
+    let in_b = svc.search(&query(c_b, None)).await.expect("search ok");
+    assert_eq!(in_b.hits.len(), 0, "other community must see nothing");
+
+    teardown(pool, &schema).await;
+}
+
 #[tokio::test]
 #[ignore = "requires Postgres"]
 async fn kind0_search_by_display_name_works_without_flattening() {
diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs
index ef67fac570..5fecdcd8c1 100644
--- a/desktop/src-tauri/src/commands/profile.rs
+++ b/desktop/src-tauri/src/commands/profile.rs
@@ -255,7 +255,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,
     })
@@ -319,12 +319,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);
@@ -469,15 +471,17 @@ mod tests {
     }
 
     #[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);
-
-        assert_eq!(filter["search"], serde_json::json!("tyl"));
-        assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
+        // ("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!("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));
     }
diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart
index 5cea037f9d..71aeef6726 100644
--- a/mobile/lib/shared/relay/nostr_filters.dart
+++ b/mobile/lib/shared/relay/nostr_filters.dart
@@ -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.
