add global profile search

This commit is contained in:
2026-10-06 08:54:18 +07:00
parent 1f9cfd92cb
commit a28dcd620a
+181 -16
View File
@@ -6,7 +6,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Error; use anyhow::Error;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt; use common::{DebouncedDelay, TimestampExt};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
@@ -14,12 +14,13 @@ use gpui::{
Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
div, img, px, relative, retain_all, uniform_list, div, img, px, relative, retain_all, uniform_list,
}; };
use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_LIMIT, NostrRegistry, StateEvent}; use state::{FIND_DELAY, FIND_LIMIT, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; use ui::button::{Button, ButtonCustomVariant, ButtonVariants};
@@ -69,6 +70,10 @@ pub enum SidebarRow {
Contact { Contact {
public_key: PublicKey, public_key: PublicKey,
}, },
/// A profile found by global search.
Profile {
public_key: PublicKey,
},
Message { Message {
hit: MessageHit, hit: MessageHit,
}, },
@@ -216,10 +221,20 @@ pub struct Sidebar {
/// Whether the search bar is shown below the header. /// Whether the search bar is shown below the header.
search_open: bool, search_open: bool,
/// Whether searches also cover global (relay) profiles.
global_open: bool,
/// Debounced delay for the global search while typing.
find_debouncer: DebouncedDelay<Self>,
/// Search input state. /// Search input state.
find_input: Entity<InputState>, find_input: Entity<InputState>,
/// Search results: rooms and groups by name, then messages by content. /// Search results: rooms and groups by name, then messages by content.
results: Vec<SearchResult>, results: Vec<SearchResult>,
/// Whether a global (relay) search is in progress.
global_finding: bool,
/// Profiles found by the last global search, if one was triggered.
global_results: Option<Vec<PublicKey>>,
/// The async global profile search.
global_task: Option<Task<()>>,
/// Scroll state of the search results list. /// Scroll state of the search results list.
search_scroll: UniformListScrollHandle, search_scroll: UniformListScrollHandle,
@@ -235,7 +250,7 @@ impl Sidebar {
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
.placeholder("Search chats, groups and messages") .placeholder("Type to search")
.clean_on_escape() .clean_on_escape()
}); });
@@ -280,13 +295,28 @@ impl Sidebar {
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&find_input, &find_input,
window, window,
|this, state, event, _window, cx| match event { |this, state, event, window, cx| match event {
InputEvent::PressEnter { .. } => this.search(cx), InputEvent::PressEnter { .. } => {
this.search(cx);
if this.global_open {
this.global_search(cx);
}
}
InputEvent::Change => { InputEvent::Change => {
if state.read(cx).value().is_empty() { if state.read(cx).value().is_empty() {
this.clear_results(cx); this.clear_results(cx);
} else { } else {
this.search(cx); this.search(cx);
if this.global_open {
this.find_debouncer.fire_new(
Duration::from_millis(FIND_DELAY),
window,
cx,
|this, window, cx| this.debounced_global(window, cx),
);
}
} }
} }
_ => {} _ => {}
@@ -314,8 +344,13 @@ impl Sidebar {
requests_scroll: UniformListScrollHandle::new(), requests_scroll: UniformListScrollHandle::new(),
new_requests: false, new_requests: false,
search_open: false, search_open: false,
global_open: false,
find_debouncer: DebouncedDelay::new(),
find_input, find_input,
results: Vec::new(), results: Vec::new(),
global_finding: false,
global_results: None,
global_task: None,
search_scroll: UniformListScrollHandle::new(), search_scroll: UniformListScrollHandle::new(),
tasks: smallvec![], tasks: smallvec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
@@ -518,6 +553,7 @@ impl Sidebar {
self.find_input.update(cx, |input, cx| { self.find_input.update(cx, |input, cx| {
input.set_value("", window, cx); input.set_value("", window, cx);
}); });
self.global_open = false;
self.clear_results(cx); self.clear_results(cx);
} }
@@ -566,9 +602,81 @@ impl Sidebar {
cx.notify(); cx.notify();
} }
/// Search profiles on the indexer relays for the current query.
fn global_search(&mut self, cx: &mut Context<Self>) {
if !self.global_open {
return;
}
let query = self.find_input.read(cx).value().trim().to_string();
if query.is_empty() {
return;
}
let nostr = NostrRegistry::global(cx);
let find_profiles = nostr.read(cx).search(&query, cx);
self.global_finding = true;
self.global_results = None;
cx.notify();
self.global_task = Some(cx.spawn(async move |this, cx| {
let result = find_profiles.await;
this.update(cx, |this, cx| {
this.global_finding = false;
this.global_results = Some(match result {
Ok(profiles) => {
// Relays may return the same profile more than once
let mut seen = HashSet::new();
profiles
.into_iter()
.filter(|key| seen.insert(*key))
.collect()
}
Err(error) => {
log::debug!("Global search failed: {error}");
Vec::new()
}
});
cx.notify();
})
.ok();
}));
}
/// Trigger a global search once the input debounce delay has passed.
fn debounced_global(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, _window, cx| {
this.global_search(cx);
})
.ok();
})
}
/// Toggle whether searches also cover global (relay) profiles.
fn toggle_global(&mut self, cx: &mut Context<Self>) {
self.global_open = !self.global_open;
if self.global_open {
self.global_search(cx);
} else {
self.global_finding = false;
self.global_results = None;
self.global_task = None;
}
cx.notify();
}
/// Discard the search results. /// Discard the search results.
fn clear_results(&mut self, cx: &mut Context<Self>) { fn clear_results(&mut self, cx: &mut Context<Self>) {
self.results.clear(); self.results.clear();
self.global_finding = false;
self.global_results = None;
self.global_task = None;
cx.notify(); cx.notify();
} }
@@ -630,6 +738,7 @@ impl Sidebar {
Button::new("sidebar-search") Button::new("sidebar-search")
.icon(IconName::Search) .icon(IconName::Search)
.tooltip("Search") .tooltip("Search")
.selected(self.search_open)
.ghost() .ghost()
.small() .small()
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
@@ -707,20 +816,38 @@ impl Sidebar {
fn render_find(&self, cx: &mut Context<Self>) -> AnyElement { fn render_find(&self, cx: &mut Context<Self>) -> AnyElement {
h_flex() h_flex()
.px_2()
.flex_shrink_0() .flex_shrink_0()
.w_full() .w_full()
.px_2()
.child( .child(
Input::new(&self.find_input).small().text_xs().suffix( h_flex()
Button::new("sidebar-search-close") .px_1p5()
.icon(IconName::Close) .gap_1()
.tooltip("Close search") .w_full()
.transparent() .rounded(cx.theme().radius)
.small() .bg(cx.theme().elevated_surface_background)
.on_click(cx.listener(|this, _event, window, cx| { .child(
this.toggle_search(window, cx); Input::new(&self.find_input)
})), .appearance(false)
), .small()
.text_xs(),
)
.child(
Button::new("sidebar-global")
.icon(IconName::Compass)
.tooltip(if self.global_open {
"Disable global search"
} else {
"Enable global search"
})
.small()
.ghost()
.selected(self.global_open)
.loading(self.global_finding)
.on_click(cx.listener(|this, _event, _window, cx| {
this.toggle_global(cx);
})),
),
) )
.into_any_element() .into_any_element()
} }
@@ -789,6 +916,23 @@ impl Sidebar {
rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit }));
} }
// Global profiles only appear once a global search was triggered
if self.global_finding || self.global_results.is_some() {
rows.push(SidebarRow::Heading("Global"));
match self.global_results.as_ref() {
Some(profiles) if profiles.is_empty() => {
rows.push(SidebarRow::Empty("No profiles found"));
}
Some(profiles) => {
rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile {
public_key: *public_key,
}));
}
None => rows.push(SidebarRow::Empty("Searching...")),
}
}
rows rows
} }
@@ -1208,6 +1352,27 @@ impl Sidebar {
}) })
.into_any_element() .into_any_element()
} }
SidebarRow::Profile { public_key } => {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let public_key = public_key.to_owned();
Nav::new(index)
.label(person.name())
.text_sm()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(move |_event, window, cx| {
profile::open(public_key, window, cx);
})
.into_any_element()
}
SidebarRow::Group { group } => { SidebarRow::Group { group } => {
let dock = self.dock.clone(); let dock = self.dock.clone();
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();