diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index ad60073..bcb5731 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -287,21 +287,8 @@ async fn main() { }); } Signal::Event(event) => { - let metadata = async_cx - .background_executor() - .spawn(async move { - if let Ok(metadata) = - client.database().metadata(event.pubkey).await - { - metadata.unwrap_or_default() - } else { - Metadata::new() - } - }) - .await; - - _ = async_cx.update_global::(|chat, cx| { - chat.receive(event, metadata, cx) + _ = async_cx.update_global::(|state, cx| { + state.receive(event, cx) }); } } diff --git a/crates/app/src/states/chat/mod.rs b/crates/app/src/states/chat/mod.rs index 2eedbde..bf7bf47 100644 --- a/crates/app/src/states/chat/mod.rs +++ b/crates/app/src/states/chat/mod.rs @@ -1,42 +1,38 @@ -use crate::{get_client, utils::room_hash}; -use gpui::{AppContext, Context, Global, Model, SharedString, WeakModel}; +use crate::{ + get_client, + utils::{compare, room_hash}, +}; +use gpui::{AppContext, Context, Global, Model, WeakModel}; use itertools::Itertools; use nostr_sdk::prelude::*; use room::Room; -use std::{ - cmp::Reverse, - collections::HashMap, - sync::{Arc, RwLock}, -}; +use std::cmp::Reverse; pub mod room; -#[derive(Clone, Debug)] -pub struct NewMessage { - pub event: Event, - pub metadata: Metadata, +pub struct Inbox { + pub(crate) rooms: Vec>, + pub(crate) is_loading: bool, } -impl NewMessage { - pub fn new(event: Event, metadata: Metadata) -> Self { - // TODO: parse event's content - Self { event, metadata } +impl Inbox { + pub fn new() -> Self { + Self { + rooms: vec![], + is_loading: true, + } } } -type NewMessages = RwLock>>>>; - pub struct ChatRegistry { - inbox: Model>>, - new_messages: Model, + inbox: Model, } impl Global for ChatRegistry {} impl ChatRegistry { pub fn set_global(cx: &mut AppContext) { - let inbox = cx.new_model(|_| Vec::new()); - let new_messages = cx.new_model(|_| RwLock::new(HashMap::new())); + let inbox = cx.new_model(|_| Inbox::new()); cx.observe_new_models::(|this, cx| { // Get all pubkeys to load metadata @@ -76,10 +72,7 @@ impl ChatRegistry { }) .detach(); - cx.set_global(Self { - inbox, - new_messages, - }); + cx.set_global(Self { inbox }); } pub fn init(&mut self, cx: &mut AppContext) { @@ -90,6 +83,7 @@ impl ChatRegistry { let hashes: Vec = self .inbox .read(cx) + .rooms .iter() .map(|room| room.read(cx).id) .collect(); @@ -139,7 +133,9 @@ impl ChatRegistry { }) .collect(); - model.extend(items); + model.rooms.extend(items); + model.is_loading = false; + cx.notify(); }); } @@ -147,36 +143,45 @@ impl ChatRegistry { .detach(); } - pub fn inbox(&self) -> WeakModel>> { + pub fn inbox(&self) -> WeakModel { self.inbox.downgrade() } pub fn room(&self, id: &u64, cx: &AppContext) -> Option> { self.inbox .read(cx) + .rooms .iter() .find(|model| &model.read(cx).id == id) .map(|model| model.downgrade()) } - pub fn new_messages(&self) -> WeakModel { - self.new_messages.downgrade() - } + pub fn receive(&mut self, event: Event, cx: &mut AppContext) { + let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect(); + pubkeys.push(event.pubkey); - pub fn receive(&mut self, event: Event, metadata: Metadata, cx: &mut AppContext) { - let entry = room_hash(&event.tags).to_string().into(); - let message = NewMessage::new(event, metadata); + self.inbox.update(cx, |this, cx| { + if let Some(room) = this.rooms.iter().find(|room| { + let room = room.read(cx); + let mut all_keys: Vec<_> = room.members.iter().map(|m| m.public_key()).collect(); + all_keys.push(room.owner.public_key()); - self.new_messages.update(cx, |this, cx| { - this.write() - .unwrap() - .entry(entry) - .or_insert(Arc::new(RwLock::new(Vec::new()))) - .write() - .unwrap() - .push(message); + compare(&all_keys, &pubkeys) + }) { + room.update(cx, |this, cx| { + this.new_messages.push(event); + cx.notify(); + }) + } else { + let room = cx.new_model(|_| Room::new(&event)); - cx.notify(); + self.inbox.update(cx, |this, cx| { + this.rooms.insert(0, room); + cx.notify(); + }) + } + + // cx.notify(); }) } } diff --git a/crates/app/src/states/chat/room.rs b/crates/app/src/states/chat/room.rs index ec05e87..9cad6c0 100644 --- a/crates/app/src/states/chat/room.rs +++ b/crates/app/src/states/chat/room.rs @@ -54,6 +54,7 @@ pub struct Room { pub members: Vec, // Extract from event's tags pub last_seen: Timestamp, pub is_group: bool, + pub new_messages: Vec, // Hold all new messages } impl Room { @@ -87,6 +88,15 @@ impl Room { title, last_seen, is_group, + new_messages: vec![], + } + } + + pub fn metadata(&self, public_key: PublicKey) -> Metadata { + if let Some(member) = self.members.iter().find(|m| m.public_key == public_key) { + member.metadata() + } else { + Metadata::default() } } diff --git a/crates/app/src/views/app.rs b/crates/app/src/views/app.rs index 7c8261a..6662531 100644 --- a/crates/app/src/views/app.rs +++ b/crates/app/src/views/app.rs @@ -12,8 +12,9 @@ use std::sync::Arc; use ui::{ dock::{DockArea, DockItem, DockPlacement}, indicator::Indicator, + notification::NotificationType, theme::Theme, - Root, Sizable, TitleBar, + ContextModal, Root, Sizable, TitleBar, }; #[derive(Clone, PartialEq, Eq, Deserialize)] @@ -141,6 +142,11 @@ impl AppView { self.dock.update(cx, |dock_area, cx| { dock_area.add_panel(panel, action.position, cx); }); + } else { + cx.push_notification(( + NotificationType::Error, + "System error. Cannot open this chat room.", + )); } } } diff --git a/crates/app/src/views/chat/mod.rs b/crates/app/src/views/chat/mod.rs index a0f7742..35e45dd 100644 --- a/crates/app/src/views/chat/mod.rs +++ b/crates/app/src/views/chat/mod.rs @@ -1,12 +1,9 @@ -use crate::{ - get_client, - states::chat::room::{Member, Room}, - utils::compare, -}; +use crate::{get_client, states::chat::room::Room, utils::compare}; use gpui::{ div, list, px, AnyElement, AppContext, Context, EventEmitter, Flatten, FocusHandle, FocusableView, IntoElement, ListAlignment, ListState, Model, ParentElement, PathPromptOptions, - Pixels, Render, SharedString, Styled, View, ViewContext, VisualContext, WindowContext, + Pixels, Render, SharedString, Styled, View, ViewContext, VisualContext, WeakModel, + WindowContext, }; use itertools::Itertools; use message::RoomMessage; @@ -31,25 +28,22 @@ pub struct State { pub struct ChatPanel { // Panel - name: SharedString, closeable: bool, zoomable: bool, focus_handle: FocusHandle, // Chat Room id: SharedString, - owner: Member, - members: Arc<[Member]>, - input: View, - list: ListState, + name: SharedString, + room: Model, state: Model, + list: ListState, + input: View, } impl ChatPanel { - pub fn new(room: Model, cx: &mut WindowContext) -> View { - let room = room.read(cx); + pub fn new(model: Model, cx: &mut WindowContext) -> View { + let room = model.read(cx); let id = room.id.to_string().into(); - let owner = room.owner.clone(); - let members = room.members.clone().into(); let name = room.title.clone().unwrap_or("Untitled".into()); cx.observe_new_views::(|this, cx| { @@ -67,7 +61,7 @@ impl ChatPanel { .cleanable() }); - // Send message when user presses enter on form. + // Send message when user presses enter cx.subscribe(&input, move |this: &mut ChatPanel, _, input_event, cx| { if let InputEvent::PressEnter = input_event { this.send_message(cx); @@ -80,6 +74,7 @@ impl ChatPanel { items: vec![], }); + // Update list on every state changes cx.observe(&state, |this, model, cx| { let items = model.read(cx).items.clone(); @@ -97,32 +92,35 @@ impl ChatPanel { }) .detach(); - let list = ListState::new(0, ListAlignment::Bottom, Pixels(256.), move |_, _| { - div().into_any_element() - }); + cx.observe(&model, |this, model, cx| { + this.load_new_messages(model.downgrade(), cx); + }) + .detach(); Self { closeable: true, zoomable: true, focus_handle: cx.focus_handle(), + room: model, + list: ListState::new(0, ListAlignment::Bottom, Pixels(256.), move |_, _| { + div().into_any_element() + }), id, - owner, - members, name, input, - list, state, } }) } fn load_messages(&self, cx: &mut ViewContext) { - let members = Arc::clone(&self.members); - let owner = self.owner.clone(); + let room = self.room.read(cx); + let members = room.members.clone(); + let owner = room.owner.clone(); // Get all public keys - let mut all_keys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect(); - all_keys.push(self.owner.public_key()); + let mut all_keys: Vec<_> = room.members.iter().map(|m| m.public_key()).collect(); + all_keys.push(room.owner.public_key()); // Async let async_state = self.state.clone(); @@ -201,11 +199,38 @@ impl ChatPanel { .detach(); } - fn send_message(&mut self, cx: &mut ViewContext) { - let content = Arc::new(self.input.read(cx).text().to_string()); - let owner = self.owner.clone(); + fn load_new_messages(&self, model: WeakModel, cx: &mut ViewContext) { + if let Some(model) = model.upgrade() { + let room = model.read(cx); + let items: Vec = room + .new_messages + .iter() + .map(|event| { + let metadata = room.metadata(event.pubkey); - let mut members = self.members.to_vec(); + RoomMessage::new( + event.pubkey, + metadata, + event.content.clone(), + event.created_at, + ) + }) + .collect(); + + cx.update_model(&self.state, |model, cx| { + model.items.extend(items); + model.count = model.items.len(); + cx.notify(); + }); + } + } + + fn send_message(&mut self, cx: &mut ViewContext) { + let room = self.room.read(cx); + let content = Arc::new(self.input.read(cx).text().to_string()); + let owner = room.owner.clone(); + + let mut members = room.members.to_vec(); members.push(owner.clone()); // Async diff --git a/crates/app/src/views/chat/room.rs b/crates/app/src/views/chat/room.rs deleted file mode 100644 index 76c6c9c..0000000 --- a/crates/app/src/views/chat/room.rs +++ /dev/null @@ -1,351 +0,0 @@ -use gpui::{ - div, list, px, Context, Flatten, IntoElement, ListAlignment, ListState, Model, ParentElement, - PathPromptOptions, Pixels, Render, SharedString, Styled, View, ViewContext, VisualContext, -}; -use itertools::Itertools; -use nostr_sdk::prelude::*; -use std::sync::Arc; -use ui::{ - button::{Button, ButtonVariants}, - input::{InputEvent, TextInput}, - theme::ActiveTheme, - v_flex, Icon, IconName, -}; - -use super::message::RoomMessage; -use crate::{ - get_client, - states::chat::{ChatRegistry, Member, Room}, -}; - -#[derive(Clone)] -pub struct Messages { - count: usize, - items: Vec, -} - -pub struct RoomPanel { - id: SharedString, - owner: PublicKey, - members: Vec, - // Form - input: View, - // Messages - list: ListState, - messages: Model, -} - -impl RoomPanel { - pub fn new(room: &Arc, cx: &mut ViewContext<'_, Self>) -> Self { - let id = room.id.clone(); - let members = room.members.clone(); - let owner = room.owner; - - // Form - let input = cx.new_view(|cx| { - TextInput::new(cx) - .appearance(false) - .text_size(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 { - id, - 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(); - - let public_keys: Vec = self.members.iter().map(|m| m.public_key()).collect(); - - cx.foreground_executor() - .spawn({ - let client = get_client(); - let owner = self.owner; - - let recv = Filter::new() - .kind(Kind::PrivateDirectMessage) - .author(owner) - .pubkeys(public_keys.clone()); - - let send = Filter::new() - .kind(Kind::PrivateDirectMessage) - .authors(public_keys) - .pubkey(owner); - - async move { - let events = async_cx - .background_executor() - .spawn(async move { client.database().query(vec![recv, send]).await }) - .await; - - if let Ok(events) = events { - let mut items: Vec = Vec::new(); - - for event in events.into_iter().sorted_by_key(|ev| ev.created_at) { - let metadata = async_cx - .background_executor() - .spawn( - async move { client.database().metadata(event.pubkey).await }, - ) - .await; - - let message = if let Ok(metadata) = metadata { - RoomMessage::new( - event.pubkey, - metadata, - event.content, - event.created_at, - ) - } else { - RoomMessage::new( - event.pubkey, - None, - event.content, - event.created_at, - ) - }; - - items.push(message); - } - - 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 room_id = self.id.clone(); - let messages = self.messages.clone(); - let state = cx.global::().messages(); - - if let Some(state) = state.upgrade() { - cx.observe(&state, move |_, model, cx| { - let new_messages = model.read(cx).read().unwrap().get(&room_id).cloned(); - - if let Some(new_messages) = new_messages { - let items: Vec = new_messages - .read() - .unwrap() - .clone() - .into_iter() - .map(|m| { - RoomMessage::new( - m.event.pubkey, - m.metadata, - m.event.content, - m.event.created_at, - ) - }) - .collect(); - - cx.update_model(&messages, |model, cx| { - model.items.extend(items); - model.count = model.items.len(); - cx.notify(); - }); - } - }) - .detach(); - } - } - - fn send_message(&mut self, cx: &mut ViewContext) { - let members: Vec = self.members.iter().map(|m| m.public_key()).collect(); - let members2 = members.clone(); - - let content = self.input.read(cx).text().to_string(); - let content2 = content.clone(); - let content3 = content2.clone(); - - let async_input = self.input.clone(); - let async_messages = self.messages.clone(); - let mut async_cx = cx.to_async(); - - cx.foreground_executor() - .spawn({ - let client = get_client(); - - async move { - let current_user = async_cx - .background_executor() - .spawn(async move { - let signer = client.signer().await.unwrap(); - signer.get_public_key().await.unwrap() - }) - .await; - - // Send message to all members - async_cx - .background_executor() - .spawn(async move { - let extra_tags: Vec = members - .iter() - .filter_map(|m| { - if m != ¤t_user { - Some(Tag::public_key(*m)) - } else { - None - } - }) - .collect(); - - for member in members.iter() { - _ = client - .send_private_msg(*member, &content, extra_tags.clone()) - .await - } - }) - .detach(); - - // Send a copy to yourself - async_cx - .background_executor() - .spawn(async move { - let extra_tags: Vec = members2 - .iter() - .filter_map(|m| { - if m != ¤t_user { - Some(Tag::public_key(*m)) - } else { - None - } - }) - .collect(); - - _ = client - .send_private_msg(current_user, content2, extra_tags) - .await; - }) - .detach(); - - // Create a new room message - let new_message: anyhow::Result = async_cx - .background_executor() - .spawn(async move { - let metadata = client.database().metadata(current_user).await?; - let created_at = Timestamp::now(); - let message = - RoomMessage::new(current_user, metadata, content3, created_at); - - Ok(message) - }) - .await; - - if let Ok(message) = new_message { - _ = async_cx.update_model(&async_messages, |model, cx| { - model.items.extend(vec![message]); - model.count = model.items.len(); - cx.notify(); - }); - - _ = async_cx.update_view(&async_input, |input, cx| { - input.set_text("", cx); - }); - } - } - }) - .detach(); - } -} - -impl Render for RoomPanel { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - v_flex() - .size_full() - .child(list(self.list.clone()).flex_1()) - .child( - div() - .flex_shrink_0() - .w_full() - .h_12() - .flex() - .items_center() - .gap_2() - .px_2() - .child( - Button::new("upload") - .icon(Icon::new(IconName::Upload)) - .ghost() - .on_click(|_, cx| { - let paths = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - }); - - cx.spawn(move |_async_cx| async move { - match Flatten::flatten(paths.await.map_err(|e| e.into())) { - Ok(Some(paths)) => { - // TODO: upload file to blossom server - println!("Paths: {:?}", paths) - } - Ok(None) => {} - Err(_) => {} - } - }) - .detach(); - }), - ) - .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/sidebar/inbox.rs b/crates/app/src/views/sidebar/inbox.rs index 374425b..122af61 100644 --- a/crates/app/src/views/sidebar/inbox.rs +++ b/crates/app/src/views/sidebar/inbox.rs @@ -40,77 +40,87 @@ impl Inbox { let weak_model = cx.global::().inbox(); if let Some(model) = weak_model.upgrade() { - div().children(model.read(cx).iter().map(|model| { - let room = model.read(cx); - let id = room.id; - let room_id: SharedString = id.to_string().into(); - let ago: SharedString = ago(room.last_seen.as_u64()).into(); - let is_group = room.is_group; - // Get first member - let sender = room.members.first().unwrap(); - // Compute group name based on member' names - let name: SharedString = room - .members - .iter() - .map(|profile| profile.name()) - .collect::>() - .join(", ") - .into(); + div().map(|this| { + let inbox = model.read(cx); + + if inbox.is_loading { + this.children(self.render_skeleton(5)) + } else { + this.children(inbox.rooms.iter().map(|model| { + let room = model.read(cx); + let id = room.id; + let room_id: SharedString = id.to_string().into(); + let ago: SharedString = ago(room.last_seen.as_u64()).into(); + let is_group = room.is_group; + // Get first member + let sender = room.members.first().unwrap(); + // Compute group name based on member' names + let name: SharedString = room + .members + .iter() + .map(|profile| profile.name()) + .collect::>() + .join(", ") + .into(); - div() - .id(room_id) - .h_8() - .px_1() - .flex() - .items_center() - .justify_between() - .text_xs() - .rounded_md() - .hover(|this| { - this.bg(cx.theme().sidebar_accent) - .text_color(cx.theme().sidebar_accent_foreground) - }) - .child( div() - .font_medium() - .text_color(cx.theme().sidebar_accent_foreground) - .map(|this| { - if is_group { - this.flex() - .items_center() - .gap_2() - .child(img("brand/avatar.png").size_6().rounded_full()) - .child(name) - } else { - this.flex() - .items_center() - .gap_2() - .child( - img(format!( - "{}/?url={}&w=72&h=72&fit=cover&mask=circle&n=-1", - IMAGE_SERVICE, - sender - .metadata() - .picture - .unwrap_or("brand/avatar.png".into()) - )) - .flex_shrink_0() - .size_6() - .rounded_full(), - ) - .child(sender.name()) - } - }), - ) - .child( - div() - .child(ago) - .text_color(cx.theme().sidebar_accent_foreground.opacity(0.7)), - ) - .on_click(cx.listener(move |this, _, cx| { - this.action(id, cx); + .id(room_id) + .h_8() + .px_1() + .flex() + .items_center() + .justify_between() + .text_xs() + .rounded_md() + .hover(|this| { + this.bg(cx.theme().sidebar_accent) + .text_color(cx.theme().sidebar_accent_foreground) + }) + .child( + div() + .font_medium() + .text_color(cx.theme().sidebar_accent_foreground) + .map(|this| { + if is_group { + this.flex() + .items_center() + .gap_2() + .child( + img("brand/avatar.png").size_6().rounded_full(), + ) + .child(name) + } else { + this.flex() + .items_center() + .gap_2() + .child( + img(format!( + "{}/?url={}&w=72&h=72&fit=cover&mask=circle&n=-1", + IMAGE_SERVICE, + sender + .metadata() + .picture + .unwrap_or("brand/avatar.png".into()) + )) + .flex_shrink_0() + .size_6() + .rounded_full(), + ) + .child(sender.name()) + } + }), + ) + .child( + div() + .child(ago) + .text_color(cx.theme().sidebar_accent_foreground.opacity(0.7)), + ) + .on_click(cx.listener(move |this, _, cx| { + this.action(id, cx); + })) })) - })) + } + }) } else { div().children(self.render_skeleton(5)) }