diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 6adef3e..4bb21e7 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -246,16 +246,29 @@ async fn main() { } } } + Signal::RecvEvent(event) => { + let metadata = async_cx + .background_executor() + .spawn(async move { + if let Ok(metadata) = + client.database().metadata(event.pubkey).await + { + metadata + } else { + None + } + }) + .await; + + _ = async_cx.update_global::(|state, _| { + state.push(event, metadata); + }); + } Signal::RecvMetadata(public_key) => { _ = async_cx.update_global::(|state, _cx| { state.seen(public_key); }) } - Signal::RecvEvent(event) => { - _ = async_cx.update_global::(|state, _| { - state.push(event); - }); - } _ => {} } } diff --git a/crates/app/src/states/chat.rs b/crates/app/src/states/chat.rs index 955379a..66ca45f 100644 --- a/crates/app/src/states/chat.rs +++ b/crates/app/src/states/chat.rs @@ -1,8 +1,23 @@ use gpui::*; use nostr_sdk::prelude::*; +use serde::Deserialize; + +#[derive(Clone, PartialEq, Eq, Deserialize)] +pub struct Room { + pub owner: PublicKey, + pub members: Vec, + pub last_seen: Timestamp, + pub title: Option, +} + +#[derive(Clone, Debug)] +pub struct Message { + pub event: Event, + pub metadata: Option, +} pub struct ChatRegistry { - pub new_messages: Vec, + pub new_messages: Vec, pub reload: bool, pub is_initialized: bool, } @@ -22,8 +37,8 @@ impl ChatRegistry { self.reload = true; } - pub fn push(&mut self, event: Event) { - self.new_messages.push(event); + pub fn push(&mut self, event: Event, metadata: Option) { + self.new_messages.push(Message { event, metadata }); } fn new() -> Self { diff --git a/crates/app/src/views/app.rs b/crates/app/src/views/app.rs index f1d0fa3..5c7fc9e 100644 --- a/crates/app/src/views/app.rs +++ b/crates/app/src/views/app.rs @@ -5,7 +5,6 @@ use coop_ui::{ IconName, Root, Sizable, TitleBar, }; use gpui::*; -use nostr_sdk::prelude::*; use prelude::FluentBuilder; use serde::Deserialize; use std::sync::Arc; @@ -14,12 +13,12 @@ use super::{ dock::{chat::ChatPanel, left_dock::LeftDock, welcome::WelcomePanel}, onboarding::Onboarding, }; -use crate::states::account::AccountRegistry; +use crate::states::{account::AccountRegistry, chat::Room}; #[derive(Clone, PartialEq, Eq, Deserialize)] pub struct AddPanel { - pub title: Option, - pub from: PublicKey, + pub room: Arc, + pub position: DockPlacement, } impl_actions!(dock, [AddPanel]); @@ -112,10 +111,10 @@ impl AppView { } fn on_action_add_panel(&mut self, action: &AddPanel, cx: &mut ViewContext) { - let chat_panel = Arc::new(ChatPanel::new(action.from, cx)); + let chat_panel = Arc::new(ChatPanel::new(&action.room, cx)); self.dock.update(cx, |dock_area, cx| { - dock_area.add_panel(chat_panel, DockPlacement::Center, cx); + dock_area.add_panel(chat_panel, action.position, cx); }); } } diff --git a/crates/app/src/views/dock/chat/form.rs b/crates/app/src/views/dock/chat/form.rs deleted file mode 100644 index 5b403bc..0000000 --- a/crates/app/src/views/dock/chat/form.rs +++ /dev/null @@ -1,108 +0,0 @@ -use coop_ui::{ - button::{Button, ButtonVariants}, - input::{InputEvent, TextInput}, - theme::ActiveTheme, - Icon, IconName, -}; -use gpui::*; -use nostr_sdk::prelude::*; - -use crate::get_client; - -pub struct Form { - to: PublicKey, - input: View, -} - -impl Form { - pub fn new(to: PublicKey, cx: &mut ViewContext<'_, Self>) -> Self { - let input = cx.new_view(|cx| { - TextInput::new(cx) - .appearance(false) - .text_size(coop_ui::Size::Small) - .placeholder("Message...") - .cleanable() - }); - - cx.subscribe(&input, move |form, _, input_event, cx| { - if let InputEvent::PressEnter = input_event { - form.send_message(cx); - } - }) - .detach(); - - Self { to, input } - } - - fn send_message(&mut self, cx: &mut ViewContext) { - let send_to = self.to; - let content = self.input.read(cx).text().to_string(); - let content_clone = content.clone(); - - let async_input = self.input.clone(); - let mut async_cx = cx.to_async(); - - cx.foreground_executor() - .spawn(async move { - let client = get_client(); - - async_cx - .background_executor() - .spawn(async move { - let signer = client.signer().await.unwrap(); - let public_key = signer.get_public_key().await.unwrap(); - - // Send message to all members - if client - .send_private_msg(send_to, content, vec![]) - .await - .is_ok() - { - // Send a copy to yourself - _ = client - .send_private_msg( - public_key, - content_clone, - vec![Tag::public_key(send_to)], - ) - .await; - } - }) - .await; - - _ = async_cx.update_view(&async_input, |input, cx| { - input.set_text("", cx); - }); - }) - .detach(); - } -} - -impl Render for Form { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .flex_shrink_0() - .w_full() - .h_12() - .border_t_1() - .border_color(cx.theme().border.opacity(0.7)) - .flex() - .items_center() - .gap_2() - .px_2() - .child( - Button::new("upload") - .icon(Icon::new(IconName::Upload)) - .ghost(), - ) - .child( - div() - .flex_1() - .flex() - .bg(cx.theme().muted) - .rounded(px(cx.theme().radius)) - .px_2() - .child(self.input.clone()), - ) - } -} diff --git a/crates/app/src/views/dock/chat/list.rs b/crates/app/src/views/dock/chat/list.rs deleted file mode 100644 index 49e2efd..0000000 --- a/crates/app/src/views/dock/chat/list.rs +++ /dev/null @@ -1,94 +0,0 @@ -use gpui::*; -use nostr_sdk::prelude::*; -use prelude::FluentBuilder; - -use crate::{get_client, states::chat::ChatRegistry}; - -pub struct MessageList { - member: PublicKey, - messages: Model>, -} - -impl MessageList { - pub fn new(from: PublicKey, cx: &mut ViewContext<'_, Self>) -> Self { - let messages = cx.new_model(|_| None); - - Self { - member: from, - messages, - } - } - - pub fn init(&self, cx: &mut ViewContext) { - let messages = self.messages.clone(); - let member = self.member; - - let mut async_cx = cx.to_async(); - - cx.foreground_executor() - .spawn(async move { - let client = get_client(); - let signer = client.signer().await.unwrap(); - let public_key = signer.get_public_key().await.unwrap(); - - let recv = Filter::new() - .kind(Kind::PrivateDirectMessage) - .author(member) - .pubkey(public_key); - - let send = Filter::new() - .kind(Kind::PrivateDirectMessage) - .author(public_key) - .pubkey(member); - - let events = async_cx - .background_executor() - .spawn(async move { client.database().query(vec![recv, send]).await }) - .await; - - if let Ok(events) = events { - _ = async_cx.update_model(&messages, |a, b| { - *a = Some(events); - b.notify(); - }); - } - }) - .detach(); - } - - pub fn subscribe(&self, cx: &mut ViewContext) { - let messages = self.messages.clone(); - - cx.observe_global::(move |_, cx| { - let state = cx.global::(); - let events = state.new_messages.clone(); - - cx.update_model(&messages, |a, b| { - if let Some(m) = a { - m.extend(events); - b.notify(); - } - }); - }) - .detach(); - } -} - -impl Render for MessageList { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .h_full() - .flex() - .flex_col_reverse() - .justify_end() - .when_some(self.messages.read(cx).as_ref(), |this, messages| { - this.children(messages.clone().into_iter().map(|m| { - div() - .flex() - .flex_col() - .child(m.pubkey.to_hex()) - .child(m.content) - })) - }) - } -} diff --git a/crates/app/src/views/dock/chat/mod.rs b/crates/app/src/views/dock/chat/mod.rs index e4befa2..89b3aad 100644 --- a/crates/app/src/views/dock/chat/mod.rs +++ b/crates/app/src/views/dock/chat/mod.rs @@ -2,15 +2,14 @@ use coop_ui::{ button::Button, dock::{Panel, PanelEvent, PanelState, TitleStyle}, popup_menu::PopupMenu, - v_flex, }; -use form::Form; use gpui::*; -use list::MessageList; -use nostr_sdk::*; +use room::ChatRoom; +use std::sync::Arc; -pub mod form; -pub mod list; +use crate::states::chat::Room; + +mod room; pub struct ChatPanel { // Panel @@ -18,22 +17,20 @@ pub struct ChatPanel { closeable: bool, zoomable: bool, focus_handle: FocusHandle, - // Chat Room - list: View, - form: View
, + // Room + room: View, } impl ChatPanel { - pub fn new(from: PublicKey, cx: &mut WindowContext) -> View { - let form = cx.new_view(|cx| Form::new(from, cx)); - let list = cx.new_view(|cx| { - let list = MessageList::new(from, cx); - // Load messages from database - list.init(cx); - // Subscribe for new message - list.subscribe(cx); + pub fn new(room: &Arc, cx: &mut WindowContext) -> View { + let room = cx.new_view(|cx| { + let view = ChatRoom::new(room, cx); + // Load messages + view.load(cx); + // Subscribe for new messages + view.subscribe(cx); - list + view }); cx.new_view(|cx| Self { @@ -41,8 +38,7 @@ impl ChatPanel { closeable: true, zoomable: true, focus_handle: cx.focus_handle(), - list, - form, + room, }) } } @@ -91,9 +87,6 @@ impl FocusableView for ChatPanel { impl Render for ChatPanel { fn render(&mut self, _cx: &mut gpui::ViewContext) -> impl IntoElement { - v_flex() - .size_full() - .child(div().flex_1().min_h_0().child(self.list.clone())) - .child(self.form.clone()) + div().size_full().child(self.room.clone()) } } diff --git a/crates/app/src/views/dock/chat/room.rs b/crates/app/src/views/dock/chat/room.rs new file mode 100644 index 0000000..86feefe --- /dev/null +++ b/crates/app/src/views/dock/chat/room.rs @@ -0,0 +1,309 @@ +use coop_ui::{ + button::{Button, ButtonVariants}, + input::{InputEvent, TextInput}, + theme::ActiveTheme, + v_flex, Icon, IconName, +}; +use gpui::*; +use itertools::Itertools; +use nostr_sdk::prelude::*; +use std::{collections::HashMap, sync::Arc}; + +use crate::{ + get_client, + states::chat::{ChatRegistry, Room}, +}; + +#[derive(Clone, Debug, IntoElement)] +pub struct MessageItem { + author: PublicKey, + metadata: Option, + content: SharedString, + created_at: Timestamp, +} + +impl MessageItem { + pub fn new( + author: PublicKey, + metadata: Option, + content: String, + created_at: Timestamp, + ) -> Self { + MessageItem { + author, + metadata, + created_at, + content: content.into(), + } + } +} + +impl RenderOnce for MessageItem { + fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + div().flex().flex_col().text_sm().child(self.content) + } +} + +#[derive(Clone)] +pub struct Messages { + count: usize, + items: Vec, +} + +pub struct ChatRoom { + owner: PublicKey, + members: Arc<[PublicKey]>, + // Form + input: View, + // Messages + list: ListState, + messages: Model, +} + +impl ChatRoom { + pub fn new(room: &Arc, cx: &mut ViewContext<'_, Self>) -> Self { + let members: Arc<[PublicKey]> = room.members.clone().into(); + let owner = room.owner; + + // Form + let input = cx.new_view(|cx| { + TextInput::new(cx) + .appearance(false) + .text_size(coop_ui::Size::Small) + .placeholder("Message...") + .cleanable() + }); + + // Send message when user presses enter on form. + cx.subscribe(&input, move |this, _, input_event, cx| { + if let InputEvent::PressEnter = input_event { + this.send_message(cx); + } + }) + .detach(); + + let messages = cx.new_model(|_| Messages { + count: 0, + items: vec![], + }); + + cx.observe(&messages, |this, model, cx| { + let items = model.read(cx).items.clone(); + + this.list = ListState::new( + items.len(), + ListAlignment::Bottom, + Pixels(256.), + move |idx, _cx| { + let item = items.get(idx).unwrap().clone(); + div().child(item).into_any_element() + }, + ); + + cx.notify(); + }) + .detach(); + + let list = ListState::new(0, ListAlignment::Bottom, Pixels(256.), move |_, _| { + div().into_any_element() + }); + + Self { + owner, + members, + input, + list, + messages, + } + } + + pub fn load(&self, cx: &mut ViewContext) { + let async_messages = self.messages.clone(); + let mut async_cx = cx.to_async(); + + cx.foreground_executor() + .spawn({ + let client = get_client(); + let members = self.members.to_vec(); + + async move { + let signer = client.signer().await.unwrap(); + let public_key = signer.get_public_key().await.unwrap(); + + let recv = Filter::new() + .kind(Kind::PrivateDirectMessage) + .authors(members.clone()) + .pubkey(public_key); + + let send = Filter::new() + .kind(Kind::PrivateDirectMessage) + .author(public_key) + .pubkeys(members); + + let events = async_cx + .background_executor() + .spawn(async move { client.database().query(vec![recv, send]).await }) + .await; + + if let Ok(events) = events { + let public_keys: Vec = events + .iter() + .unique_by(|ev| ev.pubkey) + .map(|ev| ev.pubkey) + .collect(); + + let mut profiles = async_cx + .background_executor() + .spawn(async move { + let mut data: HashMap> = HashMap::new(); + + for public_key in public_keys.into_iter() { + if let Ok(metadata) = + client.database().metadata(public_key).await + { + data.insert(public_key, metadata); + } + } + + data + }) + .await; + + let items: Vec = events + .into_iter() + .sorted_by_key(|ev| ev.created_at) + .map(|ev| { + // Get user's metadata + let metadata = profiles.get_mut(&ev.pubkey).and_then(Option::take); + // Return message item + MessageItem::new(ev.pubkey, metadata, ev.content, ev.created_at) + }) + .collect(); + + let total = items.len(); + + _ = async_cx.update_model(&async_messages, |a, b| { + a.items = items; + a.count = total; + b.notify(); + }); + } + } + }) + .detach(); + } + + pub fn subscribe(&self, cx: &mut ViewContext) { + let messages = self.messages.clone(); + + cx.observe_global::(move |_, cx| { + let state = cx.global::(); + let events = state.new_messages.clone(); + // let mut metadata = state.metadata.clone(); + + // TODO: filter messages + let items: Vec = events + .into_iter() + .map(|m| { + MessageItem::new( + m.event.pubkey, + m.metadata, + m.event.content, + m.event.created_at, + ) + }) + .collect(); + + cx.update_model(&messages, |a, b| { + a.items.extend(items); + a.count = a.items.len(); + b.notify(); + }); + }) + .detach(); + } + + // TODO: support chat room + pub fn send_message(&mut self, cx: &mut ViewContext) { + let owner = self.owner; + let content = self.input.read(cx).text().to_string(); + let content_clone = content.clone(); + + let async_input = self.input.clone(); + let mut async_cx = cx.to_async(); + + cx.foreground_executor() + .spawn({ + let client = get_client(); + + async move { + let send: anyhow::Result<(), anyhow::Error> = async_cx + .background_executor() + .spawn(async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + // Send message to [owner] + if client + .send_private_msg(owner, content, vec![]) + .await + .is_ok() + { + // Send a copy to [yourself] + _ = client + .send_private_msg( + public_key, + content_clone, + vec![Tag::public_key(owner)], + ) + .await? + } + + Ok(()) + }) + .await; + + if send.is_ok() { + _ = async_cx.update_view(&async_input, |input, cx| { + input.set_text("", cx); + }); + } + } + }) + .detach(); + } +} + +impl Render for ChatRoom { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { + v_flex() + .size_full() + .child(list(self.list.clone()).size_full().flex_1()) + .child( + div() + .flex_shrink_0() + .w_full() + .h_12() + .border_t_1() + .border_color(cx.theme().border.opacity(0.7)) + .flex() + .items_center() + .gap_2() + .px_2() + .child( + Button::new("upload") + .icon(Icon::new(IconName::Upload)) + .ghost(), + ) + .child( + div() + .flex_1() + .flex() + .bg(cx.theme().muted) + .rounded(px(cx.theme().radius)) + .px_2() + .child(self.input.clone()), + ), + ) + } +} diff --git a/crates/app/src/views/dock/inbox/chat.rs b/crates/app/src/views/dock/inbox/item.rs similarity index 75% rename from crates/app/src/views/dock/inbox/chat.rs rename to crates/app/src/views/dock/inbox/item.rs index 2c16c92..bfe88f5 100644 --- a/crates/app/src/views/dock/inbox/chat.rs +++ b/crates/app/src/views/dock/inbox/item.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use coop_ui::{theme::ActiveTheme, Selectable, StyledExt}; use gpui::*; use nostr_sdk::prelude::*; @@ -5,45 +7,36 @@ use prelude::FluentBuilder; use crate::{ get_client, - states::{metadata::MetadataRegistry, signal::SignalRegistry}, + states::{chat::Room, metadata::MetadataRegistry, signal::SignalRegistry}, utils::{ago, show_npub}, views::app::AddPanel, }; #[derive(IntoElement)] -struct ChatItem { +struct Item { id: ElementId, - public_key: PublicKey, + room: Arc, metadata: Option, - last_seen: Timestamp, - title: Option, // Interactive base: Div, selected: bool, } -impl ChatItem { - pub fn new( - public_key: PublicKey, - metadata: Option, - last_seen: Timestamp, - title: Option, - ) -> Self { - let id = SharedString::from(public_key.to_hex()).into(); +impl Item { + pub fn new(room: Arc, metadata: Option) -> Self { + let id = SharedString::from(room.owner.to_hex()).into(); Self { id, - public_key, + room, metadata, - last_seen, - title, base: div(), selected: false, } } } -impl Selectable for ChatItem { +impl Selectable for Item { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -54,16 +47,16 @@ impl Selectable for ChatItem { } } -impl InteractiveElement for ChatItem { +impl InteractiveElement for Item { fn interactivity(&mut self) -> &mut gpui::Interactivity { self.base.interactivity() } } -impl RenderOnce for ChatItem { +impl RenderOnce for Item { fn render(self, cx: &mut WindowContext) -> impl IntoElement { - let ago = ago(self.last_seen.as_u64()); - let fallback_name = show_npub(self.public_key, 16); + let ago = ago(self.room.last_seen.as_u64()); + let fallback_name = show_npub(self.room.owner, 16); let mut content = div() .font_medium() @@ -129,34 +122,41 @@ impl RenderOnce for ChatItem { ) .on_click(move |_, cx| { cx.dispatch_action(Box::new(AddPanel { - title: self.title.clone(), - from: self.public_key, + room: self.room.clone(), + position: coop_ui::dock::DockPlacement::Center, })) }) } } -pub struct Chat { - title: Option, +pub struct InboxItem { + room: Arc, metadata: Model>, - last_seen: Timestamp, - pub(crate) public_key: PublicKey, + pub(crate) sender: PublicKey, } -impl Chat { +impl InboxItem { pub fn new(event: Event, cx: &mut ViewContext<'_, Self>) -> Self { - let public_key = event.pubkey; + let sender = event.pubkey; let last_seen = event.created_at; + + // Get all members from event's tag + let mut members: Vec = event.tags.public_keys().copied().collect(); + // Add sender to members + members.insert(0, sender); + + // Get title from event's tag let title = if let Some(tag) = event.tags.find(TagKind::Title) { tag.content().map(|s| s.to_string()) } else { + // TODO: create random name? None }; let metadata = cx.new_model(|_| None); // Request metadata - _ = cx.global::().tx.send(public_key); + _ = cx.global::().tx.send(sender); // Reload when received metadata cx.observe_global::(|chat, cx| { @@ -164,16 +164,22 @@ impl Chat { }) .detach(); - Self { - public_key, - last_seen, - metadata, + let room = Arc::new(Room { title, + members, + last_seen, + owner: sender, + }); + + Self { + room, + sender, + metadata, } } pub fn load_metadata(&mut self, cx: &mut ViewContext) { - let public_key = self.public_key; + let public_key = self.sender; let async_metadata = self.metadata.clone(); let mut async_cx = cx.to_async(); @@ -194,17 +200,17 @@ impl Chat { }) .detach(); } -} -impl Render for Chat { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + fn render_item(&self, cx: &mut ViewContext) -> impl IntoElement { let metadata = self.metadata.read(cx).clone(); + let room = self.room.clone(); - div().child(ChatItem::new( - self.public_key, - metadata, - self.last_seen, - self.title.clone(), - )) + Item::new(room, metadata) + } +} + +impl Render for InboxItem { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div().child(self.render_item(cx)) } } diff --git a/crates/app/src/views/dock/inbox/mod.rs b/crates/app/src/views/dock/inbox/mod.rs index e9cc60f..813c093 100644 --- a/crates/app/src/views/dock/inbox/mod.rs +++ b/crates/app/src/views/dock/inbox/mod.rs @@ -1,8 +1,8 @@ -use chat::Chat; use coop_ui::{ skeleton::Skeleton, theme::ActiveTheme, v_flex, Collapsible, Icon, IconName, StyledExt, }; use gpui::*; +use item::InboxItem; use itertools::Itertools; use nostr_sdk::prelude::*; use prelude::FluentBuilder; @@ -10,12 +10,12 @@ use std::cmp::Reverse; use crate::{get_client, states::chat::ChatRegistry}; -pub mod chat; +pub mod item; pub struct Inbox { label: SharedString, events: Model>>, - chats: Model>>, + chats: Model>>, is_loading: bool, is_fetching: bool, is_collapsed: bool, @@ -37,8 +37,8 @@ impl Inbox { for message in new_messages.into_iter() { cx.update_model(&inbox.events, |model, b| { if let Some(events) = model { - if !events.iter().any(|ev| ev.pubkey == message.pubkey) { - events.push(message); + if !events.iter().any(|ev| ev.pubkey == message.event.pubkey) { + events.push(message.event); b.notify(); } } @@ -56,15 +56,14 @@ impl Inbox { if let Some(events) = events { let views = inbox.chats.read(cx); - let public_keys: Vec = - views.iter().map(|v| v.read(cx).public_key).collect(); + let public_keys: Vec = views.iter().map(|v| v.read(cx).sender).collect(); for event in events .into_iter() .sorted_by_key(|ev| Reverse(ev.created_at)) { if !public_keys.contains(&event.pubkey) { - let view = cx.new_view(|cx| Chat::new(event, cx)); + let view = cx.new_view(|cx| InboxItem::new(event, cx)); cx.update_model(&inbox.chats, |a, b| { a.push(view); @@ -79,7 +78,7 @@ impl Inbox { }) .detach(); - cx.observe_new_views::(|chat, cx| { + cx.observe_new_views::(|chat, cx| { chat.load_metadata(cx); }) .detach();