diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index 8e766050..f9d86cbb 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -1,79 +1,306 @@ +use std::ops::Range; +use std::rc::Rc; + +use anyhow::Error; use chat::{ChatRegistry, Room, RoomKind}; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, WeakEntity, Window, div, px, + AnyElement, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, + Styled, Subscription, Task, WeakEntity, Window, div, px, uniform_list, }; use nostr_sdk::prelude::*; +use person::PersonRegistry; use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock::{DockArea, DockPlacement, PanelHandle}; use ui::input::{Input, InputEvent, InputState}; -use ui::{StyledExt, WindowExtension, v_flex}; +use ui::modal::ModalButtonProps; +use ui::nav::Nav; +use ui::{Disableable, IconName, Selectable, Sizable, WindowExtension, h_flex, v_flex}; + +use crate::sidebar::nav_avatar; + +/// Maximum number of members a chat can have. +const MAX_MEMBERS: usize = 3; pub fn open(dock: WeakEntity, window: &mut Window, cx: &mut App) { let view = cx.new(|cx| NewChat::new(dock, window, cx)); - window.open_modal(cx, move |this, _window, _cx| { - this.width(px(420.)).title("New chat").child(view.clone()) + window.open_modal(cx, { + let view = view.clone(); + move |this, _window, _cx| { + this.width(px(420.)) + .title("New chat") + .confirm() + .button_props( + ModalButtonProps::default() + .cancel_text("Cancel") + .ok_text("Start"), + ) + .on_ok({ + let view = view.clone(); + move |_, window, cx| view.update(cx, |this, cx| this.start_chat(window, cx)) + }) + .child(view.clone()) + } }); } pub struct NewChat { - /// The dock a started chat opens in dock: WeakEntity, - - /// Public key input + /// Find input, used to filter contacts or enter a new public key input: Entity, - - /// Error message + /// Current user's contacts + contacts: Option>, + /// Public keys selected as members of the chat + members: Vec, + /// Whether a NIP-05 address is being resolved + resolving: bool, error: Option, - - /// Input subscription + tasks: Vec>>, _subscription: Option, } impl NewChat { fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { - let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub")); + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder("Search or enter npub or user@address.me") + }); let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { - this.start_chat(window, cx); + this.submit(window, cx) } }); + // Load the contacts once the view is mounted + cx.defer_in(window, |this, window, cx| { + this.load_contacts(window, cx); + }); + Self { dock, input, + contacts: None, + members: Vec::new(), + resolving: false, error: None, + tasks: Vec::new(), _subscription: Some(subscription), } } - fn start_chat(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.input.read(cx).value().to_string(); + /// Load the signed-in user's contact list. + fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { + if self.contacts.is_some() { + return; + } - let Ok(peer) = PublicKey::parse(&value) else { - self.set_error("Public key is invalid", cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(current_user) = nostr.read(cx).current_user() else { return; }; + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(current_user) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.contacts = Some(contacts); + cx.notify(); + })?; + } + Err(error) => { + this.update(cx, |this, cx| { + this.set_error(error.to_string(), cx); + })?; + } + } + + Ok(()) + })); + } + + /// The contacts matching the current input, or every contact when empty. + fn filtered_contacts(&self, cx: &Context) -> Vec { + let Some(contacts) = self.contacts.as_ref() else { + return Vec::new(); + }; + + let persons = PersonRegistry::global(cx); + let query = self.input.read(cx).value().trim().to_lowercase(); + + if query.is_empty() { + return contacts.clone(); + } + + contacts + .iter() + .filter(|public_key| { + if public_key.to_hex().to_lowercase().contains(&query) { + return true; + } + let name = persons.read(cx).get(public_key, cx).name(); + name.to_lowercase().contains(&query) + }) + .cloned() + .collect() + } + + /// Resolve the input into a public key and add it as a member. + fn submit(&mut self, window: &mut Window, cx: &mut Context) { + if self.resolving { + return; + } + + let query = self.input.read(cx).value().trim().to_string(); + + if query.is_empty() { + return; + } + + // A raw public key or an npub can be added directly. + if let Ok(public_key) = PublicKey::parse(&query) { + if self.try_add(public_key, cx) { + self.clear_input(window, cx); + } + return; + } + + // A NIP-05 address is resolved before being added. + if let Ok(address) = Nip05Address::parse(&query) { + let nostr = NostrRegistry::global(cx); + let task = nostr.read(cx).query_address(address, cx); + + self.set_resolving(true, window, cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = task.await; + + this.update_in(cx, |this, window, cx| { + this.set_resolving(false, window, cx); + + match result { + Ok(public_key) => { + if this.try_add(public_key, cx) { + this.clear_input(window, cx); + } + } + Err(error) => this.set_error(error.to_string(), cx), + } + })?; + + Ok(()) + })); + return; + } + + // Fall back to a local search by contact name. + let mut matches = self.filtered_contacts(cx); + match matches.len() { + 0 => self.set_error("Enter a valid npub or NIP-05 address", cx), + 1 => { + let public_key = matches.remove(0); + if self.try_add(public_key, cx) { + self.clear_input(window, cx); + } + } + _ => self.set_error("Multiple contacts match, pick one from the list", cx), + } + } + + /// Add a public key to the members if possible, otherwise show an error. + fn try_add(&mut self, public_key: PublicKey, cx: &mut Context) -> bool { + let nostr = NostrRegistry::global(cx); + + let Some(current_user) = nostr.read(cx).current_user() else { + self.set_error("You are not signed in", cx); + return false; + }; + + if public_key == current_user { + self.set_error("You cannot add yourself", cx); + return false; + } + + if self.members.contains(&public_key) { + self.set_error("This person is already added", cx); + return false; + } + + if self.members.len() >= MAX_MEMBERS { + self.set_error(format!("A chat can have up to {MAX_MEMBERS} members"), cx); + return false; + } + + self.members.push(public_key); + self.error = None; + cx.notify(); + + true + } + + /// Toggle a contact's membership. + fn toggle_member(&mut self, public_key: &PublicKey, cx: &mut Context) { + if self.members.iter().any(|member| member == public_key) { + self.members.retain(|member| member != public_key); + self.error = None; + cx.notify(); + } else { + self.try_add(public_key.to_owned(), cx); + } + } + + fn clear_input(&mut self, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + } + + /// Toggle the input's loading spinner while a NIP-05 address is resolved. + fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context) { + self.input.update(cx, |this, cx| { + this.set_loading(resolving, window, cx); + }); + self.resolving = resolving; + cx.notify(); + } + + fn start_chat(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.members.is_empty() { + self.set_error("Add at least one person to chat with", cx); + return false; + } + let nostr = NostrRegistry::global(cx); let chat = ChatRegistry::global(cx); let Some(current_user) = nostr.read(cx).current_user() else { self.set_error("You are not signed in", cx); - return; + return false; }; - if peer == current_user { - self.set_error("You cannot chat with yourself", cx); - return; - } - - let room = Room::new(current_user, [peer]) + let room = Room::new(current_user, self.members.clone()) .organize(¤t_user) .kind(RoomKind::Ongoing); @@ -91,43 +318,105 @@ impl NewChat { cx, ); - window.close_modal(cx); + true } fn set_error(&mut self, message: impl Into, cx: &mut Context) { self.error = Some(message.into()); cx.notify(); } + + fn render_contacts( + &self, + range: Range, + contacts: &[PublicKey], + cx: &mut Context, + ) -> Vec { + let persons = PersonRegistry::global(cx); + let start = range.start; + + contacts + .get(range) + .into_iter() + .flatten() + .enumerate() + .map(|(offset, public_key)| { + let selected = self.members.contains(public_key); + let profile = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + + Nav::new(SharedString::from(format!("contact-{}", start + offset))) + .label(profile.name()) + .text_sm() + .when_some( + nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx), + |this, avatar| this.prefix(avatar), + ) + .selected(selected) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_member(&public_key, cx); + })) + .into_any_element() + }) + .collect() + } } impl Render for NewChat { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let contacts = Rc::new(self.filtered_contacts(cx)); + let contact_count = contacts.len(); + let notice = SharedString::from(format!("Group Chat can have up to {MAX_MEMBERS} members")); + v_flex() - .gap_4() + .gap_3() .child( v_flex() - .gap_1() - .text_color(cx.theme().text_muted) - .child("Public key of the person you want to chat with") - .child(Input::new(&self.input)), + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.input).flex_1().text_sm().cleanable(true)) + .child( + Button::new("add") + .icon(IconName::Plus) + .tooltip("Add person") + .ghost() + .large() + .disabled(self.resolving) + .on_click(cx.listener(|this, _event, window, cx| { + this.submit(window, cx); + })), + ), + ) + .map(|this| { + if let Some(error) = self.error.clone() { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().text_danger) + .child(error), + ) + } else { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(notice), + ) + } + }), ) .child( - Button::new("start-chat") - .label("Start chat") - .primary() - .font_semibold() - .on_click(cx.listener(|this, _event, window, cx| { - this.start_chat(window, cx); - })), - ) - .when_some(self.error.clone(), |this, error| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().text_danger) - .child(error), + uniform_list( + "contacts", + contact_count, + cx.processor(move |this, range, _window, cx| { + this.render_contacts(range, contacts.as_slice(), cx) + }), ) - }) + .h_80(), + ) } }