notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_columns/src/draft.rs | 2++
Mcrates/notedeck_columns/src/ui/mentions_picker.rs | 110++++++++++++++++---------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/note/post.rs | 47++++++++++++++++++++++++++++++++---------------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 28+++++++++++++++++-----------
Mcrates/notedeck_columns/src/ui/search/state.rs | 9+++++++++
Mcrates/notedeck_ui/src/contacts_list.rs | 1+
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],