use chats::{room::Room, ChatRegistry}; use common::{profile::NostrProfile, utils::random_name}; use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client}; 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, Task, TextAlign, Window, }; use nostr_sdk::prelude::*; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use smol::Timer; use std::{collections::HashSet, time::Duration}; use ui::{ button::{Button, ButtonRounded}, input::{InputEvent, TextInput}, theme::{scale::ColorScaleStep, ActiveTheme}, ContextModal, Icon, IconName, Sizable, Size, StyledExt, }; const DESCRIPTION: &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: 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 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...") }); 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); } }, )); let client = get_client(); let (tx, rx) = oneshot::channel::>(); cx.background_spawn(async move { let signer = client.signer().await.unwrap(); let public_key = signer.get_public_key().await.unwrap(); 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(async move |this, cx| { 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(); }); }) }) .ok(); } }) .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 event: Task> = cx.background_spawn(async move { let client = get_client(); let signer = client.signer().await?; // [IMPORTANT] // Make sure this event is never send, // this event existed just use for convert to Coop's Chat Room later. let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "") .tags(tags) .sign(&signer) .await?; Ok(event) }); cx.spawn_in(window, async move |this, cx| { if let Ok(event) = event.await { cx.update(|window, cx| { // Stop loading spinner this.update(cx, |this, cx| { this.set_submitting(false, cx); }) .ok(); let chats = ChatRegistry::global(cx); let room = Room::new(&event, cx); chats.update(cx, |state, cx| { match state.push_room(room, cx) { Ok(_) => { // TODO: automatically open newly created chat panel window.close_modal(cx); } Err(e) => { _ = this.update(cx, |this, cx| { this.set_error(Some(e.to_string().into()), cx); }); } } }); }) .ok(); } }) .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 client = get_client(); let window_handle = window.window_handle(); let content = self.user_input.read(cx).text().to_string(); // Show loading spinner self.set_loading(true, cx); let task: Task> = if content.contains("@") { cx.background_spawn(async move { let profile = nip05::profile(&content, None).await?; let public_key = profile.public_key; let metadata = client .fetch_metadata(public_key, Duration::from_secs(2)) .await? .unwrap_or_default(); Ok(NostrProfile::new(public_key, metadata)) }) } else { let Ok(public_key) = PublicKey::parse(&content) else { self.set_loading(false, cx); self.set_error(Some("Public Key is not valid".into()), cx); return; }; cx.background_spawn(async move { let metadata = client .fetch_metadata(public_key, Duration::from_secs(2)) .await? .unwrap_or_default(); Ok(NostrProfile::new(public_key, metadata)) }) }; cx.spawn(async move |this, cx| { match task.await { Ok(profile) => { let public_key = profile.public_key; _ = cx .background_spawn(async move { let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE); // Create a device announcement filter let device = Filter::new() .kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)) .author(public_key) .limit(1); // Only subscribe to the latest device announcement client.subscribe(device, Some(opts)).await }) .await; _ = cx.update_window(window_handle, |_, 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 this.set_loading(false, cx); // Clear input this.user_input.update(cx, |this, cx| { this.set_text("", window, cx); cx.notify(); }); }); }); } Err(e) => { _ = cx.update_window(window_handle, |_, _, cx| { _ = this.update(cx, |this, cx| { this.set_loading(false, cx); this.set_error(Some(e.to_string().into()), cx); }); }); } } }) .detach(); } 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(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(); }) .ok(); }) .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(DESCRIPTION), ) .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.)), ) } }), ) } }