commit b0a5b7bfb2110f7cca46d59b02b2ca7c0be3e998
parent c974df6d217f2046156466f8394624f0f73728f3
Author: William Casarin <jb55@jb55.com>
Date: Fri, 20 Feb 2026 12:50:44 -0800
consolidate mention search to use search_profiles()
All @mention searches (post composer, search column) now go through
search_profiles() instead of calling ndb.search_profile() directly.
This gives contact prioritization and pubkey format parsing everywhere.
Results are cached to avoid per-frame allocation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
6 files changed, 83 insertions(+), 114 deletions(-)
diff --git a/crates/notedeck_columns/src/draft.rs b/crates/notedeck_columns/src/draft.rs
@@ -7,6 +7,7 @@ use crate::{
ui::{note::PostType, search::FocusState},
Error,
};
+use notedeck_ui::ProfileSearchResult;
use std::collections::HashMap;
#[derive(Default)]
@@ -24,6 +25,7 @@ pub struct MentionHint {
pub index: usize,
pub pos: egui::Pos2,
pub text: String,
+ pub results: Vec<ProfileSearchResult>,
}
#[derive(Default)]
diff --git a/crates/notedeck_columns/src/ui/mentions_picker.rs b/crates/notedeck_columns/src/ui/mentions_picker.rs
@@ -1,14 +1,7 @@
-use egui::{vec2, FontId, Layout, Pos2, Rect, ScrollArea, UiBuilder, Vec2b};
-use nostrdb::{Ndb, ProfileRecord, Transaction};
-use notedeck::{
- fonts::get_font_size, name::get_display_name, profile::get_profile_url, DragResponse, Images,
- MediaJobSender, NotedeckTextStyle,
-};
-use notedeck_ui::{
- anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
- widgets::x_button,
- ProfilePic,
-};
+use egui::{vec2, Layout, ScrollArea, UiBuilder, Vec2b};
+use nostrdb::{Ndb, Transaction};
+use notedeck::{DragResponse, Images, Localization, MediaJobSender};
+use notedeck_ui::{profile_row, widgets::x_button, ProfileSearchResult};
use tracing::error;
/// Displays user profiles for the user to pick from.
@@ -17,8 +10,9 @@ pub struct MentionPickerView<'a> {
ndb: &'a Ndb,
txn: &'a Transaction,
img_cache: &'a mut Images,
- results: &'a Vec<&'a [u8; 32]>,
+ results: &'a [ProfileSearchResult],
jobs: &'a MediaJobSender,
+ i18n: &'a mut Localization,
}
pub enum MentionPickerResponse {
@@ -31,8 +25,9 @@ impl<'a> MentionPickerView<'a> {
img_cache: &'a mut Images,
ndb: &'a Ndb,
txn: &'a Transaction,
- results: &'a Vec<&'a [u8; 32]>,
+ results: &'a [ProfileSearchResult],
jobs: &'a MediaJobSender,
+ i18n: &'a mut Localization,
) -> Self {
Self {
ndb,
@@ -40,25 +35,30 @@ impl<'a> MentionPickerView<'a> {
img_cache,
results,
jobs,
+ i18n,
}
}
- fn show(&mut self, ui: &mut egui::Ui, width: f32) -> MentionPickerResponse {
+ fn show(&mut self, ui: &mut egui::Ui) -> MentionPickerResponse {
let mut selection = None;
ui.vertical(|ui| {
for (i, res) in self.results.iter().enumerate() {
- let profile = match self.ndb.get_profile_by_pubkey(self.txn, res) {
+ let profile = match self.ndb.get_profile_by_pubkey(self.txn, &res.pk) {
Ok(rec) => rec,
Err(e) => {
- error!("Error fetching profile for pubkey {:?}: {e}", res);
+ error!("Error fetching profile for pubkey {:?}: {e}", res.pk);
return;
}
};
- if ui
- .add(user_result(&profile, self.img_cache, self.jobs, i, width))
- .clicked()
- {
+ if profile_row(
+ ui,
+ Some(&profile),
+ res.is_contact,
+ self.img_cache,
+ self.jobs,
+ self.i18n,
+ ) {
selection = Some(i)
}
}
@@ -82,11 +82,10 @@ impl<'a> MentionPickerView<'a> {
egui::Frame::NONE
.fill(ui.visuals().panel_fill)
.show(ui, |ui| {
- let width = rect.width() - (2.0 * inner_margin_size);
-
ui.allocate_space(vec2(ui.available_width(), inner_margin_size));
let close_button_resp = {
let close_button_size = 16.0;
+ let width = rect.width() - (2.0 * inner_margin_size);
let (close_section_rect, _) = ui.allocate_exact_size(
vec2(width, close_button_size),
egui::Sense::hover(),
@@ -109,7 +108,7 @@ impl<'a> MentionPickerView<'a> {
let scroll_resp = ScrollArea::vertical()
.max_width(rect.width())
.auto_shrink(Vec2b::FALSE)
- .show(ui, |ui| Some(self.show(ui, width)));
+ .show(ui, |ui| Some(self.show(ui)));
ui.advance_cursor_after_rect(rect);
DragResponse::scroll(scroll_resp).map_output(|o| {
@@ -126,68 +125,3 @@ impl<'a> MentionPickerView<'a> {
area_resp.inner
}
}
-
-fn user_result<'a>(
- profile: &'a ProfileRecord<'_>,
- cache: &'a mut Images,
- jobs: &'a MediaJobSender,
- index: usize,
- width: f32,
-) -> impl egui::Widget + 'a {
- move |ui: &mut egui::Ui| -> egui::Response {
- let min_img_size = 48.0;
- let max_image = min_img_size * ICON_EXPANSION_MULTIPLE;
- let spacing = 8.0;
- let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
-
- let animation_rect = {
- let max_width = ui.available_width();
- let extra_width = (max_width - width) / 2.0;
- let left = ui.cursor().left();
- let (rect, _) =
- ui.allocate_exact_size(vec2(width + extra_width, max_image), egui::Sense::click());
-
- let (_, right) = rect.split_left_right_at_x(left + extra_width);
- right
- };
-
- let helper = AnimationHelper::new_from_rect(ui, ("user_result", index), animation_rect);
-
- let icon_rect = {
- let r = helper.get_animation_rect();
- let mut center = r.center();
- center.x = r.left() + (max_image / 2.0);
- let size = helper.scale_1d_pos(min_img_size);
- Rect::from_center_size(center, vec2(size, size))
- };
-
- let pfp_resp = ui.put(
- icon_rect,
- &mut ProfilePic::new(cache, jobs, get_profile_url(Some(profile)))
- .size(helper.scale_1d_pos(min_img_size)),
- );
-
- let name_font = FontId::new(
- helper.scale_1d_pos(body_font_size),
- NotedeckTextStyle::Body.font_family(),
- );
- let painter = ui.painter_at(helper.get_animation_rect());
- let name_galley = painter.layout(
- get_display_name(Some(profile)).name().to_owned(),
- name_font,
- ui.visuals().text_color(),
- width,
- );
-
- let galley_pos = {
- let right_top = pfp_resp.rect.right_top();
- let galley_pos_y = pfp_resp.rect.center().y - (name_galley.rect.height() / 2.0);
- Pos2::new(right_top.x + spacing, galley_pos_y)
- };
-
- painter.galley(galley_pos, name_galley, ui.visuals().text_color());
- ui.advance_cursor_after_rect(helper.get_animation_rect());
-
- pfp_resp.union(helper.take_animation_response())
- }
-}
diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs
@@ -25,7 +25,7 @@ use notedeck_ui::{
app_images,
context_menu::{input_context, PasteBehavior},
note::render_note_preview,
- NoteOptions, ProfilePic,
+ search_profiles, NoteOptions, ProfilePic,
};
use tracing::error;
#[cfg(not(target_os = "android"))]
@@ -278,20 +278,39 @@ impl<'a, 'd> PostView<'a, 'd> {
let mention_str = self.draft.buffer.get_mention_string(&mention);
if !mention_str.is_empty() {
- if let Some(mention_hint) = &mut self.draft.cur_mention_hint {
- if mention_hint.index != mention.index {
- mention_hint.index = mention.index;
- mention_hint.pos =
+ let text_changed;
+ if let Some(hint) = &mut self.draft.cur_mention_hint {
+ text_changed = hint.text != mention_str;
+ if hint.index != mention.index {
+ hint.index = mention.index;
+ hint.pos =
calculate_mention_hints_pos(textedit_output, mention.info.start_index);
}
- mention_hint.text = mention_str.to_owned();
+ if text_changed {
+ hint.text = mention_str.to_owned();
+ }
} else {
+ text_changed = true;
self.draft.cur_mention_hint = Some(MentionHint {
index: mention.index,
text: mention_str.to_owned(),
pos: calculate_mention_hints_pos(textedit_output, mention.info.start_index),
+ results: Vec::new(),
});
}
+
+ if text_changed {
+ let contacts = self
+ .note_context
+ .accounts
+ .get_selected_account()
+ .data
+ .contacts
+ .get_state();
+ let hint = self.draft.cur_mention_hint.as_mut().unwrap();
+ hint.results =
+ search_profiles(self.note_context.ndb, txn, &hint.text, contacts, 128);
+ }
}
let hint_rect = {
@@ -304,18 +323,15 @@ impl<'a, 'd> PostView<'a, 'd> {
hint_rect
};
- let res = self
- .note_context
- .ndb
- .search_profile(txn, mention_str, 10)
- .ok()?;
+ let hint = self.draft.cur_mention_hint.as_ref().unwrap();
let resp = MentionPickerView::new(
self.note_context.img_cache,
self.note_context.ndb,
txn,
- &res,
+ &hint.results,
self.note_context.jobs,
+ self.note_context.i18n,
)
.show_in_rect(hint_rect, ui);
@@ -328,14 +344,15 @@ impl<'a, 'd> PostView<'a, 'd> {
match out {
ui::mentions_picker::MentionPickerResponse::SelectResult(selection) => {
if let Some(hint_index) = selection {
- if let Some(pk) = res.get(hint_index) {
- let record = self.note_context.ndb.get_profile_by_pubkey(txn, pk);
+ let hint = self.draft.cur_mention_hint.as_ref().unwrap();
+ if let Some(result) = hint.results.get(hint_index) {
+ let record = self.note_context.ndb.get_profile_by_pubkey(txn, &result.pk);
if let Some(made_selection) =
self.draft.buffer.select_mention_and_replace_name(
mention.index,
get_display_name(record.ok().as_ref()).name(),
- Pubkey::new(**pk),
+ Pubkey::new(result.pk),
)
{
selection_made = Some(made_selection);
diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs
@@ -164,21 +164,27 @@ impl<'a, 'd> SearchView<'a, 'd> {
return;
};
- 's: {
- let Ok(results) = self
+ if self.query.last_mention_query != mention_name {
+ let contacts = self
.note_context
- .ndb
- .search_profile(self.txn, mention_name, 10)
- else {
- break 's;
- };
+ .accounts
+ .get_selected_account()
+ .data
+ .contacts
+ .get_state();
+ self.query.mention_results =
+ search_profiles(self.note_context.ndb, self.txn, mention_name, contacts, 128);
+ self.query.last_mention_query = mention_name.to_owned();
+ }
+ 's: {
let search_res = MentionPickerView::new(
self.note_context.img_cache,
self.note_context.ndb,
self.txn,
- &results,
+ &self.query.mention_results,
self.note_context.jobs,
+ self.note_context.i18n,
)
.show_in_rect(ui.available_rect_before_wrap(), ui);
@@ -188,20 +194,20 @@ impl<'a, 'd> SearchView<'a, 'd> {
*search_action = match res {
MentionPickerResponse::SelectResult(Some(index)) => {
- let Some(pk_bytes) = results.get(index) else {
+ let Some(result) = self.query.mention_results.get(index) else {
break 's;
};
let username = self
.note_context
.ndb
- .get_profile_by_pubkey(self.txn, pk_bytes)
+ .get_profile_by_pubkey(self.txn, &result.pk)
.ok()
.and_then(|p| p.record().profile().and_then(|p| p.name()))
.unwrap_or(&self.query.string);
Some(SearchAction::NewSearch {
- search_type: SearchType::Profile(Pubkey::new(**pk_bytes)),
+ search_type: SearchType::Profile(Pubkey::new(result.pk)),
new_search_text: format!("@{username}"),
})
}
diff --git a/crates/notedeck_columns/src/ui/search/state.rs b/crates/notedeck_columns/src/ui/search/state.rs
@@ -1,5 +1,6 @@
use crate::timeline::TimelineTab;
use enostr::Pubkey;
+use notedeck_ui::ProfileSearchResult;
use super::SearchType;
@@ -60,6 +61,12 @@ pub struct SearchQueryState {
/// Recent search history (most recent first, max 10)
pub recent_searches: Vec<RecentSearchItem>,
+
+ /// Cached @mention search results
+ pub mention_results: Vec<ProfileSearchResult>,
+
+ /// The query string that produced `mention_results`
+ pub last_mention_query: String,
}
impl Default for SearchQueryState {
@@ -78,6 +85,8 @@ impl SearchQueryState {
selected_index: -1,
user_results: Vec::new(),
recent_searches: Vec::new(),
+ mention_results: Vec::new(),
+ last_mention_query: String::new(),
}
}
diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs
@@ -214,6 +214,7 @@ where
}
/// A profile search result.
+#[derive(Debug)]
pub struct ProfileSearchResult {
/// The public key bytes of the matched profile.
pub pk: [u8; 32],