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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user