feat: improve search and handle input in compose (#67)

* feat: support search by npub or nprofile

* .

* .

* .

* chore: prevent update local search with empty result

* clean up

* .
This commit is contained in:
reya
2025-06-25 15:03:05 +07:00
committed by GitHub
parent c7e3331eb0
commit edee9305cc
7 changed files with 550 additions and 314 deletions

View File

@@ -144,6 +144,15 @@ impl ChatRegistry {
.collect()
}
/// Search rooms by public keys.
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.cloned()
.collect()
}
/// Load all rooms from the lmdb.
///
/// This method:

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use futures::channel::oneshot;
use futures::FutureExt;
use gpui::{Context, Task};
use gpui::{Context, Task, Window};
pub struct DebouncedDelay<E: 'static> {
task: Option<Task<()>>,
@@ -26,9 +26,14 @@ impl<E: 'static> DebouncedDelay<E> {
}
}
pub fn fire_new<F>(&mut self, delay: Duration, cx: &mut Context<E>, func: F)
where
F: 'static + Send + FnOnce(&mut E, &mut Context<E>) -> Task<()>,
pub fn fire_new<F>(
&mut self,
delay: Duration,
window: &mut Window,
cx: &mut Context<E>,
func: F,
) where
F: 'static + Send + FnOnce(&mut E, &mut Window, &mut Context<E>) -> Task<()>,
{
if let Some(channel) = self.cancel_channel.take() {
_ = channel.send(());
@@ -38,7 +43,8 @@ impl<E: 'static> DebouncedDelay<E> {
self.cancel_channel = Some(sender);
let previous_task = self.task.take();
self.task = Some(cx.spawn(async move |entity, cx| {
self.task = Some(cx.spawn_in(window, async move |entity, cx| {
let mut timer = cx.background_executor().timer(delay).fuse();
if let Some(previous_task) = previous_task {
@@ -50,7 +56,9 @@ impl<E: 'static> DebouncedDelay<E> {
_ = timer => {}
}
if let Ok(task) = entity.update(cx, |project, cx| (func)(project, cx)) {
if let Ok(Ok(task)) =
cx.update(|window, cx| entity.update(cx, |project, cx| (func)(project, window, cx)))
{
task.await;
}
}));

View File

@@ -1,17 +1,18 @@
use std::collections::{BTreeSet, HashSet};
use std::ops::Range;
use std::time::Duration;
use anyhow::Error;
use chats::room::Room;
use anyhow::{anyhow, Error};
use chats::room::{Room, RoomKind};
use chats::ChatRegistry;
use common::profile::RenderProfile;
use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, impl_internal_actions, px, red, relative, uniform_list, App, AppContext, Context,
Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use serde::Deserialize;
use settings::AppSettings;
@@ -20,6 +21,7 @@ use smol::Timer;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
@@ -31,82 +33,117 @@ struct SelectContact(PublicKey);
impl_internal_actions!(contacts, [SelectContact]);
#[derive(Debug, Clone)]
struct Contact {
profile: Profile,
select: bool,
}
impl AsRef<Profile> for Contact {
fn as_ref(&self) -> &Profile {
&self.profile
}
}
impl Contact {
pub fn new(profile: Profile) -> Self {
Self {
profile,
select: false,
}
}
pub fn select(mut self) -> Self {
self.select = true;
self
}
}
pub struct Compose {
/// Input for the room's subject
title_input: Entity<InputState>,
/// Input for the room's members
user_input: Entity<InputState>,
contacts: Entity<Vec<Profile>>,
selected: Entity<HashSet<PublicKey>>,
focus_handle: FocusHandle,
is_loading: bool,
is_submitting: bool,
/// The current user's contacts
contacts: Vec<Entity<Contact>>,
/// Input error message
error_message: Entity<Option<SharedString>>,
adding: bool,
submitting: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Compose {
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
let contacts = cx.new(|_| Vec::new());
let selected = cx.new(|_| HashSet::new());
let error_message = cx.new(|_| None);
let user_input =
cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile..."));
let user_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1..."));
let title_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
let error_message = cx.new(|_| None);
let mut subscriptions = smallvec![];
// Handle Enter event for user input
subscriptions.push(cx.subscribe_in(
&user_input,
window,
move |this, _, input_event, window, cx| {
if let InputEvent::PressEnter { .. } = input_event {
this.add(window, cx);
}
move |this, _input, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => this.add_and_select_contact(window, cx),
InputEvent::Change(_) => {}
_ => {}
};
},
));
cx.spawn(async move |this, cx| {
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
let client = shared_state().client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let profiles = client.database().contacts(public_key).await?;
let get_contacts: Task<Result<Vec<Contact>, Error>> = cx.background_spawn(async move {
let client = shared_state().client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let profiles = client.database().contacts(public_key).await?;
let contacts = profiles.into_iter().map(Contact::new).collect_vec();
Ok(profiles)
});
Ok(contacts)
});
if let Ok(contacts) = task.await {
cx.update(|cx| {
cx.spawn_in(window, async move |this, cx| {
match get_contacts.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| {
this.extend(contacts);
cx.notify();
});
this.contacts(contacts, cx);
})
.ok()
})
.ok();
}
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Contacts"),
cx,
);
})
.ok();
}
};
})
.detach();
Self {
adding: false,
submitting: false,
contacts: vec![],
title_input,
user_input,
contacts,
selected,
error_message,
subscriptions,
is_loading: false,
is_submitting: false,
focus_handle: cx.focus_handle(),
}
}
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.selected.read(cx).is_empty() {
let public_keys: Vec<PublicKey> = self.selected(cx);
if public_keys.is_empty() {
self.set_error(Some("You need to add at least 1 receiver".into()), cx);
return;
}
@@ -114,11 +151,8 @@ impl Compose {
// Show loading spinner
self.set_submitting(true, cx);
// Get all pubkeys
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
// Convert selected pubkeys into Nostr tags
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
let mut tag_list: Vec<Tag> = public_keys.iter().map(|pk| Tag::public_key(*pk)).collect();
// Add subject if it is present
if !self.title_input.read(cx).value().is_empty() {
@@ -128,31 +162,30 @@ impl Compose {
));
}
let tags = Tags::from_list(tag_list);
let event: Task<Result<Event, anyhow::Error>> = cx.background_spawn(async move {
let event: Task<Result<Room, anyhow::Error>> = cx.background_spawn(async move {
let signer = shared_state().client().signer().await?;
let public_key = signer.get_public_key().await?;
// [IMPORTANT]
// Make sure this event is never send,
// this event existed just use for convert to Coop's Room later.
let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
.tags(tags)
let room = EventBuilder::private_msg_rumor(public_keys[0], "")
.tags(Tags::from_list(tag_list))
.build(public_key)
.sign(&Keys::generate())
.await?;
.await
.map(|event| Room::new(&event).kind(RoomKind::Ongoing))?;
Ok(event)
Ok(room)
});
cx.spawn_in(window, async move |this, cx| match event.await {
Ok(event) => {
Ok(room) => {
cx.update(|window, cx| {
let room = cx.new(|_| Room::new(&event).kind(chats::room::RoomKind::Ongoing));
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
ChatRegistry::global(cx).update(cx, |this, cx| {
this.push_room(room, cx);
this.push_room(cx.new(|_| room), cx);
});
window.close_modal(cx);
@@ -169,28 +202,77 @@ impl Compose {
.detach();
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn contacts(&mut self, contacts: impl IntoIterator<Item = Contact>, cx: &mut Context<Self>) {
self.contacts
.extend(contacts.into_iter().map(|contact| cx.new(|_| contact)));
cx.notify();
}
fn push_contact(&mut self, contact: Contact, cx: &mut Context<Self>) {
if !self
.contacts
.iter()
.any(|e| e.read(cx).profile.public_key() == contact.profile.public_key())
{
self.contacts.insert(0, cx.new(|_| contact));
cx.notify();
}
}
fn selected(&self, cx: &Context<Self>) -> Vec<PublicKey> {
self.contacts
.iter()
.filter_map(|contact| {
if contact.read(cx).select {
Some(contact.read(cx).profile.public_key())
} else {
None
}
})
.collect()
}
fn add_and_select_contact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let content = self.user_input.read(cx).value().to_string();
// Show loading spinner
self.set_loading(true, cx);
// Prevent multiple requests
self.set_adding(true, cx);
let task: Task<Result<Profile, anyhow::Error>> = if content.contains("@") {
// Show loading indicator in the input
self.user_input.update(cx, |this, cx| {
this.set_loading(true, cx);
});
let task: Task<Result<Contact, anyhow::Error>> = if content.contains("@") {
cx.background_spawn(async move {
let profile = nip05::profile(&content, None).await?;
let public_key = profile.public_key;
let (tx, rx) = oneshot::channel::<Nip05Profile>();
let metadata = shared_state()
.client()
.fetch_metadata(public_key, Duration::from_secs(2))
.await?
.unwrap_or_default();
nostr_sdk::async_utility::task::spawn(async move {
if let Ok(profile) = nip05::profile(&content, None).await {
tx.send(profile).ok();
}
});
Ok(Profile::new(public_key, metadata))
if let Ok(profile) = rx.await {
let public_key = profile.public_key;
let metadata = shared_state()
.client()
.fetch_metadata(public_key, Duration::from_secs(2))
.await?
.unwrap_or_default();
let profile = Profile::new(public_key, metadata);
let contact = Contact::new(profile).select();
Ok(contact)
} else {
Err(anyhow!("Profile not found"))
}
})
} else {
let Ok(public_key) = PublicKey::parse(&content) else {
self.set_loading(false, cx);
} else if content.starts_with("nprofile1") {
let Some(public_key) = Nip19Profile::from_bech32(&content)
.map(|nip19| nip19.public_key)
.ok()
else {
self.set_error(Some("Public Key is not valid".into()), cx);
return;
};
@@ -202,55 +284,67 @@ impl Compose {
.await?
.unwrap_or_default();
Ok(Profile::new(public_key, metadata))
let profile = Profile::new(public_key, metadata);
let contact = Contact::new(profile).select();
Ok(contact)
})
} else {
let Ok(public_key) = PublicKey::parse(&content) else {
self.set_error(Some("Public Key is not valid".into()), cx);
return;
};
cx.background_spawn(async move {
let metadata = shared_state()
.client()
.fetch_metadata(public_key, Duration::from_secs(2))
.await?
.unwrap_or_default();
let profile = Profile::new(public_key, metadata);
let contact = Contact::new(profile).select();
Ok(contact)
})
};
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(profile) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
let public_key = profile.public_key();
this.contacts.update(cx, |this, cx| {
this.insert(0, profile);
cx.notify();
});
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Stop loading indicator
cx.spawn_in(window, async move |this, cx| match task.await {
Ok(contact) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.push_contact(contact, cx);
this.set_adding(false, cx);
this.user_input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
})
.ok();
});
})
.ok();
}
Err(e) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_error(Some(e.to_string().into()), cx);
})
.ok();
})
.ok();
}
})
.ok();
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
})
.ok();
}
})
.detach();
}
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
if self.adding {
self.set_adding(false, cx);
}
// Unlock the user input
self.user_input.update(cx, |this, cx| {
this.set_loading(false, cx);
});
// Update error message
self.error_message.update(cx, |this, cx| {
*this = error;
cx.notify();
@@ -259,42 +353,72 @@ impl Compose {
// Dismiss error after 2 seconds
cx.spawn(async move |this, cx| {
Timer::after(Duration::from_secs(2)).await;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.set_error(None, cx);
})
.ok();
this.update(cx, |this, cx| {
this.set_error(None, cx);
})
.ok();
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
fn set_adding(&mut self, status: bool, cx: &mut Context<Self>) {
self.adding = status;
cx.notify();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
self.submitting = status;
cx.notify();
}
fn on_action_select(
&mut self,
action: &SelectContact,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.selected.update(cx, |this, cx| {
if this.contains(&action.0) {
this.remove(&action.0);
} else {
this.insert(action.0);
fn list_items(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let mut items = Vec::with_capacity(self.contacts.len());
for ix in range {
let Some(entity) = self.contacts.get(ix).cloned() else {
continue;
};
cx.notify();
});
let profile = entity.read(cx).as_ref();
let selected = entity.read(cx).select;
items.push(
div()
.id(ix)
.w_full()
.h_10()
.px_3()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex()
.items_center()
.gap_3()
.text_sm()
.child(img(profile.render_avatar(proxy)).size_7().flex_shrink_0())
.child(profile.render_name()),
)
.when(selected, |this| {
this.child(
Icon::new(IconName::CheckCircleFill)
.small()
.text_color(cx.theme().ring),
)
})
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(cx.listener(move |_this, _event, _window, cx| {
entity.update(cx, |this, cx| {
this.select = !this.select;
cx.notify();
});
})),
);
}
items
}
}
@@ -303,17 +427,13 @@ impl Render for Compose {
const DESCRIPTION: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let label: SharedString = if self.selected.read(cx).len() > 1 {
let label: SharedString = if self.contacts.len() > 1 {
"Create Group DM".into()
} else {
"Create DM".into()
};
div()
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_action_select))
.flex()
.flex_col()
.gap_1()
@@ -353,12 +473,30 @@ impl Render for Compose {
.flex_col()
.gap_2()
.child(div().text_sm().font_semibold().child("To:"))
.child(TextInput::new(&self.user_input).small()),
.child(
div()
.flex()
.items_center()
.gap_1()
.child(
TextInput::new(&self.user_input)
.small()
.disabled(self.adding),
)
.child(
Button::new("add")
.icon(IconName::PlusCircleFill)
.small()
.ghost()
.disabled(self.adding)
.on_click(cx.listener(move |this, _, window, cx| {
this.add_and_select_contact(window, cx);
})),
),
),
)
.map(|this| {
let contacts = self.contacts.read(cx).clone();
if contacts.is_empty() {
if self.contacts.is_empty() {
this.child(
div()
.w_full()
@@ -386,62 +524,9 @@ impl Render for Compose {
this.child(
uniform_list(
"contacts",
contacts.len(),
self.contacts.len(),
cx.processor(move |this, range, _window, cx| {
let selected = this.selected.read(cx);
let mut items = Vec::new();
for ix in range {
let profile: &Profile = contacts.get(ix).unwrap();
let item = profile.clone();
let is_select = selected.contains(&item.public_key());
items.push(
div()
.id(ix)
.w_full()
.h_10()
.px_3()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex()
.items_center()
.gap_3()
.text_sm()
.child(
img(item.render_avatar(proxy))
.size_7()
.flex_shrink_0(),
)
.child(item.render_name()),
)
.when(is_select, |this| {
this.child(
Icon::new(IconName::CheckCircleFill)
.small()
.text_color(cx.theme().icon_accent),
)
})
.hover(|this| {
this.bg(cx
.theme()
.elevated_surface_background)
})
.on_click(move |_, window, cx| {
window.dispatch_action(
Box::new(SelectContact(
item.public_key(),
)),
cx,
);
}),
);
}
items
this.list_items(range, cx)
}),
)
.pb_4()
@@ -456,9 +541,11 @@ impl Render for Compose {
.label(label)
.primary()
.w_full()
.loading(self.is_submitting)
.disabled(self.is_submitting)
.on_click(cx.listener(|this, _, window, cx| this.compose(window, cx))),
.loading(self.submitting)
.disabled(self.submitting || self.adding)
.on_click(cx.listener(move |this, _event, window, cx| {
this.compose(window, cx);
})),
),
)
}

View File

@@ -2,6 +2,7 @@ use std::collections::BTreeSet;
use std::ops::Range;
use std::time::Duration;
use anyhow::Error;
use chats::room::{Room, RoomKind};
use chats::{ChatRegistry, RoomEmitter};
use common::debounced_delay::DebouncedDelay;
@@ -27,6 +28,7 @@ use ui::button::{Button, ButtonRounded, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput};
use ui::notification::Notification;
use ui::popup_menu::PopupMenu;
use ui::skeleton::Skeleton;
use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt};
@@ -95,9 +97,9 @@ impl Sidebar {
subscriptions.push(cx.subscribe_in(
&find_input,
window,
|this, _state, event, _window, cx| {
|this, _state, event, window, cx| {
match event {
InputEvent::PressEnter { .. } => this.search(cx),
InputEvent::PressEnter { .. } => this.search(window, cx),
InputEvent::Change(text) => {
// Clear the result when input is empty
if text.is_empty() {
@@ -106,8 +108,9 @@ impl Sidebar {
// Run debounced search
this.find_debouncer.fire_new(
Duration::from_millis(FIND_DELAY),
window,
cx,
|this, cx| this.debounced_search(cx),
|this, window, cx| this.debounced_search(window, cx),
);
}
}
@@ -132,19 +135,23 @@ impl Sidebar {
}
}
fn debounced_search(&self, cx: &mut Context<Self>) -> Task<()> {
cx.spawn(async move |this, cx| {
this.update(cx, |this, cx| {
this.search(cx);
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.search(window, cx);
})
.ok();
})
.ok();
})
}
fn nip50_search(&self, cx: &App) -> Task<Result<BTreeSet<Room>, Error>> {
let query = self.find_input.read(cx).value().clone();
fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let query = query.to_owned();
let query_cloned = query.clone();
cx.background_spawn(async move {
let task: Task<Result<BTreeSet<Room>, Error>> = cx.background_spawn(async move {
let client = shared_state().client();
let filter = Filter::new()
@@ -153,137 +160,251 @@ impl Sidebar {
.limit(FIND_LIMIT);
let events = client
.fetch_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
.fetch_events_from(SEARCH_RELAYS, filter, Duration::from_secs(5))
.await?
.into_iter()
.unique_by(|event| event.pubkey)
.collect_vec();
let mut rooms = BTreeSet::new();
let (tx, rx) = smol::channel::bounded::<Room>(10);
nostr_sdk::async_utility::task::spawn(async move {
let signer = client.signer().await.expect("signer is required");
let public_key = signer.get_public_key().await.expect("error");
// Process to verify the search results
if !events.is_empty() {
let (tx, rx) = smol::channel::bounded::<Room>(events.len());
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
nostr_sdk::async_utility::task::spawn(async move {
let signer = client.signer().await.unwrap();
let public_key = signer.get_public_key().await.unwrap();
let Some(target) = metadata.nip05.as_ref() else {
continue;
};
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
let Ok(verified) = nip05::verify(&event.pubkey, target, None).await else {
continue;
};
let Some(target) = metadata.nip05.as_ref() else {
// Skip if NIP-05 is not found
continue;
};
if !verified {
continue;
};
let Ok(verified) = nip05::verify(&event.pubkey, target, None).await else {
// Skip if NIP-05 verification fails
continue;
};
if let Ok(event) = EventBuilder::private_msg_rumor(event.pubkey, "")
.build(public_key)
.sign(&Keys::generate())
.await
{
if let Err(e) = tx.send(Room::new(&event).kind(RoomKind::Ongoing)).await {
log::error!("{e}")
if !verified {
// Skip if NIP-05 is not valid
continue;
};
if let Ok(event) = EventBuilder::private_msg_rumor(event.pubkey, "")
.build(public_key)
.sign(&Keys::generate())
.await
{
if let Err(e) = tx.send(Room::new(&event).kind(RoomKind::Ongoing)).await
{
log::error!("Send error: {e}")
}
}
}
}
});
});
while let Ok(room) = rx.recv().await {
rooms.insert(room);
while let Ok(room) = rx.recv().await {
rooms.insert(room);
}
}
Ok(rooms)
})
}
fn search(&mut self, cx: &mut Context<Self>) {
let query = self.find_input.read(cx).value();
let result = ChatRegistry::get_global(cx).search(query.as_ref(), cx);
// Return if query is empty
if query.is_empty() {
return;
}
if query.starts_with("nevent1")
|| query.starts_with("naddr")
|| query.starts_with("nsec1")
|| query.starts_with("note1")
{
return;
}
// Return if search is in progress
if self.finding {
return;
}
// Block the UI until the search process completes
self.set_finding(true, cx);
// Disable the search input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(true, cx);
this.set_loading(true, cx);
});
if !result.is_empty() {
self.set_finding(false, cx);
self.find_input.update(cx, |this, cx| {
this.set_disabled(false, cx);
this.set_loading(false, cx);
});
self.local_result.update(cx, |this, cx| {
*this = Some(result);
cx.notify();
});
} else {
let task = self.nip50_search(cx);
cx.spawn(async move |this, cx| {
if let Ok(result) = task.await {
this.update(cx, |this, cx| {
let result = result
.into_iter()
.map(|room| cx.new(|_| room))
.collect_vec();
this.set_finding(false, cx);
this.find_input.update(cx, |this, cx| {
this.set_disabled(false, cx);
this.set_loading(false, cx);
});
this.global_result.update(cx, |this, cx| {
*this = Some(result);
cx.notify();
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(result) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
if result.is_empty() {
let msg =
format!("There are no users matching query {}", query_cloned);
window.push_notification(Notification::info(msg), cx);
this.set_finding(false, cx);
} else {
let result = result
.into_iter()
.map(|room| cx.new(|_| room))
.collect_vec();
this.global_result(result, cx);
}
})
.ok();
})
.ok();
}
})
.detach();
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Search Error"),
cx,
);
})
.ok();
}
};
})
.detach();
}
fn search_by_user(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
let public_key = if query.starts_with("npub1") {
PublicKey::parse(query).ok()
} else if query.starts_with("nprofile1") {
Nip19Profile::from_bech32(query)
.map(|nip19| nip19.public_key)
.ok()
} else {
None
};
let Some(public_key) = public_key else {
window.push_notification("Public Key is not valid", cx);
self.set_finding(false, cx);
return;
};
let task: Task<Result<(Profile, Room), Error>> = cx.background_spawn(async move {
let client = shared_state().client();
let signer = client.signer().await.unwrap();
let user_pubkey = signer.get_public_key().await.unwrap();
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(3))
.await?
.unwrap_or_default();
let event = EventBuilder::private_msg_rumor(public_key, "")
.build(user_pubkey)
.sign(&Keys::generate())
.await?;
let profile = Profile::new(public_key, metadata);
let room = Room::new(&event);
Ok((profile, room))
});
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok((profile, room)) => {
this.update(cx, |this, cx| {
let chats = ChatRegistry::global(cx);
let result = chats
.read(cx)
.search_by_public_key(profile.public_key(), cx);
if !result.is_empty() {
this.local_result(result, cx);
}
this.global_result(vec![cx.new(|_| room)], cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Search Error"),
cx,
);
})
.ok();
}
};
})
.detach();
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let query = self.find_input.read(cx).value().to_string();
// Return if search is in progress
if self.finding {
window.push_notification("There is another search in progress", cx);
return;
}
// Return if the query is empty
if query.is_empty() {
window.push_notification("Cannot search with an empty query", cx);
return;
}
// Return if the query starts with "nsec1" or "note1"
if query.starts_with("nsec1") || query.starts_with("note1") {
window.push_notification("Coop does not support searching with this query", cx);
return;
}
// Block the input until the search process completes
self.set_finding(true, cx);
// Process to search by user if query starts with npub or nprofile
if query.starts_with("npub1") || query.starts_with("nprofile1") {
self.search_by_user(&query, window, cx);
return;
};
let chats = ChatRegistry::global(cx);
let result = chats.read(cx).search(&query, cx);
if result.is_empty() {
// There are no current rooms matching this query, so proceed with global search via NIP-50
self.search_by_nip50(&query, window, cx);
} else {
self.local_result(result, cx);
}
}
fn global_result(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
if self.finding {
self.set_finding(false, cx);
}
self.global_result.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
fn local_result(&mut self, rooms: Vec<Entity<Room>>, cx: &mut Context<Self>) {
if self.finding {
self.set_finding(false, cx);
}
self.local_result.update(cx, |this, cx| {
*this = Some(rooms);
cx.notify();
});
}
fn set_finding(&mut self, status: bool, cx: &mut Context<Self>) {
self.finding = status;
cx.notify();
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(status, cx);
this.set_loading(status, cx);
});
}
fn clear_search_results(&mut self, cx: &mut Context<Self>) {
// Reset the input state
if self.finding {
self.set_finding(false, cx);
}
// Clear all local results
self.local_result.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all global results
self.global_result.update(cx, |this, cx| {
*this = None;
cx.notify();
@@ -516,6 +637,7 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let chats = ChatRegistry::get_global(cx);
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx) {
results.to_owned()
@@ -552,11 +674,11 @@ impl Render for Sidebar {
),
)
// Global Search Results
.when_some(self.global_result.read(cx).clone(), |this, rooms| {
.when_some(self.global_result.read(cx).as_ref(), |this, rooms| {
this.child(div().px_2().w_full().flex().flex_col().gap_1().children({
let mut items = Vec::with_capacity(rooms.len());
for (ix, room) in rooms.into_iter().enumerate() {
for (ix, room) in rooms.iter().enumerate() {
let this = room.read(cx);
let id = this.id;
let label = this.display_name(cx);

View File

@@ -11,7 +11,7 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://relaydiscovery.com",
"wss://purplepag.es",
];
/// NIP65 Relays. Used for new account
@@ -26,7 +26,7 @@ pub const NIP65_RELAYS: [&str; 4] = [
pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"];
/// Search Relays.
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"];
/// Default relay for Nostr Connect
pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app";

View File

@@ -302,6 +302,12 @@ impl Globals {
})
}
pub async fn request_metadata(&self, public_key: PublicKey) {
if let Err(e) = self.batch_sender.send(public_key).await {
log::error!("Failed to request metadata: {e}")
}
}
/// Gets a person's profile from cache or creates default (blocking)
pub fn person(&self, public_key: &PublicKey) -> Profile {
let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) {

View File

@@ -257,6 +257,7 @@ impl RenderOnce for TitleBar {
const HEIGHT: Pixels = px(34.);
let is_linux = cfg!(target_os = "linux");
let is_macos = cfg!(target_os = "macos");
div().flex_shrink_0().child(
self.base
@@ -270,6 +271,9 @@ impl RenderOnce for TitleBar {
.when(is_linux, |this| {
this.on_double_click(|_, window, _| window.zoom_window())
})
.when(is_macos, |this| {
this.on_double_click(|_, window, _| window.titlebar_double_click())
})
.child(
h_flex()
.id("bar")