diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index db12c24..8ae0cc3 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -326,20 +326,26 @@ fn main() { while let Some(signal) = signal_rx.recv().await { match signal { Signal::Eose => { - cx.update_window(*window.deref(), |_this, _window, cx| { - cx.update_global::(|this, cx| { - this.load(cx); - }); - }) - .unwrap(); + if let Err(e) = + cx.update_window(*window.deref(), |_this, window, cx| { + cx.update_global::(|this, cx| { + this.load(window, cx); + }); + }) + { + error!("Error: {}", e) + } } Signal::Event(event) => { - cx.update_window(*window.deref(), |_this, _window, cx| { - cx.update_global::(|this, cx| { - this.new_room_message(event, cx); - }); - }) - .unwrap(); + if let Err(e) = + cx.update_window(*window.deref(), |_this, window, cx| { + cx.update_global::(|this, cx| { + this.new_room_message(event, window, cx); + }); + }) + { + error!("Error: {}", e) + } } } } diff --git a/crates/app/src/views/app.rs b/crates/app/src/views/app.rs index daa9c80..d715aa8 100644 --- a/crates/app/src/views/app.rs +++ b/crates/app/src/views/app.rs @@ -191,7 +191,8 @@ impl AppView { PanelKind::Room(id) => { if let Some(weak_room) = cx.global::().get_room(id, cx) { if let Some(room) = weak_room.upgrade() { - let panel = Arc::new(chat::init(room, window, cx)); + let panel = Arc::new(chat::init(&room, window, cx)); + self.dock.update(cx, |dock_area, cx| { dock_area.add_panel(panel, action.position, window, cx); }); diff --git a/crates/app/src/views/chat/message.rs b/crates/app/src/views/chat/message.rs index dc91236..4938717 100644 --- a/crates/app/src/views/chat/message.rs +++ b/crates/app/src/views/chat/message.rs @@ -10,7 +10,7 @@ use ui::{ #[derive(Clone, Debug, IntoElement)] pub struct Message { - member: NostrProfile, + profile: NostrProfile, content: SharedString, ago: SharedString, } @@ -18,7 +18,7 @@ pub struct Message { impl PartialEq for Message { fn eq(&self, other: &Self) -> bool { let content = self.content == other.content; - let member = self.member == other.member; + let member = self.profile == other.profile; let ago = self.ago == other.ago; content && member && ago @@ -26,9 +26,9 @@ impl PartialEq for Message { } impl Message { - pub fn new(member: NostrProfile, content: SharedString, ago: SharedString) -> Self { + pub fn new(profile: NostrProfile, content: SharedString, ago: SharedString) -> Self { Self { - member, + profile, content, ago, } @@ -58,7 +58,7 @@ impl RenderOnce for Message { }), ) .child( - img(self.member.avatar()) + img(self.profile.avatar()) .size_8() .rounded_full() .flex_shrink_0(), @@ -75,7 +75,7 @@ impl RenderOnce for Message { .items_baseline() .gap_2() .text_xs() - .child(div().font_semibold().child(self.member.name())) + .child(div().font_semibold().child(self.profile.name())) .child( div() .child(self.ago) diff --git a/crates/app/src/views/chat/mod.rs b/crates/app/src/views/chat/mod.rs index 88547aa..221f1c1 100644 --- a/crates/app/src/views/chat/mod.rs +++ b/crates/app/src/views/chat/mod.rs @@ -2,13 +2,14 @@ use async_utility::task::spawn; use chat_state::room::Room; use common::{ constants::IMAGE_SERVICE, + profile::NostrProfile, utils::{compare, message_time, nip96_upload}, }; use gpui::{ div, img, list, prelude::FluentBuilder, px, white, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Pixels, Render, - SharedString, StatefulInteractiveElement, Styled, StyledImage, WeakEntity, Window, + SharedString, StatefulInteractiveElement, Styled, StyledImage, Window, }; use itertools::Itertools; use message::Message; @@ -27,7 +28,7 @@ use ui::{ mod message; -pub fn init(room: Entity, window: &mut Window, cx: &mut App) -> Entity { +pub fn init(room: &Entity, window: &mut Window, cx: &mut App) -> Entity { Chat::new(room, window, cx) } @@ -45,7 +46,8 @@ pub struct Chat { // Chat Room id: SharedString, name: SharedString, - room: Entity, + owner: NostrProfile, + members: Vec, state: Entity, list: ListState, // New Message @@ -56,12 +58,15 @@ pub struct Chat { } impl Chat { - pub fn new(model: Entity, window: &mut Window, cx: &mut App) -> Entity { + pub fn new(model: &Entity, window: &mut Window, cx: &mut App) -> Entity { let room = model.read(cx); let id = room.id.to_string().into(); let name = room.title.clone().unwrap_or("Untitled".into()); + let owner = room.owner.clone(); + let members = room.members.clone(); cx.new(|cx| { + // Load all messages cx.observe_new::(|this, window, cx| { if let Some(window) = window { this.load_messages(window, cx); @@ -69,7 +74,13 @@ impl Chat { }) .detach(); - // Form + // Observe and load new messages + cx.observe_in(model, window, |this: &mut Chat, model, _, cx| { + this.load_new_messages(&model, cx); + }) + .detach(); + + // New message form let input = cx.new(|cx| { TextInput::new(window, cx) .appearance(false) @@ -77,60 +88,56 @@ impl Chat { .placeholder("Message...") }); - // List - let state = cx.new(|_| State { - count: 0, - items: vec![], - }); - // Send message when user presses enter cx.subscribe_in( &input, window, - move |this: &mut Chat, view, input_event, window, cx| { + move |this: &mut Chat, _, input_event, window, cx| { if let InputEvent::PressEnter = input_event { - this.send_message(view.downgrade(), window, cx); + this.send_message(window, cx); } }, ) .detach(); + // List state model + let state = cx.new(|_| State { + count: 0, + items: vec![], + }); + // Update list on every state changes cx.observe(&state, |this, model, cx| { - let items = model.read(cx).items.clone(); - this.list = ListState::new( - items.len(), + model.read(cx).items.len(), ListAlignment::Bottom, - Pixels(256.), - move |idx, _window, _cx| { - let item = items.get(idx).unwrap().clone(); - div().child(item).into_any_element() + Pixels(1024.), + move |idx, _window, cx| { + if let Some(message) = model.read(cx).items.get(idx) { + div().child(message.clone()).into_any_element() + } else { + div().into_any_element() + } }, ); - cx.notify(); }) .detach(); - cx.observe_in(&model, window, |this, model, window, cx| { - this.load_new_messages(model.downgrade(), window, cx); - }) - .detach(); - let attaches = cx.new(|_| None); Self { closable: true, zoomable: true, focus_handle: cx.focus_handle(), - room: model, - list: ListState::new(0, ListAlignment::Bottom, Pixels(256.), move |_, _, _| { + list: ListState::new(0, ListAlignment::Bottom, Pixels(1024.), move |_, _, _| { div().into_any_element() }), is_uploading: false, id, name, + owner, + members, input, state, attaches, @@ -138,150 +145,139 @@ impl Chat { }) } - fn load_messages(&self, _window: &mut Window, cx: &mut Context) { - let room = self.room.read(cx); - let members = room.members.clone(); - let owner = room.owner.clone(); - // Get all public keys - let all_keys = room.get_pubkeys(); + fn load_messages(&self, window: &mut Window, cx: &mut Context) { + let window_handle = window.window_handle(); + // Get current user + let author = self.owner.public_key(); + // Get other users in room + let pubkeys = self + .members + .iter() + .map(|m| m.public_key()) + .collect::>(); + // Get all public keys for comparisation + let mut all_keys = pubkeys.clone(); + all_keys.push(author); - // Async - let async_state = self.state.clone(); - let mut async_cx = cx.to_async(); + cx.spawn(|this, mut cx| async move { + let (tx, rx) = oneshot::channel::(); - cx.foreground_executor() - .spawn(async move { - let events: anyhow::Result = async_cx - .background_executor() - .spawn({ - let client = get_client(); - let pubkeys = members.iter().map(|m| m.public_key()).collect::>(); + cx.background_spawn({ + let client = get_client(); - async move { - let signer = client.signer().await?; - let author = signer.get_public_key().await?; + let recv = Filter::new() + .kind(Kind::PrivateDirectMessage) + .author(author) + .pubkeys(pubkeys.iter().copied()); - let recv = Filter::new() - .kind(Kind::PrivateDirectMessage) - .author(author) - .pubkeys(pubkeys.clone()); + let send = Filter::new() + .kind(Kind::PrivateDirectMessage) + .authors(pubkeys) + .pubkey(author); - let send = Filter::new() - .kind(Kind::PrivateDirectMessage) - .authors(pubkeys) - .pubkey(author); - - // Get all DM events in database - let recv_events = client.database().query(recv).await?; - let send_events = client.database().query(send).await?; - let events = recv_events.merge(send_events); - - Ok(events) - } - }) - .await; - - if let Ok(events) = events { - let items: Vec = events - .into_iter() - .sorted_by_key(|ev| ev.created_at) - .filter_map(|ev| { - let mut pubkeys: Vec<_> = ev.tags.public_keys().copied().collect(); - pubkeys.push(ev.pubkey); - - if compare(&pubkeys, &all_keys) { - let member = if let Some(member) = - members.iter().find(|&m| m.public_key() == ev.pubkey) - { - member.to_owned() - } else { - owner.clone() - }; - - Some(Message::new( - member, - ev.content.into(), - message_time(ev.created_at).into(), - )) - } else { - None - } - }) - .collect(); - - let total = items.len(); - - _ = async_cx.update_entity(&async_state, |a, b| { - a.items = items; - a.count = total; - b.notify(); - }); + // Get all DM events in database + async move { + let recv_events = client.database().query(recv).await.unwrap(); + let send_events = client.database().query(send).await.unwrap(); + let events = recv_events.merge(send_events); + _ = tx.send(events); } }) .detach(); + + if let Ok(events) = rx.await { + _ = cx.update_window(window_handle, |_, _, cx| { + _ = this.update(cx, |this, cx| { + let items: Vec = events + .into_iter() + .sorted_by_key(|ev| ev.created_at) + .filter_map(|ev| { + let mut pubkeys: Vec<_> = ev.tags.public_keys().copied().collect(); + pubkeys.push(ev.pubkey); + + if compare(&pubkeys, &all_keys) { + let member = if let Some(member) = + this.members.iter().find(|&m| m.public_key() == ev.pubkey) + { + member.to_owned() + } else { + this.owner.clone() + }; + + Some(Message::new( + member, + ev.content.into(), + message_time(ev.created_at).into(), + )) + } else { + None + } + }) + .collect(); + + cx.update_entity(&this.state, |this, cx| { + this.count = items.len(); + this.items = items; + cx.notify(); + }); + }); + }); + } + }) + .detach(); } - fn load_new_messages( - &self, - model: WeakEntity, - _window: &mut Window, - cx: &mut Context, - ) { - if let Some(model) = model.upgrade() { - let room = model.read(cx); - let items: Vec = room - .new_messages - .iter() - .filter_map(|event| { - room.member(&event.pubkey).map(|member| { - Message::new( - member, - event.content.clone().into(), - message_time(event.created_at).into(), - ) - }) + fn load_new_messages(&self, model: &Entity, cx: &mut Context) { + let room = model.read(cx); + let items: Vec = room + .new_messages + .iter() + .filter_map(|event| { + room.member(&event.pubkey).map(|member| { + Message::new( + member, + event.content.clone().into(), + message_time(event.created_at).into(), + ) + }) + }) + .collect(); + + cx.update_entity(&self.state, |this, cx| { + let messages: Vec = items + .into_iter() + .filter_map(|new| { + if !this.items.iter().any(|old| old == &new) { + Some(new) + } else { + None + } }) .collect(); - cx.update_entity(&self.state, |model, cx| { - let messages: Vec = items - .into_iter() - .filter_map(|new| { - if !model.items.iter().any(|old| old == &new) { - Some(new) - } else { - None - } - }) - .collect(); - - model.items.extend(messages); - model.count = model.items.len(); - cx.notify(); - }); - } + this.items.extend(messages); + this.count = this.items.len(); + cx.notify(); + }); } - fn send_message( - &mut self, - view: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) { + fn send_message(&mut self, window: &mut Window, cx: &mut Context) { let window_handle = window.window_handle(); - let room = self.room.read(cx); - let owner = room.owner.clone(); - let mut members = room.members.to_vec(); - members.push(owner.clone()); + + // Get current user + let author = self.owner.public_key(); + + // Get other users in room + let mut pubkeys = self + .members + .iter() + .map(|m| m.public_key()) + .collect::>(); + pubkeys.push(author); // Get message let mut content = self.input.read(cx).text().to_string(); - if content.is_empty() { - window.push_notification("Message cannot be empty", cx); - return; - } - // Get all attaches and merge with message if let Some(attaches) = self.attaches.read(cx).as_ref() { let merged = attaches @@ -293,75 +289,77 @@ impl Chat { content = format!("{}\n{}", content, merged) } - // Update input state - if let Some(input) = view.upgrade() { - cx.update_entity(&input, |input, cx| { - input.set_loading(true, window, cx); - input.set_disabled(true, window, cx); - }); + if content.is_empty() { + window.push_notification("Cannot send an empty message", cx); + return; } - cx.spawn(|this, mut cx| async move { - // Send message to all members - cx.background_executor() - .spawn({ - let client = get_client(); - let content = content.clone().to_string(); - let tags: Vec = members - .iter() - .filter_map(|m| { - if m.public_key() != owner.public_key() { - Some(Tag::public_key(m.public_key())) - } else { - None - } - }) - .collect(); + // Disable input when sending message + self.input.update(cx, |this, cx| { + this.set_loading(true, window, cx); + this.set_disabled(true, window, cx); + }); - async move { - // Send message to all members - for member in members.iter() { - _ = client - .send_private_msg(member.public_key(), &content, tags.clone()) - .await + cx.spawn(|this, mut cx| async move { + cx.background_spawn({ + let client = get_client(); + let content = content.clone(); + let tags: Vec = pubkeys + .iter() + .filter_map(|pubkey| { + if pubkey != &author { + Some(Tag::public_key(*pubkey)) + } else { + None + } + }) + .collect(); + + async move { + // Send message to all members + for pubkey in pubkeys.iter() { + if let Err(_e) = client + .send_private_msg(*pubkey, &content, tags.clone()) + .await + { + // TODO: handle error } } - }) - .detach(); + } + }) + .detach(); - if let Some(view) = this.upgrade() { - _ = cx.update_entity(&view, |this, cx| { - cx.update_entity(&this.state, |model, cx| { - let message = Message::new( - owner, - content.to_string().into(), - message_time(Timestamp::now()).into(), - ); + _ = cx.update_window(window_handle, |_, window, cx| { + _ = this.update(cx, |this, cx| { + let message = Message::new( + this.owner.clone(), + content.to_string().into(), + message_time(Timestamp::now()).into(), + ); - model.items.extend(vec![message]); - model.count = model.items.len(); + // Update message list + cx.update_entity(&this.state, |this, cx| { + this.items.extend(vec![message]); + this.count = this.items.len(); cx.notify(); }); - cx.notify(); - }); - } - if let Some(input) = view.upgrade() { - cx.update_window(window_handle, |_, window, cx| { - cx.update_entity(&input, |input, cx| { - input.set_loading(false, window, cx); - input.set_disabled(false, window, cx); - input.set_text("", window, cx); + // Reset message input + cx.update_entity(&this.input, |this, cx| { + this.set_loading(false, window, cx); + this.set_disabled(false, window, cx); + this.set_text("", window, cx); + cx.notify(); }); - }) - .unwrap() - } + }); + }); }) .detach(); } - fn upload(&mut self, _window: &mut Window, cx: &mut Context) { - let attaches = self.attaches.clone(); + fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let window_handle = window.window_handle(); + let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, @@ -372,7 +370,7 @@ impl Chat { self.set_loading(true, cx); // TODO: support multiple upload - cx.spawn(move |this, mut async_cx| async move { + cx.spawn(move |this, mut cx| async move { match Flatten::flatten(paths.await.map_err(|e| e.into())) { Ok(Some(mut paths)) => { let path = paths.pop().unwrap(); @@ -382,33 +380,39 @@ impl Chat { spawn(async move { let client = get_client(); - if let Ok(url) = nip96_upload(client, file_data).await { _ = tx.send(url); } }); if let Ok(url) = rx.await { - // Stop loading spinner - if let Some(view) = this.upgrade() { - _ = async_cx.update_entity(&view, |this, cx| { + _ = cx.update_window(window_handle, |_, _, cx| { + _ = this.update(cx, |this, cx| { + // Stop loading spinner this.set_loading(false, cx); - }); - } - // Update attaches model - _ = async_cx.update_entity(&attaches, |model, cx| { - if let Some(model) = model.as_mut() { - model.push(url); - } else { - *model = Some(vec![url]); - } - cx.notify(); + this.attaches.update(cx, |this, cx| { + if let Some(model) = this.as_mut() { + model.push(url); + } else { + *this = Some(vec![url]); + } + cx.notify(); + }); + }); }); } } } - Ok(None) => {} + Ok(None) => { + // Stop loading spinner + if let Some(view) = this.upgrade() { + cx.update_entity(&view, |this, cx| { + this.set_loading(false, cx); + }) + .unwrap(); + } + } Err(_) => {} } }) @@ -436,15 +440,8 @@ impl Panel for Chat { self.id.clone() } - fn panel_facepile(&self, cx: &App) -> Option> { - Some( - self.room - .read(cx) - .members - .iter() - .map(|member| member.avatar()) - .collect(), - ) + fn panel_facepile(&self, _cx: &App) -> Option> { + Some(self.members.iter().map(|member| member.avatar()).collect()) } fn title(&self, _cx: &App) -> AnyElement { @@ -561,11 +558,7 @@ impl Render for Chat { .rounded(ButtonRounded::Medium) .label("SEND") .on_click(cx.listener(|this, _, window, cx| { - this.send_message( - this.input.downgrade(), - window, - cx, - ) + this.send_message(window, cx) })), ), ), diff --git a/crates/app/src/views/profile.rs b/crates/app/src/views/profile.rs index 3209aab..5aedef7 100644 --- a/crates/app/src/views/profile.rs +++ b/crates/app/src/views/profile.rs @@ -1,14 +1,14 @@ -use std::str::FromStr; - use async_utility::task::spawn; use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload}; use gpui::{ - div, img, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle, - Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Window, + div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter, + Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, + SharedString, Styled, Window, }; use nostr_sdk::prelude::*; use smol::fs; use state::get_client; +use std::str::FromStr; use tokio::sync::oneshot; use ui::{ button::{Button, ButtonVariants}, @@ -112,34 +112,39 @@ impl Profile { spawn(async move { let client = get_client(); - if let Ok(url) = nip96_upload(client, file_data).await { _ = tx.send(url); } }); if let Ok(url) = rx.await { - // Stop loading spinner - if let Some(view) = this.upgrade() { - cx.update_entity(&view, |this, cx| { + cx.update_window(window_handle, |_, window, cx| { + // Stop loading spinner + this.update(cx, |this, cx| { this.set_loading(false, cx); }) .unwrap(); - } - // Update avatar input - if let Some(input) = avatar_input.upgrade() { - cx.update_window(window_handle, |_, window, cx| { - cx.update_entity(&input, |this, cx| { + // Set avatar input + avatar_input + .update(cx, |this, cx| { this.set_text(url.to_string(), window, cx); - }); - }) - .unwrap(); - } + }) + .unwrap(); + }) + .unwrap(); } } } - Ok(None) => {} + Ok(None) => { + // Stop loading spinner + if let Some(view) = this.upgrade() { + cx.update_entity(&view, |this, cx| { + this.set_loading(false, cx); + }) + .unwrap(); + } + } Err(_) => {} } }) @@ -189,18 +194,14 @@ impl Profile { .detach(); if rx.await.is_ok() { - if let Some(profile) = this.upgrade() { - cx.update_window(window_handle, |_, window, cx| { - cx.update_entity(&profile, |this, cx| { - this.set_submitting(false, cx); - window.push_notification( - "Your profile has been updated successfully", - cx, - ); - }) + cx.update_window(window_handle, |_, window, cx| { + this.update(cx, |this, cx| { + this.set_submitting(false, cx); + window.push_notification("Your profile has been updated successfully", cx); }) - .unwrap(); - } + .unwrap() + }) + .unwrap(); } }) .detach(); @@ -263,16 +264,29 @@ impl Render for Profile { .gap_2() .w_full() .h_24() - .child( - img(format!( - "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", - IMAGE_SERVICE, - self.avatar_input.read(cx).text() - )) - .size_10() - .rounded_full() - .flex_shrink_0(), - ) + .map(|this| { + let picture = self.avatar_input.read(cx).text(); + + if picture.is_empty() { + this.child( + img("brand/avatar.png") + .size_10() + .rounded_full() + .flex_shrink_0(), + ) + } else { + this.child( + img(format!( + "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", + IMAGE_SERVICE, + self.avatar_input.read(cx).text() + )) + .size_10() + .rounded_full() + .flex_shrink_0(), + ) + } + }) .child( div() .flex() diff --git a/crates/app/src/views/sidebar/compose.rs b/crates/app/src/views/sidebar/compose.rs index 1a68bf0..2dc5cc4 100644 --- a/crates/app/src/views/sidebar/compose.rs +++ b/crates/app/src/views/sidebar/compose.rs @@ -1,25 +1,25 @@ use app_state::registry::AppRegistry; -use chat_state::room::Room; +use chat_state::registry::ChatRegistry; use common::{ + constants::FAKE_SIG, profile::NostrProfile, - utils::{random_name, room_hash}, + 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, TextAlign, Window, + AppContext, BorrowAppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, TextAlign, Window, }; use nostr_sdk::prelude::*; use serde::Deserialize; use state::get_client; -use std::{collections::HashSet, time::Duration}; +use std::{collections::HashSet, str::FromStr, time::Duration}; use tokio::sync::oneshot; use ui::{ button::{Button, ButtonRounded}, - indicator::Indicator, input::{InputEvent, TextInput}, theme::{scale::ColorScaleStep, ActiveTheme}, - Icon, IconName, Sizable, Size, StyledExt, + ContextModal, Icon, IconName, Sizable, Size, StyledExt, }; #[derive(Clone, PartialEq, Eq, Deserialize)] @@ -31,15 +31,16 @@ pub struct Compose { title_input: Entity, message_input: Entity, user_input: Entity, - contacts: Entity>>, + contacts: Entity>, selected: Entity>, focus_handle: FocusHandle, is_loading: bool, + is_submitting: bool, } impl Compose { pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { - let contacts = cx.new(|_| None); + let contacts = cx.new(|_| Vec::with_capacity(200)); let selected = cx.new(|_| HashSet::new()); let user_input = cx.new(|cx| { @@ -64,9 +65,10 @@ impl Compose { TextInput::new(window, cx) .appearance(false) .text_size(Size::XSmall) - .placeholder("Hello... (Optional)") + .placeholder("Hello...") }); + // Handle Enter event for message input cx.subscribe_in( &user_input, window, @@ -79,23 +81,22 @@ impl Compose { .detach(); cx.spawn(|this, mut cx| async move { - let client = get_client(); let (tx, rx) = oneshot::channel::>(); cx.background_executor() .spawn(async move { - let signer = client.signer().await.unwrap(); - let public_key = signer.get_public_key().await.unwrap(); + let client = get_client(); + 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(); - 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); + _ = tx.send(members); + } } }) .detach(); @@ -104,7 +105,7 @@ impl Compose { if let Some(view) = this.upgrade() { _ = cx.update_entity(&view, |this, cx| { this.contacts.update(cx, |this, cx| { - *this = Some(contacts); + this.extend(contacts); cx.notify(); }); cx.notify(); @@ -121,43 +122,116 @@ impl Compose { contacts, selected, is_loading: false, + is_submitting: false, focus_handle: cx.focus_handle(), } } - pub fn room(&self, _window: &Window, cx: &App) -> Option { - if let Some(current_user) = cx.global::().user() { - // Convert selected pubkeys into nostr tags - let tags: Vec = self - .selected - .read(cx) - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(); - let tags = Tags::new(tags); + pub fn compose(&mut self, window: &mut Window, cx: &mut Context) { + let selected = self.selected.read(cx).to_owned(); + let message = self.message_input.read(cx).text(); - // Convert selected pubkeys into members - let members: Vec = self - .selected - .read(cx) - .clone() - .into_iter() - .map(|pk| NostrProfile::new(pk, Metadata::new())) - .collect(); - - // Get room's id - let id = room_hash(&tags); - - // Get room's owner (current user) - let owner = NostrProfile::new(current_user.public_key(), Metadata::new()); - - // Get room's title - let title = self.title_input.read(cx).text().to_string().into(); - - Some(Room::new(id, owner, members, Some(title), Timestamp::now())) - } else { - None + if selected.is_empty() { + window.push_notification("You need to add at least 1 receiver", cx); + return; } + + if message.is_empty() { + window.push_notification("Message is required", cx); + return; + } + + let current_user = if let Some(profile) = cx.global::().user() { + profile + } else { + return; + }; + + // Show loading spinner + self.set_submitting(true, cx); + + // Get nostr client + let client = get_client(); + + // Get message from user's input + let content = message.to_string(); + + // Get room title from user's input + let title = Tag::title(self.title_input.read(cx).text().to_string()); + + // Get all pubkeys + let current_user = current_user.public_key(); + let mut pubkeys: Vec = selected.iter().copied().collect(); + pubkeys.push(current_user); + + // Convert selected pubkeys into Nostr tags + let mut tag_list: Vec = selected.iter().map(|pk| Tag::public_key(*pk)).collect(); + tag_list.push(title); + + let tags = Tags::new(tag_list); + let window_handle = window.window_handle(); + + cx.spawn(|this, mut cx| async move { + let (tx, rx) = oneshot::channel::(); + + cx.background_spawn(async move { + let mut event: Option = None; + + for pubkey in pubkeys.iter() { + if let Ok(output) = client + .send_private_msg(*pubkey, &content, tags.clone()) + .await + { + if pubkey == ¤t_user && event.is_none() { + if let Ok(Some(ev)) = client.database().event_by_id(&output.val).await { + if let Ok(UnwrappedGift { mut rumor, .. }) = + client.unwrap_gift_wrap(&ev).await + { + // Compute event id if not exist + rumor.ensure_id(); + + if let Some(id) = rumor.id { + let ev = Event::new( + id, + rumor.pubkey, + rumor.created_at, + rumor.kind, + rumor.tags, + rumor.content, + Signature::from_str(FAKE_SIG).unwrap(), + ); + + event = Some(ev); + } + } + } + } + } + } + + if let Some(event) = event { + _ = tx.send(event); + } + }) + .detach(); + + if let Ok(event) = rx.await { + _ = cx.update_window(window_handle, |_, window, cx| { + cx.update_global::(|this, cx| { + this.new_room_message(event, window, cx); + }); + + // Stop loading spinner + _ = this.update(cx, |this, cx| { + this.set_submitting(false, cx); + }); + + // Close modal + window.close_modal(cx); + }); + } + }) + .detach(); } pub fn label(&self, _window: &Window, cx: &App) -> SharedString { @@ -168,35 +242,47 @@ impl Compose { } } + 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(); - let input = self.user_input.downgrade(); // Show loading spinner self.set_loading(true, cx); if let Ok(public_key) = PublicKey::parse(&content) { - cx.spawn(|this, mut async_cx| async move { - let query: anyhow::Result = async_cx - .background_executor() - .spawn(async move { - let client = get_client(); - let metadata = client - .fetch_metadata(public_key, Duration::from_secs(3)) - .await?; + if self + .contacts + .read(cx) + .iter() + .any(|c| c.public_key() == public_key) + { + self.set_loading(false, cx); + return; + }; - Ok(metadata) - }) - .await; + cx.spawn(|this, mut cx| async move { + let (tx, rx) = oneshot::channel::(); - if let Ok(metadata) = query { - if let Some(view) = this.upgrade() { - _ = async_cx.update_entity(&view, |this, cx| { + 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| { - if let Some(members) = this { - members.insert(0, NostrProfile::new(public_key, metadata)); - } + this.insert(0, NostrProfile::new(public_key, metadata)); cx.notify(); }); @@ -205,22 +291,22 @@ impl Compose { cx.notify(); }); + // Stop loading indicator this.set_loading(false, cx); - }); - } - if let Some(input) = input.upgrade() { - _ = async_cx.update_window(window_handle, |_, window, cx| { - cx.update_entity(&input, |this, cx| { + // Clear input + this.user_input.update(cx, |this, cx| { this.set_text("", window, cx); - }) + cx.notify(); + }); }); - } + }); } }) .detach(); } else { - // Handle error + self.set_loading(false, cx); + window.push_notification("Public Key is not valid", cx); } } @@ -229,6 +315,11 @@ impl Compose { 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, @@ -318,114 +409,103 @@ impl Render for Compose { .child(self.user_input.clone()), ) .map(|this| { - if let Some(contacts) = self.contacts.read(cx).clone() { - let view = cx.entity(); - let total = contacts.len(); + let contacts = self.contacts.read(cx).clone(); + let view = cx.entity(); - if total != 0 { - 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", - total, - 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 - }, + 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"), ) - .min_h(px(300.)), - ) - } + .child( + div() + .text_xs() + .text_color( + cx.theme().base.step(cx, ColorScaleStep::ELEVEN), + ) + .child("Your recently contacts will appear here."), + ), + ) } else { - this.flex() - .items_center() - .justify_center() - .h_16() - .child(Indicator::new().small()) + 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.)), + ) } }), ) diff --git a/crates/app/src/views/sidebar/inbox.rs b/crates/app/src/views/sidebar/inbox.rs index 375843e..a326a06 100644 --- a/crates/app/src/views/sidebar/inbox.rs +++ b/crates/app/src/views/sidebar/inbox.rs @@ -89,7 +89,7 @@ impl Inbox { .text_xs() .rounded(px(cx.theme().radius)) .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR))) - .child(div().font_medium().map(|this| { + .child(div().flex_1().truncate().font_medium().map(|this| { if room.is_group { this.flex() .items_center() @@ -113,6 +113,7 @@ impl Inbox { })) .child( div() + .flex_shrink_0() .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) .child(ago), ) diff --git a/crates/app/src/views/sidebar/mod.rs b/crates/app/src/views/sidebar/mod.rs index f4e7e72..de7cdfc 100644 --- a/crates/app/src/views/sidebar/mod.rs +++ b/crates/app/src/views/sidebar/mod.rs @@ -1,9 +1,8 @@ use crate::views::sidebar::inbox::Inbox; -use chat_state::registry::ChatRegistry; use compose::Compose; use gpui::{ - div, px, AnyElement, App, AppContext, BorrowAppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, }; use ui::{ @@ -11,7 +10,7 @@ use ui::{ dock_area::panel::{Panel, PanelEvent}, popup_menu::PopupMenu, theme::{scale::ColorScaleStep, ActiveTheme}, - v_flex, ContextModal, Icon, IconName, Sizable, StyledExt, + v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, }; mod compose; @@ -53,6 +52,7 @@ impl Sidebar { window.open_modal(cx, move |modal, window, cx| { let label = compose.read(cx).label(window, cx); + let is_submitting = compose.read(cx).is_submitting(); modal .title("Direct Messages") @@ -70,14 +70,10 @@ impl Sidebar { .bold() .rounded(ButtonRounded::Large) .w_full() + .loading(is_submitting) + .disabled(is_submitting) .on_click(window.listener_for(&compose, |this, _, window, cx| { - if let Some(room) = this.room(window, cx) { - cx.update_global::(|this, cx| { - this.new_room(room, cx); - }); - - window.close_modal(cx); - } + this.compose(window, cx) })), ), ) diff --git a/crates/chat_state/src/registry.rs b/crates/chat_state/src/registry.rs index d73638d..9febb08 100644 --- a/crates/chat_state/src/registry.rs +++ b/crates/chat_state/src/registry.rs @@ -1,6 +1,6 @@ use anyhow::Error; use common::utils::{compare, room_hash}; -use gpui::{App, AppContext, Entity, Global, WeakEntity}; +use gpui::{App, AppContext, Entity, Global, WeakEntity, Window}; use nostr_sdk::prelude::*; use state::get_client; @@ -28,8 +28,11 @@ impl ChatRegistry { let mut profiles = Vec::new(); for public_key in pubkeys.into_iter() { - let query = client.database().metadata(public_key).await?; - let metadata = query.unwrap_or_default(); + let metadata = client + .database() + .metadata(public_key) + .await? + .unwrap_or_default(); profiles.push((public_key, metadata)); } @@ -56,14 +59,16 @@ impl ChatRegistry { cx.set_global(Self { inbox }); } - pub fn load(&mut self, cx: &mut App) { + pub fn load(&mut self, window: &mut Window, cx: &mut App) { + let window_handle = window.window_handle(); + self.inbox.update(cx, |this, cx| { let task = this.load(cx.to_async()); - cx.spawn(|this, mut async_cx| async move { - if let Some(inbox) = this.upgrade() { - if let Ok(events) = task.await { - _ = async_cx.update_entity(&inbox, |this, cx| { + cx.spawn(|this, mut cx| async move { + if let Ok(events) = task.await { + _ = cx.update_window(window_handle, |_, _, cx| { + _ = this.update(cx, |this, cx| { let current_rooms = this.get_room_ids(cx); let items: Vec> = events .into_iter() @@ -83,7 +88,7 @@ impl ChatRegistry { cx.notify(); }); - } + }); } }) .detach(); @@ -114,29 +119,42 @@ impl ChatRegistry { }) } - pub fn new_room_message(&mut self, event: Event, cx: &mut App) { + pub fn new_room_message(&mut self, event: Event, window: &mut Window, cx: &mut App) { + let window_handle = window.window_handle(); + + // Get all pubkeys from event's tags for comparision let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect(); pubkeys.push(event.pubkey); - self.inbox.update(cx, |this, cx| { - if let Some(room) = this.rooms.iter().find(|room| { - let all_keys = room.read(cx).get_pubkeys(); - compare(&all_keys, &pubkeys) - }) { - room.update(cx, |this, cx| { - this.new_messages.push(event); - cx.notify(); - }) - } else { - let room = cx.new(|_| Room::parse(&event)); + if let Some(room) = self + .inbox + .read(cx) + .rooms + .iter() + .find(|room| compare(&room.read(cx).get_pubkeys(), &pubkeys)) + { + let weak_room = room.downgrade(); - self.inbox.update(cx, |this, cx| { - this.rooms.insert(0, room); - cx.notify(); - }) - } + cx.spawn(|mut cx| async move { + if let Err(e) = cx.update_window(window_handle, |_, _, cx| { + _ = weak_room.update(cx, |this, cx| { + this.last_seen = event.created_at; + this.new_messages.push(event); - cx.notify(); - }) + cx.notify(); + }); + }) { + println!("Error: {}", e) + } + }) + .detach(); + } else { + let room = cx.new(|_| Room::parse(&event)); + + self.inbox.update(cx, |this, cx| { + this.rooms.insert(0, room); + cx.notify(); + }); + } } } diff --git a/crates/chat_state/src/room.rs b/crates/chat_state/src/room.rs index cdc059c..3c10d9f 100644 --- a/crates/chat_state/src/room.rs +++ b/crates/chat_state/src/room.rs @@ -4,6 +4,7 @@ use common::{ }; use gpui::SharedString; use nostr_sdk::prelude::*; +use std::collections::HashSet; #[derive(Debug)] pub struct Room { @@ -59,14 +60,19 @@ impl Room { let id = room_hash(&event.tags); let last_seen = event.created_at; + // Always equal to current user let owner = NostrProfile::new(event.pubkey, Metadata::default()); + + // Get all pubkeys that invole in this group let members: Vec = event .tags .public_keys() - .copied() - .map(|public_key| NostrProfile::new(public_key, Metadata::default())) + .collect::>() + .into_iter() + .map(|public_key| NostrProfile::new(*public_key, Metadata::default())) .collect(); + // Get title from event's tags let title = if let Some(tag) = event.tags.find(TagKind::Title) { tag.content().map(|s| s.to_owned().into()) } else { diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index 017863a..9cb4d8f 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -12,4 +12,4 @@ pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps"; pub const IMAGE_SERVICE: &str = "https://wsrv.nl"; /// NIP96 Media Server -pub const NIP96_SERVER: &str = "https://nostrcheck.me"; +pub const NIP96_SERVER: &str = "https://nostrmedia.com"; diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index f3ae832..34d81eb 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -7,6 +7,18 @@ pub struct NostrProfile { metadata: Metadata, } +impl AsRef for NostrProfile { + fn as_ref(&self) -> &PublicKey { + &self.public_key + } +} + +impl AsRef for NostrProfile { + fn as_ref(&self) -> &Metadata { + &self.metadata + } +} + impl PartialEq for NostrProfile { fn eq(&self, other: &Self) -> bool { self.public_key() == other.public_key() diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 6567c4d..c64aebc 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,5 +1,6 @@ use crate::constants::NIP96_SERVER; use chrono::{Datelike, Local, TimeZone}; +use itertools::Itertools; use nostr_sdk::prelude::*; use rnglib::{Language, RNG}; use std::{ @@ -7,6 +8,13 @@ use std::{ hash::{DefaultHasher, Hash, Hasher}, }; +pub async fn signer_public_key(client: &Client) -> anyhow::Result { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + Ok(public_key) +} + pub async fn nip96_upload(client: &Client, file: Vec) -> anyhow::Result { let signer = client.signer().await?; let server_url = Url::parse(NIP96_SERVER)?; @@ -18,8 +26,9 @@ pub async fn nip96_upload(client: &Client, file: Vec) -> anyhow::Result u64 { - let pubkeys: Vec = tags.public_keys().copied().collect(); + let pubkeys: Vec<&PublicKey> = tags.public_keys().unique_by(|&pubkey| pubkey).collect(); let mut hasher = DefaultHasher::new(); + // Generate unique hash pubkeys.hash(&mut hasher); diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 5b4f513..1f8d075 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -140,6 +140,7 @@ impl TextElement { // cursor blink let cursor_height = window.text_style().font_size.to_pixels(window.rem_size()) + px(2.); + cursor = Some(fill( Bounds::new( point( @@ -148,7 +149,7 @@ impl TextElement { ), size(px(1.), cursor_height), ), - cx.theme().accent.step(cx, ColorScaleStep::NINE), + cx.theme().accent.step(cx, ColorScaleStep::TEN), )) }; }