use chats::{registry::ChatRegistry, room::Room}; use common::{ profile::NostrProfile, utils::{random_name, signer_public_key}, }; use gpui::{ div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, TextAlign, Window, }; use nostr_sdk::prelude::*; use serde::Deserialize; use smol::Timer; use state::get_client; use std::{collections::HashSet, time::Duration}; use tokio::sync::oneshot; use ui::{ button::{Button, ButtonRounded}, input::{InputEvent, TextInput}, theme::{scale::ColorScaleStep, ActiveTheme}, ContextModal, Icon, IconName, Sizable, Size, StyledExt, }; const ALERT: &str = "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; #[derive(Clone, PartialEq, Eq, Deserialize)] struct SelectContact(PublicKey); impl_internal_actions!(contacts, [SelectContact]); pub struct Compose { title_input: Entity, user_input: Entity, contacts: Entity>, selected: Entity>, focus_handle: FocusHandle, is_loading: bool, is_submitting: bool, error_message: Entity>, #[allow(dead_code)] subscriptions: Vec, } 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 mut subscriptions = Vec::new(); let title_input = cx.new(|cx| { let name = random_name(2); let mut input = TextInput::new(window, cx) .appearance(false) .text_size(Size::XSmall); input.set_placeholder("Family... . (Optional)"); input.set_text(name, window, cx); input }); let user_input = cx.new(|cx| { TextInput::new(window, cx) .text_size(ui::Size::Small) .small() .placeholder("npub1...") }); // 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); } }, )); let client = get_client(); let (tx, rx) = oneshot::channel::>(); cx.background_spawn(async move { if let Ok(public_key) = signer_public_key(client).await { if let Ok(profiles) = client.database().contacts(public_key).await { let members: Vec = profiles .into_iter() .map(|profile| NostrProfile::new(profile.public_key(), profile.metadata())) .collect(); _ = tx.send(members); } } }) .detach(); cx.spawn(|this, cx| async move { if let Ok(contacts) = rx.await { _ = cx.update(|cx| { this.update(cx, |this, cx| { this.contacts.update(cx, |this, cx| { this.extend(contacts); cx.notify(); }); }) }); } }) .detach(); Self { title_input, user_input, contacts, selected, error_message, is_loading: false, is_submitting: false, focus_handle: cx.focus_handle(), subscriptions, } } pub fn compose(&mut self, window: &mut Window, cx: &mut Context) { if self.selected.read(cx).is_empty() { self.set_error(Some("You need to add at least 1 receiver".into()), cx); return; } // Show loading spinner self.set_submitting(true, cx); // Get all pubkeys let pubkeys: Vec = self.selected.read(cx).iter().copied().collect(); // Convert selected pubkeys into Nostr tags let mut tag_list: Vec = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect(); // Add subject if it is present if !self.title_input.read(cx).text().is_empty() { tag_list.push(Tag::custom( TagKind::Subject, vec![self.title_input.read(cx).text().to_string()], )); } let tags = Tags::from_list(tag_list); let client = get_client(); let window_handle = window.window_handle(); let (tx, rx) = oneshot::channel::(); cx.background_spawn(async move { let signer = client.signer().await.expect("Signer is required"); // [IMPORTANT] // Make sure this event is never send, // this event existed just use for convert to Coop's Chat Room later. if let Ok(event) = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "") .tags(tags) .sign(&signer) .await { _ = tx.send(event) }; }) .detach(); cx.spawn(|this, mut cx| async move { if let Ok(event) = rx.await { _ = cx.update_window(window_handle, |_, window, cx| { // Stop loading spinner _ = this.update(cx, |this, cx| { this.set_submitting(false, cx); }); if let Some(chats) = ChatRegistry::global(cx) { let room = Room::parse(&event, cx); chats.update(cx, |state, cx| match state.new_room(room, cx) { Ok(_) => { // TODO: open chat panel window.close_modal(cx); } Err(e) => { _ = this.update(cx, |this, cx| { this.set_error(Some(e.to_string().into()), cx); }); } }); } }); } }) .detach(); } pub fn label(&self, _window: &Window, cx: &App) -> SharedString { if self.selected.read(cx).len() > 1 { "Create Group DM".into() } else { "Create DM".into() } } pub fn is_submitting(&self) -> bool { self.is_submitting } fn add(&mut self, window: &mut Window, cx: &mut Context) { let window_handle = window.window_handle(); let content = self.user_input.read(cx).text().to_string(); // Show loading spinner self.set_loading(true, cx); if let Ok(public_key) = PublicKey::parse(&content) { if self .contacts .read(cx) .iter() .any(|c| c.public_key() == public_key) { self.set_loading(false, cx); return; }; cx.spawn(|this, mut cx| async move { let (tx, rx) = oneshot::channel::(); cx.background_spawn(async move { let client = get_client(); let metadata = (client .fetch_metadata(public_key, Duration::from_secs(3)) .await) .unwrap_or_default(); _ = tx.send(metadata); }) .detach(); if let Ok(metadata) = rx.await { _ = cx.update_window(window_handle, |_, window, cx| { _ = this.update(cx, |this, cx| { this.contacts.update(cx, |this, cx| { this.insert(0, NostrProfile::new(public_key, metadata)); cx.notify(); }); this.selected.update(cx, |this, cx| { this.insert(public_key); cx.notify(); }); // Stop loading indicator this.set_loading(false, cx); // Clear input this.user_input.update(cx, |this, cx| { this.set_text("", window, cx); cx.notify(); }); }); }); } }) .detach(); } else { self.set_loading(false, cx); self.set_error(Some("Public Key is not valid".into()), cx); } } fn set_error(&mut self, error: Option, cx: &mut Context) { self.error_message.update(cx, |this, cx| { *this = error; cx.notify(); }); // Dismiss error after 2 seconds cx.spawn(|this, cx| async move { Timer::after(Duration::from_secs(2)).await; _ = cx.update(|cx| { this.update(cx, |this, cx| { this.set_error(None, cx); }) }); }) .detach(); } fn set_loading(&mut self, status: bool, cx: &mut Context) { self.is_loading = status; cx.notify(); } fn set_submitting(&mut self, status: bool, cx: &mut Context) { self.is_submitting = status; cx.notify(); } fn on_action_select( &mut self, action: &SelectContact, _window: &mut Window, cx: &mut Context, ) { self.selected.update(cx, |this, cx| { if this.contains(&action.0) { this.remove(&action.0); } else { this.insert(action.0); }; cx.notify(); }); } } impl Render for Compose { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_action_select)) .flex() .flex_col() .gap_1() .child( div() .px_2() .text_xs() .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) .child(ALERT), ) .when_some(self.error_message.read(cx).as_ref(), |this, msg| { this.child( div() .px_2() .text_xs() .text_color(cx.theme().danger) .child(msg.clone()), ) }) .child( div().flex().flex_col().child( div() .h_10() .px_2() .border_b_1() .border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) .flex() .items_center() .gap_1() .child(div().text_xs().font_semibold().child("Title:")) .child(self.title_input.clone()), ), ) .child( div() .flex() .flex_col() .gap_2() .child(div().px_2().text_xs().font_semibold().child("To:")) .child( div() .flex() .items_center() .gap_2() .px_2() .child( Button::new("add_user_to_compose_btn") .icon(IconName::Plus) .small() .rounded(ButtonRounded::Size(px(9999.))) .loading(self.is_loading) .on_click( cx.listener(|this, _, window, cx| this.add(window, cx)), ), ) .child(self.user_input.clone()), ) .map(|this| { let contacts = self.contacts.read(cx).clone(); let view = cx.entity(); if contacts.is_empty() { this.child( div() .w_full() .h_24() .flex() .flex_col() .items_center() .justify_center() .text_align(TextAlign::Center) .child( div() .text_xs() .font_semibold() .line_height(relative(1.2)) .child("No contacts"), ) .child( div() .text_xs() .text_color( cx.theme().base.step(cx, ColorScaleStep::ELEVEN), ) .child("Your recently contacts will appear here."), ), ) } else { this.child( uniform_list( view, "contacts", contacts.len(), move |this, range, _window, cx| { let selected = this.selected.read(cx); let mut items = Vec::new(); for ix in range { let item = contacts.get(ix).unwrap().clone(); let is_select = selected.contains(&item.public_key()); items.push( div() .id(ix) .w_full() .h_9() .px_2() .flex() .items_center() .justify_between() .child( div() .flex() .items_center() .gap_2() .text_xs() .child( div().flex_shrink_0().child( img(item.avatar()).size_6(), ), ) .child(item.name()), ) .when(is_select, |this| { this.child( Icon::new(IconName::CircleCheck) .size_3() .text_color(cx.theme().base.step( cx, ColorScaleStep::TWELVE, )), ) }) .hover(|this| { this.bg(cx .theme() .base .step(cx, ColorScaleStep::THREE)) }) .on_click(move |_, window, cx| { window.dispatch_action( Box::new(SelectContact( item.public_key(), )), cx, ); }), ); } items }, ) .min_h(px(250.)), ) } }), ) } }