diff --git a/Cargo.lock b/Cargo.lock index aea12be..2dfb4df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,7 +178,7 @@ dependencies = [ [[package]] name = "assets" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "gpui", @@ -417,7 +417,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto_update" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "cargo-packager-updater", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "client_keys" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "global", @@ -1123,7 +1123,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1158,7 +1158,7 @@ dependencies = [ [[package]] name = "common" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "chrono", @@ -1214,7 +1214,7 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] name = "coop" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "assets", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "proc-macro2", "quote", @@ -2342,7 +2342,7 @@ checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "global" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "dirs 5.0.1", @@ -2436,7 +2436,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2529,7 +2529,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2541,7 +2541,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "gpui", "tokio", @@ -2772,7 +2772,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "anyhow", "bytes", @@ -2792,7 +2792,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2886,7 +2886,7 @@ dependencies = [ [[package]] name = "i18n" -version = "0.2.1" +version = "0.2.2" dependencies = [ "rust-i18n", ] @@ -3003,7 +3003,7 @@ dependencies = [ [[package]] name = "identity" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "client_keys", @@ -3594,7 +3594,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -4632,9 +4632,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" dependencies = [ "unicode-ident", ] @@ -4984,7 +4984,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "derive_refineable", "workspace-hack", @@ -5021,7 +5021,7 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "registry" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "chrono", @@ -5029,13 +5029,11 @@ dependencies = [ "fuzzy-matcher", "global", "gpui", - "i18n", "itertools 0.13.0", "log", "nostr", "nostr-sdk", "oneshot", - "rust-i18n", "settings", "smallvec", "smol", @@ -5142,7 +5140,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "anyhow", "bytes", @@ -5678,7 +5676,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "anyhow", "serde", @@ -5813,7 +5811,7 @@ dependencies = [ [[package]] name = "settings" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "global", @@ -6073,7 +6071,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "arrayvec", "log", @@ -6376,7 +6374,7 @@ dependencies = [ [[package]] name = "theme" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "gpui", @@ -6536,7 +6534,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "title_bar" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "common", @@ -6907,7 +6905,7 @@ dependencies = [ [[package]] name = "ui" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "common", @@ -7107,7 +7105,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#2d9cd2ac8888a144ef41e59c9820ffbecee66ed1" +source = "git+https://github.com/zed-industries/zed#308cb9e537eda81b35bfccef00e2ef7be8d070d1" dependencies = [ "anyhow", "async-fs", diff --git a/assets/icons/sent.svg b/assets/icons/sent.svg new file mode 100644 index 0000000..3c95e14 --- /dev/null +++ b/assets/icons/sent.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 90b33f2..6a3495b 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -155,12 +155,19 @@ impl ChatSpace { if let Some(room) = room.upgrade() { this.dock.update(cx, |this, cx| { let panel = chat::init(room, window, cx); - // Load messages on panel creation + + // Load messages when the panel is created panel.update(cx, |this, cx| { this.load_messages(window, cx); }); - this.add_panel(panel, DockPlacement::Center, window, cx); + // Add the panel to the center dock (tabs) + this.add_panel( + Arc::new(panel), + DockPlacement::Center, + window, + cx, + ); }); } else { window.push_notification(t!("common.room_error"), cx); diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index c77c976..de1eac2 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -148,7 +148,7 @@ fn main() { match smol::future::or(recv(), timeout()).await { Some(event) => { - let cached = try_unwrap_event(&event, &signal_tx, &mta_tx).await; + let cached = unwrap_gift(&event, &signal_tx, &mta_tx).await; // Increment the total messages counter if message is not from cache if !cached { @@ -521,21 +521,21 @@ async fn get_unwrapped(root: EventId) -> Result { } /// Unwraps a gift-wrapped event and processes its contents. -async fn try_unwrap_event( - event: &Event, +async fn unwrap_gift( + gift: &Event, signal_tx: &Sender, mta_tx: &Sender, ) -> bool { let client = nostr_client(); let mut is_cached = false; - let event = match get_unwrapped(event.id).await { + let event = match get_unwrapped(gift.id).await { Ok(event) => { is_cached = true; event } Err(_) => { - match client.unwrap_gift_wrap(event).await { + match client.unwrap_gift_wrap(gift).await { Ok(unwrap) => { // Sign the unwrapped event with a RANDOM KEYS let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else { @@ -544,7 +544,7 @@ async fn try_unwrap_event( }; // Save this event to the database for future use. - if let Err(e) = set_unwrapped(event.id, &unwrapped).await { + if let Err(e) = set_unwrapped(gift.id, &unwrapped).await { log::warn!("Failed to cache unwrapped event: {e}") } diff --git a/crates/coop/src/views/chat/mod.rs b/crates/coop/src/views/chat/mod.rs index efdd99d..e85016d 100644 --- a/crates/coop/src/views/chat/mod.rs +++ b/crates/coop/src/views/chat/mod.rs @@ -1,6 +1,4 @@ use std::collections::{BTreeSet, HashMap}; -use std::rc::Rc; -use std::sync::Arc; use anyhow::anyhow; use common::display::DisplayProfile; @@ -8,19 +6,19 @@ use common::nip96::nip96_upload; use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ - div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem, - Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, - IntoElement, ListAlignment, ListState, MouseButton, ObjectFit, ParentElement, - PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, - Styled, StyledImage, Subscription, Window, + div, img, list, px, red, relative, rems, svg, white, Action, AnyElement, App, AppContext, + ClipboardItem, Context, Div, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, + InteractiveElement, IntoElement, ListAlignment, ListState, MouseButton, ObjectFit, + ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, Stateful, + StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; use gpui_tokio::Tokio; -use i18n::t; +use i18n::{shared_t, t}; use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; -use registry::message::Message; -use registry::room::{Room, RoomKind, RoomSignal, SendError}; +use registry::message::RenderedMessage; +use registry::room::{Room, RoomKind, RoomSignal, SendReport}; use registry::Registry; use serde::Deserialize; use settings::AppSettings; @@ -35,22 +33,20 @@ use ui::input::{InputEvent, InputState, TextInput}; use ui::modal::ModalButtonProps; use ui::notification::Notification; use ui::popup_menu::PopupMenu; -use ui::text::RichText; +use ui::text::RenderedText; use ui::{ - v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, + h_flex, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, + StyledExt, }; mod subject; -const DUPLICATE_TIME_WINDOW: u64 = 10; -const MAX_RECENT_MESSAGES_TO_CHECK: usize = 5; - #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = chat, no_json)] pub struct ChangeSubject(pub String); -pub fn init(room: Entity, window: &mut Window, cx: &mut App) -> Arc> { - Arc::new(Chat::new(room, window, cx)) +pub fn init(room: Entity, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Chat::new(room, window, cx)) } pub struct Chat { @@ -59,26 +55,28 @@ pub struct Chat { focus_handle: FocusHandle, // Chat Room room: Entity, - messages: Entity>, - text_data: HashMap, list_state: ListState, + messages: BTreeSet, + rendered_texts_by_id: HashMap, + reports_by_id: HashMap>, // New Message input: Entity, - replies_to: Entity>>, + replies_to: Entity>, + sending: bool, // Media Attachment - attaches: Entity>>, + attachments: Entity>, uploading: bool, // System image_cache: Entity, #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 2]>, + subscriptions: SmallVec<[Subscription; 3]>, } impl Chat { - pub fn new(room: Entity, window: &mut Window, cx: &mut App) -> Entity { - let attaches = cx.new(|_| None); - let replies_to = cx.new(|_| None); - let messages = cx.new(|_| BTreeSet::new()); + pub fn new(room: Entity, window: &mut Window, cx: &mut Context) -> Self { + let attachments = cx.new(|_| vec![]); + let replies_to = cx.new(|_| vec![]); + let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.)); let input = cx.new(|cx| { InputState::new(window, cx) @@ -91,72 +89,69 @@ impl Chat { .clean_on_escape() }); - cx.new(|cx| { - let mut subscriptions = smallvec![]; + let mut subscriptions = smallvec![]; - subscriptions.push(cx.subscribe_in( - &input, - window, - move |this: &mut Self, input, event, window, cx| { - match event { - InputEvent::PressEnter { .. } => { - this.send_message(window, cx); - } - InputEvent::Change(text) => { - this.mention_popup(text, input, cx); - } - _ => {} - }; - }, - )); + subscriptions.push(cx.on_release_in(window, move |this, _window, cx| { + this.messages.clear(); + this.rendered_texts_by_id.clear(); + this.reports_by_id.clear(); - subscriptions.push(cx.subscribe_in( - &room, - window, - move |this, _, signal, window, cx| { - match signal { - RoomSignal::NewMessage(event) => { - // Check if the incoming message is the same as the new message created by optimistic update - if this.prevent_duplicate_message(event, cx) { - return; - } + this.attachments.update(cx, |this, _cx| { + this.clear(); + }); - let old_len = this.messages.read(cx).len(); - let message = event.to_owned(); + this.replies_to.update(cx, |this, _cx| { + this.clear(); + }) + })); - cx.update_entity(&this.messages, |this, cx| { - this.insert(message); - cx.notify(); - }); + subscriptions.push(cx.subscribe_in( + &input, + window, + move |this: &mut Self, _input, event, window, cx| { + match event { + InputEvent::PressEnter { .. } => { + this.send_message(window, cx); + } + InputEvent::Change(_) => { + // this.mention_popup(text, input, cx); + } + _ => {} + }; + }, + )); - this.list_state.splice(old_len..old_len, 1); - } - RoomSignal::Refresh => { - this.load_messages(window, cx); - } - }; - }, - )); + subscriptions.push( + cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { + match signal { + RoomSignal::NewMessage(event) => { + if !this.is_seen_message(event) { + this.insert_message(event, cx); + }; + } + RoomSignal::Refresh => { + this.load_messages(window, cx); + } + }; + }), + ); - // Initialize list state - // [item_count] always equal to 1 at the beginning - let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.)); - - Self { - id: room.read(cx).id.to_string().into(), - image_cache: RetainAllImageCache::new(cx), - focus_handle: cx.focus_handle(), - uploading: false, - text_data: HashMap::new(), - room, - messages, - list_state, - input, - replies_to, - attaches, - subscriptions, - } - }) + Self { + id: room.read(cx).id.to_string().into(), + image_cache: RetainAllImageCache::new(cx), + focus_handle: cx.focus_handle(), + uploading: false, + sending: false, + messages: BTreeSet::new(), + rendered_texts_by_id: HashMap::new(), + reports_by_id: HashMap::new(), + room, + list_state, + input, + replies_to, + attachments, + subscriptions, + } } /// Load all messages belonging to this room @@ -166,21 +161,9 @@ impl Chat { cx.spawn_in(window, async move |this, cx| { match load_messages.await { - Ok(messages) => { + Ok(events) => { this.update(cx, |this, cx| { - let old_len = this.messages.read(cx).len(); - let new_len = messages.len(); - - // Extend the messages list with the new events - this.messages.update(cx, |this, cx| { - this.extend(messages); - cx.notify(); - }); - - // Update list state with the new messages - this.list_state.splice(old_len..old_len, new_len); - - cx.notify(); + this.insert_messages(events, cx); }) .ok(); } @@ -190,11 +173,12 @@ impl Chat { }) .ok(); } - } + }; }) .detach(); } + #[allow(dead_code)] fn mention_popup(&mut self, _text: &str, _input: &Entity, _cx: &mut Context) { // TODO: open mention popup at current cursor position } @@ -204,62 +188,49 @@ impl Chat { let mut content = self.input.read(cx).value().trim().to_string(); // Get all attaches and merge its with message - if let Some(attaches) = self.attaches.read(cx).as_ref() { - if !attaches.is_empty() { - content = format!( - "{}\n{}", - content, - attaches - .iter() - .map(|url| url.to_string()) - .collect_vec() - .join("\n") - ) - } + let attachments = self.attachments.read(cx); + + if !attachments.is_empty() { + content = format!( + "{}\n{}", + content, + attachments + .iter() + .map(|url| url.to_string()) + .collect_vec() + .join("\n") + ) } content } - // TODO: find a better way to prevent duplicate messages during optimistic updates - fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context) -> bool { - let Some(identity) = Identity::read_global(cx).public_key() else { - return false; - }; - - if new_msg.author != identity { - return false; + /// Check if the event is a seen message + fn is_seen_message(&self, event: &Event) -> bool { + if let Some(message) = self.messages.last() { + let duration = event.created_at.as_u64() - message.created_at.as_u64(); + message.content == event.content && message.author == event.pubkey && duration <= 20 + } else { + false } - - let messages = self.messages.read(cx); - let min_timestamp = new_msg - .created_at - .as_u64() - .saturating_sub(DUPLICATE_TIME_WINDOW); - - messages - .iter() - .rev() - .take(MAX_RECENT_MESSAGES_TO_CHECK) - .filter(|m| m.author == identity) - .any(|existing| { - // Check if messages are within the time window - (existing.created_at.as_u64() >= min_timestamp) && - // Compare content and author - (existing.content == new_msg.content) && - (existing.author == new_msg.author) - }) } + /// Set the sending state of the chat panel + fn set_sending(&mut self, sending: bool, cx: &mut Context) { + self.sending = sending; + cx.notify(); + } + + /// Send a message to all members of the chat fn send_message(&mut self, window: &mut Window, cx: &mut Context) { // Return if user is not logged in let Some(identity) = Identity::read_global(cx).public_key() else { - // window.push_notification("Login is required", cx); return; }; // Get the message which includes all attachments let content = self.input_content(cx); + // Get the backup setting let backup = AppSettings::get_backup_messages(cx); @@ -269,6 +240,9 @@ impl Chat { return; } + // Mark sending in progress + self.set_sending(true, cx); + // Temporary disable input self.input.update(cx, |this, cx| { this.set_loading(true, cx); @@ -276,119 +250,156 @@ impl Chat { }); // Get replies_to if it's present - let replies = self.replies_to.read(cx).as_ref(); + let replies = self.replies_to.read(cx).clone(); // Get the current room entity let room = self.room.read(cx); // Create a temporary message for optimistic update - let temp_message = room.create_temp_message(identity, &content, replies); + let temp_message = room.create_temp_message(identity, &content, replies.as_ref()); + let temp_id = temp_message.id.unwrap(); // Create a task for sending the message in the background let send_message = room.send_in_background(&content, replies, backup, cx); - if let Some(message) = temp_message { - let id = message.id; - // Optimistically update message list - self.insert_message(message, cx); - // Remove all replies - self.remove_all_replies(cx); + // Optimistically update message list + self.insert_message(temp_message, cx); - // Reset the input state - self.input.update(cx, |this, cx| { - this.set_loading(false, cx); - this.set_disabled(false, cx); - this.set_value("", window, cx); - }); + // Remove all replies + self.remove_all_replies(cx); - // Continue sending the message in the background - cx.spawn_in(window, async move |this, cx| { - if let Ok(reports) = send_message.await { - if !reports.is_empty() { - this.update(cx, |this, cx| { - this.room.update(cx, |this, cx| { - if this.kind != RoomKind::Ongoing { - this.kind = RoomKind::Ongoing; - cx.notify(); - } - }); - - this.messages.update(cx, |this, cx| { - if let Some(mut msg) = this.iter().find(|msg| msg.id == id).cloned() - { - msg.errors = Some(reports); - cx.notify(); - } - }); - }) - .ok(); - } - } - }) - .detach(); - } - } - - fn insert_message(&self, message: Message, cx: &mut Context) { - let old_len = self.messages.read(cx).len(); - - cx.update_entity(&self.messages, |this, cx| { - this.insert(message); - cx.notify(); + // Reset the input state + self.input.update(cx, |this, cx| { + this.set_loading(false, cx); + this.set_disabled(false, cx); + this.set_value("", window, cx); }); - self.list_state.splice(old_len..old_len, 1); + // Continue sending the message in the background + cx.spawn_in(window, async move |this, cx| { + match send_message.await { + Ok(reports) => { + this.update(cx, |this, cx| { + // Don't change the room kind if send failed + this.room.update(cx, |this, cx| { + if this.kind != RoomKind::Ongoing { + this.kind = RoomKind::Ongoing; + cx.notify(); + } + }); + this.reports_by_id.insert(temp_id, reports); + this.sending = false; + cx.notify(); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(e.to_string(), cx); + }) + .ok(); + } + } + }) + .detach(); } - fn scroll_to(&self, id: EventId, cx: &Context) { - if let Some(ix) = self.messages.read(cx).iter().position(|m| m.id == id) { + /// Check if a message failed to send by its ID + fn is_sent_failed(&self, id: &EventId) -> bool { + self.reports_by_id + .get(id) + .is_some_and(|reports| reports.iter().all(|r| !r.is_sent_success())) + } + + /// Check if a message was sent successfully by its ID + fn is_sent_success(&self, id: &EventId) -> Option { + self.reports_by_id + .get(id) + .map(|reports| reports.iter().all(|r| r.is_sent_success())) + } + + /// Get the sent reports for a message by its ID + fn sent_reports(&self, id: &EventId) -> Option<&Vec> { + self.reports_by_id.get(id) + } + + /// Get a message by its ID + fn message(&self, id: &EventId) -> Option<&RenderedMessage> { + self.messages.iter().find(|m| m.id == *id) + } + + fn insert_message(&mut self, event: E, cx: &mut Context) + where + E: Into, + { + let old_len = self.messages.len(); + let new_len = 1; + + // Extend the messages list with the new events + self.messages.insert(event.into()); + + // Update list state with the new messages + self.list_state.splice(old_len..old_len, new_len); + + cx.notify(); + } + + fn insert_messages(&mut self, events: E, cx: &mut Context) + where + E: IntoIterator, + E::Item: Into, + { + let old_len = self.messages.len(); + let events: Vec<_> = events.into_iter().map(Into::into).collect(); + let new_len = events.len(); + + // Extend the messages list with the new events + self.messages.extend(events); + + // Update list state with the new messages + self.list_state.splice(old_len..old_len, new_len); + + cx.notify(); + } + + fn profile(&self, public_key: &PublicKey, cx: &Context) -> Profile { + let registry = Registry::read_global(cx); + registry.get_person(public_key, cx) + } + + fn scroll_to(&self, id: EventId) { + if let Some(ix) = self.messages.iter().position(|m| m.id == id) { self.list_state.scroll_to_reveal_item(ix); } } - fn copy_message(&self, ix: usize, cx: &Context) { - let Some(item) = self - .messages - .read(cx) - .iter() - .nth(ix) - .map(|m| ClipboardItem::new_string(m.content.to_string())) - else { - return; - }; - - cx.write_to_clipboard(item); + fn copy_message(&self, id: &EventId, cx: &Context) { + if let Some(message) = self.message(id) { + cx.write_to_clipboard(ClipboardItem::new_string(message.content.to_string())); + } } - fn reply_to(&mut self, ix: usize, cx: &mut Context) { - let Some(message) = self.messages.read(cx).iter().nth(ix).map(|m| m.to_owned()) else { - return; - }; - - self.replies_to.update(cx, |this, cx| { - if let Some(replies) = this { - replies.push(message); - } else { - *this = Some(vec![message]) - } - cx.notify(); - }); + fn reply_to(&mut self, id: &EventId, cx: &mut Context) { + if let Some(text) = self.message(id) { + self.replies_to.update(cx, |this, cx| { + this.push(text.id); + cx.notify(); + }); + } } - fn remove_reply(&mut self, id: EventId, cx: &mut Context) { + fn remove_reply(&mut self, id: &EventId, cx: &mut Context) { self.replies_to.update(cx, |this, cx| { - if let Some(replies) = this { - if let Some(ix) = replies.iter().position(|m| m.id == id) { - replies.remove(ix); - cx.notify(); - } + if let Some(ix) = this.iter().position(|this| this == id) { + this.remove(ix); + cx.notify(); } }); } fn remove_all_replies(&mut self, cx: &mut Context) { self.replies_to.update(cx, |this, cx| { - *this = None; + *this = vec![]; cx.notify(); }); } @@ -458,24 +469,18 @@ impl Chat { } fn add_attachment(&mut self, url: Url, cx: &mut Context) { - self.attaches.update(cx, |this, cx| { - if let Some(model) = this.as_mut() { - model.push(url); - } else { - *this = Some(vec![url]); - } + self.attachments.update(cx, |this, cx| { + this.push(url); cx.notify(); }); self.uploading(false, cx); } fn remove_attachment(&mut self, url: &Url, _window: &mut Window, cx: &mut Context) { - self.attaches.update(cx, |model, cx| { - if let Some(urls) = model.as_mut() { - if let Some(ix) = urls.iter().position(|x| x == url) { - urls.remove(ix); - cx.notify(); - } + self.attachments.update(cx, |this, cx| { + if let Some(ix) = this.iter().position(|this| this == url) { + this.remove(ix); + cx.notify(); } }); } @@ -485,7 +490,415 @@ impl Chat { cx.notify(); } - fn render_attach(&mut self, url: &Url, cx: &Context) -> impl IntoElement { + fn render_announcement(&mut self, ix: usize, cx: &mut Context) -> Stateful
{ + v_flex() + .id(ix) + .group("") + .h_32() + .w_full() + .relative() + .gap_3() + .px_3() + .py_2() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().text_placeholder) + .line_height(relative(1.3)) + .child( + svg() + .path("brand/coop.svg") + .size_10() + .text_color(cx.theme().elevated_surface_background), + ) + .child(shared_t!("chat.notice")) + } + + fn render_message( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> Stateful
{ + let Some(message) = self.messages.iter().nth(ix) else { + return div().id(ix); + }; + + let proxy = AppSettings::get_proxy_user_avatars(cx); + let hide_avatar = AppSettings::get_hide_user_avatars(cx); + + let id = message.id; + let author = self.profile(&message.author, cx); + + let replies = message.replies_to.as_slice(); + let has_replies = !replies.is_empty(); + + // Check if message is sent failed + let is_sent_failed = self.is_sent_failed(&id); + + // Check if message is sent successfully + let is_sent_success = self.is_sent_success(&id); + + // Get or insert rendered text + let rendered_text = self + .rendered_texts_by_id + .entry(id) + .or_insert_with(|| RenderedText::new(&message.content, cx)) + .element(ix.into(), window, cx); + + div() + .id(ix) + .group("") + .relative() + .w_full() + .py_1() + .px_3() + .child( + div() + .flex() + .gap_3() + .when(!hide_avatar, |this| { + this.child(Avatar::new(author.avatar_url(proxy)).size(rems(2.))) + }) + .child( + v_flex() + .flex_1() + .w_full() + .flex_initial() + .overflow_hidden() + .child( + div() + .flex() + .items_center() + .gap_2() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child( + div() + .font_semibold() + .text_color(cx.theme().text) + .child(author.display_name()), + ) + .child(div().child(message.ago())) + .when_some(is_sent_success, |this, status| { + this.when(status, |this| { + this.child(self.render_message_sent(&id, cx)) + }) + }), + ) + .when(has_replies, |this| { + this.children(self.render_message_replies(replies, cx)) + }) + .child(rendered_text) + .when(is_sent_failed, |this| { + this.child(self.render_message_reports(&id, cx)) + }), + ), + ) + .child(self.render_border(cx)) + .child(self.render_actions(&id, cx)) + .on_mouse_down( + MouseButton::Middle, + cx.listener(move |this, _event, _window, cx| { + this.copy_message(&id, cx); + }), + ) + .on_double_click(cx.listener({ + move |this, _event, _window, cx| { + this.reply_to(&id, cx); + } + })) + .hover(|this| this.bg(cx.theme().surface_background)) + } + + fn render_message_replies( + &self, + replies: &[EventId], + cx: &Context, + ) -> impl IntoIterator { + let mut items = Vec::with_capacity(replies.len()); + + for (ix, id) in replies.iter().enumerate() { + let Some(message) = self.message(id) else { + continue; + }; + let author = self.profile(&message.author, cx); + + items.push( + div() + .id(ix) + .w_full() + .px_2() + .border_l_2() + .border_color(cx.theme().element_selected) + .text_sm() + .child( + div() + .text_color(cx.theme().text_accent) + .child(author.display_name()), + ) + .child( + div() + .w_full() + .text_ellipsis() + .line_clamp(1) + .child(message.content.clone()), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click({ + let id = *id; + cx.listener(move |this, _event, _window, _cx| { + this.scroll_to(id); + }) + }), + ); + } + + items + } + + fn render_message_sent(&self, id: &EventId, _cx: &Context) -> impl IntoElement { + div().id("").child(shared_t!("chat.sent")).when_some( + self.sent_reports(id).cloned(), + |this, reports| { + this.on_click(move |_e, window, cx| { + let reports = reports.clone(); + + window.open_modal(cx, move |this, _window, cx| { + this.title(shared_t!("chat.reports")).child( + v_flex().pb_4().gap_4().children({ + let mut items = Vec::with_capacity(reports.len()); + + for report in reports.iter() { + items.push(Self::render_report(report, cx)) + } + + items + }), + ) + }); + }) + }, + ) + } + + fn render_message_reports(&self, id: &EventId, cx: &Context) -> impl IntoElement { + h_flex() + .id("") + .gap_1() + .text_color(cx.theme().danger_foreground) + .text_xs() + .italic() + .child(Icon::new(IconName::Info).small()) + .child(shared_t!("chat.sent_failed")) + .when_some(self.sent_reports(id).cloned(), |this, reports| { + this.on_click(move |_e, window, cx| { + let reports = reports.clone(); + + window.open_modal(cx, move |this, _window, cx| { + this.title(shared_t!("chat.reports")).child( + v_flex().pb_4().gap_4().children({ + let mut items = Vec::with_capacity(reports.len()); + + for report in reports.iter() { + items.push(Self::render_report(report, cx)) + } + + items + }), + ) + }); + }) + }) + } + + fn render_report(report: &SendReport, cx: &App) -> impl IntoElement { + let registry = Registry::read_global(cx); + let profile = registry.get_person(&report.receiver, cx); + let name = profile.display_name(); + let avatar = profile.avatar_url(true); + + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .text_sm() + .child(shared_t!("chat.sent_to")) + .child( + h_flex() + .gap_1() + .font_semibold() + .child(Avatar::new(avatar).size(rems(1.25))) + .child(name.clone()), + ), + ) + .when(report.nip17_relays_not_found, |this| { + this.child( + h_flex() + .flex_wrap() + .justify_center() + .p_2() + .h_20() + .w_full() + .text_sm() + .rounded(cx.theme().radius) + .bg(cx.theme().danger_background) + .text_color(cx.theme().danger_foreground) + .child( + div() + .flex_1() + .w_full() + .text_center() + .child(shared_t!("chat.nip17_not_found", u = name)), + ), + ) + }) + .when_some(report.local_error.clone(), |this, error| { + this.child( + h_flex() + .flex_wrap() + .justify_center() + .p_2() + .h_20() + .w_full() + .text_sm() + .rounded(cx.theme().radius) + .bg(cx.theme().danger_background) + .text_color(cx.theme().danger_foreground) + .child(div().flex_1().w_full().text_center().child(error)), + ) + }) + .when_some(report.output.clone(), |this, output| { + this.child( + v_flex() + .gap_2() + .text_xs() + .children({ + let mut items = Vec::with_capacity(output.failed.len()); + + for (url, msg) in output.failed.into_iter() { + items.push( + h_flex() + .gap_1() + .justify_between() + .text_sm() + .child( + div() + .flex_1() + .py_0p5() + .px_2() + .bg(cx.theme().elevated_surface_background) + .rounded_sm() + .child(url.to_string()), + ) + .child( + div() + .flex_1() + .py_0p5() + .px_2() + .bg(cx.theme().danger_background) + .text_color(cx.theme().danger_foreground) + .rounded_sm() + .child(msg.to_string()), + ), + ) + } + + items + }) + .children({ + let mut items = Vec::with_capacity(output.success.len()); + + for url in output.success.into_iter() { + items.push( + h_flex() + .gap_1() + .justify_between() + .text_sm() + .child( + div() + .flex_1() + .py_0p5() + .px_2() + .bg(cx.theme().elevated_surface_background) + .rounded_sm() + .child(url.to_string()), + ) + .child( + div() + .flex_1() + .py_0p5() + .px_2() + .bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) + .rounded_sm() + .child(shared_t!("chat.sent_success")), + ), + ) + } + + items + }), + ) + }) + } + + fn render_border(&self, cx: &Context) -> impl IntoElement { + div() + .group_hover("", |this| this.bg(cx.theme().element_active)) + .absolute() + .left_0() + .top_0() + .w(px(2.)) + .h_full() + .bg(cx.theme().border_transparent) + } + + fn render_actions(&self, id: &EventId, cx: &Context) -> impl IntoElement { + let groups = vec![ + Button::new("reply") + .icon(IconName::Reply) + .tooltip(t!("chat.reply_button")) + .small() + .ghost() + .on_click({ + let id = id.to_owned(); + cx.listener(move |this, _event, _window, cx| { + this.reply_to(&id, cx); + }) + }), + Button::new("copy") + .icon(IconName::Copy) + .tooltip(t!("chat.copy_message_button")) + .small() + .ghost() + .on_click({ + let id = id.to_owned(); + cx.listener(move |this, _event, _window, cx| { + this.copy_message(&id, cx); + }) + }), + ]; + + h_flex() + .p_0p5() + .gap_1() + .invisible() + .absolute() + .right_4() + .top_neg_2() + .shadow_sm() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .children(groups) + .group_hover("", |this| this.visible()) + } + + fn render_attachment(&self, url: &Url, cx: &Context) -> impl IntoElement { let url = url.clone(); let path: SharedString = url.to_string().into(); @@ -518,269 +931,87 @@ impl Chat { })) } - fn render_reply_to(&mut self, message: &Message, cx: &Context) -> impl IntoElement { - let registry = Registry::read_global(cx); - let profile = registry.get_person(&message.author, cx); + fn render_attachment_list( + &self, + _window: &Window, + cx: &Context, + ) -> impl IntoIterator { + let mut items = vec![]; - div() - .w_full() - .pl_2() - .border_l_2() - .border_color(cx.theme().element_active) - .child( - div() - .flex() - .items_center() - .justify_between() - .child( - div() - .flex() - .items_baseline() - .gap_1() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::new(t!("chat.replying_to_label"))) - .child( - div() - .text_color(cx.theme().text_accent) - .child(profile.display_name()), - ), - ) - .child( - Button::new("remove-reply") - .icon(IconName::Close) - .xsmall() - .ghost() - .on_click({ - let id = message.id; - cx.listener(move |this, _, _, cx| { - this.remove_reply(id, cx); - }) - }), - ), - ) - .child( - div() - .w_full() - .text_sm() - .text_ellipsis() - .line_clamp(1) - .child(message.content.clone()), - ) + for url in self.attachments.read(cx).iter() { + items.push(self.render_attachment(url, cx)); + } + + items } - fn render_message( - &mut self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let Some(message) = self.messages.read(cx).iter().nth(ix) else { - return div().id(ix); - }; + fn render_reply(&self, id: &EventId, cx: &Context) -> impl IntoElement { + if let Some(text) = self.message(id) { + let registry = Registry::read_global(cx); + let profile = registry.get_person(&text.author, cx); - let proxy = AppSettings::get_proxy_user_avatars(cx); - let hide_avatar = AppSettings::get_hide_user_avatars(cx); - let registry = Registry::read_global(cx); - let author = registry.get_person(&message.author, cx); - - let texts = self - .text_data - .entry(message.id) - .or_insert_with(|| RichText::new(&message.content, cx)); - - div() - .id(ix) - .group("") - .relative() - .w_full() - .py_1() - .px_3() - .child( - div() - .flex() - .gap_3() - .when(!hide_avatar, |this| { - this.child(Avatar::new(author.avatar_url(proxy)).size(rems(2.))) - }) - .child( - div() - .flex_1() - .flex() - .flex_col() - .flex_initial() - .overflow_hidden() - .child( - div() - .flex() - .items_baseline() - .gap_2() - .text_sm() - .child( - div() - .font_semibold() - .text_color(cx.theme().text) - .child(author.display_name()), - ) - .child( - div() - .text_color(cx.theme().text_placeholder) - .child(message.ago()), - ), - ) - .when_some(message.replies_to.as_ref(), |this, replies| { - this.w_full().children({ - let mut items = Vec::with_capacity(replies.len()); - let messages = self.messages.read(cx); - - for (ix, id) in replies.iter().cloned().enumerate() { - let Some(message) = messages.iter().find(|m| m.id == id) - else { - continue; - }; - - items.push( - div() - .id(ix) - .w_full() - .px_2() - .border_l_2() - .border_color(cx.theme().element_selected) - .text_sm() - .child( - div() - .text_color(cx.theme().text_accent) - .child(author.display_name()), - ) - .child( - div() - .w_full() - .text_ellipsis() - .line_clamp(1) - .child(message.content.clone()), - ) - .hover(|this| { - this.bg(cx.theme().elevated_surface_background) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to(id, cx) - })), - ); - } - - items - }) - }) - .child(texts.element(ix.into(), window, cx)) - .when_some(message.errors.as_ref(), |this, errors| { - this.child(self.render_message_errors(errors, cx)) - }), - ), - ) - .child(self.render_border(cx)) - .child(self.render_actions(ix, cx)) - .on_mouse_down( - MouseButton::Middle, - cx.listener(move |this, _event, _window, cx| { - this.copy_message(ix, cx); - }), - ) - .on_double_click(cx.listener({ - move |this, _event, _window, cx| { - this.reply_to(ix, cx); - } - })) - .hover(|this| this.bg(cx.theme().surface_background)) + div() + .w_full() + .pl_2() + .border_l_2() + .border_color(cx.theme().element_active) + .child( + div() + .flex() + .items_center() + .justify_between() + .child( + div() + .flex() + .items_baseline() + .gap_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::new(t!("chat.replying_to_label"))) + .child( + div() + .text_color(cx.theme().text_accent) + .child(profile.display_name()), + ), + ) + .child( + Button::new("remove-reply") + .icon(IconName::Close) + .xsmall() + .ghost() + .on_click({ + let id = text.id; + cx.listener(move |this, _, _, cx| { + this.remove_reply(&id, cx); + }) + }), + ), + ) + .child( + div() + .w_full() + .text_sm() + .text_ellipsis() + .line_clamp(1) + .child(text.content.clone()), + ) + } else { + div() + } } - fn render_message_errors(&self, errors: &[SendError], _cx: &Context) -> impl IntoElement { - let errors = Rc::new(errors.to_owned()); + fn render_reply_list( + &self, + _window: &Window, + cx: &Context, + ) -> impl IntoIterator { + let mut items = vec![]; - div() - .id("") - .flex() - .items_center() - .gap_1() - .text_color(gpui::red()) - .text_xs() - .italic() - .child(Icon::new(IconName::Info).small()) - .child(SharedString::new(t!("chat.send_fail"))) - .on_click(move |_, window, cx| { - let errors = Rc::clone(&errors); + for id in self.replies_to.read(cx).iter() { + items.push(self.render_reply(id, cx)); + } - window.open_modal(cx, move |this, _window, cx| { - this.title(SharedString::new(t!("chat.logs_title"))).child( - div() - .pb_4() - .flex() - .flex_col() - .gap_2() - .children(errors.iter().map(|error| { - div() - .text_sm() - .child( - div() - .flex() - .items_baseline() - .gap_1() - .text_color(cx.theme().text_muted) - .child(SharedString::new(t!("chat.send_to_label"))) - .child(error.profile.display_name()), - ) - .child(error.message.clone()) - })), - ) - }); - }) - } - - fn render_border(&self, cx: &Context) -> impl IntoElement { - div() - .group_hover("", |this| this.bg(cx.theme().element_active)) - .absolute() - .left_0() - .top_0() - .w(px(2.)) - .h_full() - .bg(cx.theme().border_transparent) - } - - fn render_actions(&self, ix: usize, cx: &Context) -> impl IntoElement { - div() - .group_hover("", |this| this.visible()) - .invisible() - .absolute() - .right_4() - .top_neg_2() - .shadow_sm() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .p_0p5() - .flex() - .gap_1() - .children({ - vec![ - Button::new("reply") - .icon(IconName::Reply) - .tooltip(t!("chat.reply_button")) - .small() - .ghost() - .on_click(cx.listener(move |this, _event, _window, cx| { - this.reply_to(ix, cx); - })), - Button::new("copy") - .icon(IconName::Copy) - .tooltip(t!("chat.copy_message_button")) - .small() - .ghost() - .on_click(cx.listener(move |this, _event, _window, cx| { - this.copy_message(ix, cx); - })), - ] - }) + items } } @@ -863,18 +1094,21 @@ impl Focusable for Chat { } impl Render for Chat { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let entity = cx.entity(); - + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .image_cache(self.image_cache.clone()) .size_full() .child( - list(self.list_state.clone(), move |ix, window, cx| { - entity.update(cx, |this, cx| { - this.render_message(ix, window, cx).into_any_element() - }) - }) + list( + self.list_state.clone(), + cx.processor(move |this, ix, window, cx| { + if ix == 0 { + this.render_announcement(ix, cx).into_any_element() + } else { + this.render_message(ix, window, cx).into_any_element() + } + }), + ) .flex_1(), ) .child( @@ -888,21 +1122,9 @@ impl Render for Chat { div() .flex() .flex_col() - .when_some(self.attaches.read(cx).as_ref(), |this, urls| { - this.gap_1p5() - .children(urls.iter().map(|url| self.render_attach(url, cx))) - }) - .when_some(self.replies_to.read(cx).as_ref(), |this, messages| { - this.gap_1p5().children({ - let mut items = vec![]; - - for message in messages.iter() { - items.push(self.render_reply_to(message, cx)); - } - - items - }) - }) + .gap_1p5() + .children(self.render_attachment_list(window, cx)) + .children(self.render_reply_list(window, cx)) .child( div() .w_full() diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 0d49567..d339a51 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -9,8 +9,6 @@ common = { path = "../common" } global = { path = "../global" } settings = { path = "../settings" } -rust-i18n.workspace = true -i18n.workspace = true gpui.workspace = true nostr.workspace = true nostr-sdk.workspace = true diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index ab15afa..98ea4bd 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -20,8 +20,6 @@ use crate::room::Room; pub mod message; pub mod room; -i18n::init!(); - pub fn init(cx: &mut App) { Registry::set_global(cx.new(Registry::new), cx); } @@ -421,8 +419,8 @@ impl Registry { } // Emit the new message to the room - cx.defer_in(window, |this, window, cx| { - this.emit_message(event, window, cx); + cx.defer_in(window, move |this, _window, cx| { + this.emit_message(event, cx); }); }); diff --git a/crates/registry/src/message.rs b/crates/registry/src/message.rs index 9c5caba..5ca8371 100644 --- a/crates/registry/src/message.rs +++ b/crates/registry/src/message.rs @@ -1,19 +1,11 @@ use std::hash::Hash; -use std::iter::IntoIterator; use chrono::{Local, TimeZone}; use gpui::SharedString; use nostr_sdk::prelude::*; -use crate::room::SendError; - -/// Represents a message in the chat system. -/// -/// Contains information about the message content, author, creation time, -/// mentions, replies, and any errors that occurred during sending. #[derive(Debug, Clone)] -pub struct Message { - /// Unique identifier of the message (EventId from nostr_sdk) +pub struct RenderedMessage { pub id: EventId, /// Author's public key pub author: PublicKey, @@ -23,138 +15,82 @@ pub struct Message { pub created_at: Timestamp, /// List of mentioned public keys in the message pub mentions: Vec, - /// List of EventIds this message is replying to - pub replies_to: Option>, - /// Any errors that occurred while sending this message - pub errors: Option>, + /// List of event of the message this message is a reply to + pub replies_to: Vec, } -impl Eq for Message {} +impl From for RenderedMessage { + fn from(inner: Event) -> Self { + let mentions = extract_mentions(&inner.content); + let replies_to = extract_reply_ids(&inner.tags); -impl PartialEq for Message { + Self { + id: inner.id, + author: inner.pubkey, + content: inner.content.into(), + created_at: inner.created_at, + mentions, + replies_to, + } + } +} + +impl From for RenderedMessage { + fn from(inner: UnsignedEvent) -> Self { + let mentions = extract_mentions(&inner.content); + let replies_to = extract_reply_ids(&inner.tags); + + Self { + // Event ID must be known + id: inner.id.unwrap(), + author: inner.pubkey, + content: inner.content.into(), + created_at: inner.created_at, + mentions, + replies_to, + } + } +} + +impl From> for RenderedMessage { + fn from(inner: Box) -> Self { + (*inner).into() + } +} + +impl From<&Box> for RenderedMessage { + fn from(inner: &Box) -> Self { + inner.to_owned().into() + } +} + +impl Eq for RenderedMessage {} + +impl PartialEq for RenderedMessage { fn eq(&self, other: &Self) -> bool { self.id == other.id } } -impl Ord for Message { +impl Ord for RenderedMessage { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.created_at.cmp(&other.created_at) } } -impl PartialOrd for Message { +impl PartialOrd for RenderedMessage { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl Hash for Message { +impl Hash for RenderedMessage { fn hash(&self, state: &mut H) { self.id.hash(state); } } -/// Builder pattern implementation for constructing Message objects. -#[derive(Debug)] -pub struct MessageBuilder { - id: EventId, - author: PublicKey, - content: Option, - created_at: Option, - mentions: Vec, - replies_to: Option>, - errors: Option>, -} - -impl MessageBuilder { - /// Creates a new MessageBuilder with default values - pub fn new(id: EventId, author: PublicKey) -> Self { - Self { - id, - author, - content: None, - created_at: None, - mentions: vec![], - replies_to: None, - errors: None, - } - } - - /// Sets the message content - pub fn content(mut self, content: impl Into) -> Self { - self.content = Some(content.into()); - self - } - - /// Sets the creation timestamp - pub fn created_at(mut self, created_at: Timestamp) -> Self { - self.created_at = Some(created_at); - self - } - - /// Adds a single mention to the message - pub fn mention(mut self, mention: PublicKey) -> Self { - self.mentions.push(mention); - self - } - - /// Adds multiple mentions to the message - pub fn mentions(mut self, mentions: I) -> Self - where - I: IntoIterator, - { - self.mentions.extend(mentions); - self - } - - /// Sets a single message this is replying to - pub fn reply_to(mut self, reply_to: EventId) -> Self { - self.replies_to = Some(vec![reply_to]); - self - } - - /// Sets multiple messages this is replying to - pub fn replies_to(mut self, replies_to: I) -> Self - where - I: IntoIterator, - { - let replies: Vec = replies_to.into_iter().collect(); - if !replies.is_empty() { - self.replies_to = Some(replies); - } - self - } - - /// Adds errors that occurred during sending - pub fn errors(mut self, errors: I) -> Self - where - I: IntoIterator, - { - self.errors = Some(errors.into_iter().collect()); - self - } - - /// Builds the message - pub fn build(self) -> Result { - Ok(Message { - id: self.id, - author: self.author, - content: self.content.ok_or("Content is required")?, - created_at: self.created_at.unwrap_or_else(Timestamp::now), - mentions: self.mentions, - replies_to: self.replies_to, - errors: self.errors, - }) - } -} - -impl Message { - /// Creates a new MessageBuilder - pub fn builder(id: EventId, author: PublicKey) -> MessageBuilder { - MessageBuilder::new(id, author) - } - +impl RenderedMessage { /// Returns a human-readable string representing how long ago the message was created pub fn ago(&self) -> SharedString { let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) { @@ -177,3 +113,41 @@ impl Message { .into() } } + +fn extract_mentions(content: &str) -> Vec { + let parser = NostrParser::new(); + let tokens = parser.parse(content); + + tokens + .filter_map(|token| match token { + Token::Nostr(nip21) => match nip21 { + Nip21::Pubkey(pubkey) => Some(pubkey), + Nip21::Profile(profile) => Some(profile.public_key), + _ => None, + }, + _ => None, + }) + .collect::>() +} + +fn extract_reply_ids(inner: &Tags) -> Vec { + let mut replies_to = vec![]; + + for tag in inner.filter(TagKind::e()) { + if let Some(content) = tag.content() { + if let Ok(id) = EventId::from_hex(content) { + replies_to.push(id); + } + } + } + + for tag in inner.filter(TagKind::q()) { + if let Some(content) = tag.content() { + if let Ok(id) = EventId::from_hex(content) { + replies_to.push(id); + } + } + } + + replies_to +} diff --git a/crates/registry/src/room.rs b/crates/registry/src/room.rs index d55bf18..36b3825 100644 --- a/crates/registry/src/room.rs +++ b/crates/registry/src/room.rs @@ -5,12 +5,11 @@ use chrono::{Local, TimeZone}; use common::display::DisplayProfile; use common::event::EventUtils; use global::nostr_client; -use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; +use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use itertools::Itertools; use nostr_sdk::prelude::*; use smallvec::SmallVec; -use crate::message::Message; use crate::Registry; pub(crate) const NOW: &str = "now"; @@ -20,15 +19,58 @@ pub(crate) const HOURS_IN_DAY: i64 = 24; pub(crate) const DAYS_IN_MONTH: i64 = 30; #[derive(Debug, Clone)] -pub enum RoomSignal { - NewMessage(Message), - Refresh, +pub struct SendReport { + pub receiver: PublicKey, + pub output: Option>, + pub local_error: Option, + pub nip17_relays_not_found: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SendError { - pub profile: Profile, - pub message: SharedString, +impl SendReport { + pub fn output(receiver: PublicKey, output: Output) -> Self { + Self { + receiver, + output: Some(output), + local_error: None, + nip17_relays_not_found: false, + } + } + + pub fn error(receiver: PublicKey, error: impl Into) -> Self { + Self { + receiver, + output: None, + local_error: Some(error.into()), + nip17_relays_not_found: false, + } + } + + pub fn nip17_relays_not_found(receiver: PublicKey) -> Self { + Self { + receiver, + output: None, + local_error: None, + nip17_relays_not_found: true, + } + } + + pub fn is_relay_error(&self) -> bool { + self.local_error.is_some() || self.nip17_relays_not_found + } + + pub fn is_sent_success(&self) -> bool { + if let Some(output) = self.output.as_ref() { + !output.success.is_empty() + } else { + false + } + } +} + +#[derive(Debug, Clone)] +pub enum RoomSignal { + NewMessage(Box), + Refresh, } #[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] @@ -343,201 +385,80 @@ impl Room { /// /// # Returns /// - /// A Task that resolves to Result, Error> containing all messages for this room - pub fn load_messages(&self, cx: &App) -> Task, Error>> { - let pubkeys = self.members.clone(); - - let filter = Filter::new() - .kind(Kind::PrivateDirectMessage) - .authors(self.members.clone()) - .pubkeys(self.members.clone()); + /// A Task that resolves to Result, Error> containing all messages for this room + pub fn load_messages(&self, cx: &App) -> Task, Error>> { + let members = self.members.clone(); + let members_clone = members.clone(); cx.background_spawn(async move { - let mut messages = vec![]; - let parser = NostrParser::new(); - let database = nostr_client().database(); + let client = nostr_client(); + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; - // Get all events from database - let events = database - .query(filter) - .await? + let send = Filter::new() + .kind(Kind::PrivateDirectMessage) + .author(public_key) + .pubkeys(members.clone()); + + let recv = Filter::new() + .kind(Kind::PrivateDirectMessage) + .authors(members) + .pubkey(public_key); + + let send_events = client.database().query(send).await?; + let recv_events = client.database().query(recv).await?; + + let events = send_events + .merge(recv_events) .into_iter() .sorted_by_key(|ev| ev.created_at) - .filter(|ev| ev.compare_pubkeys(&pubkeys)) + .filter(|ev| ev.compare_pubkeys(&members_clone)) .collect::>(); - for event in events.into_iter() { - let content = event.content.clone(); - let tokens = parser.parse(&content); - let mut replies_to = vec![]; - - for tag in event.tags.filter(TagKind::e()) { - if let Some(content) = tag.content() { - if let Ok(id) = EventId::from_hex(content) { - replies_to.push(id); - } - } - } - - for tag in event.tags.filter(TagKind::q()) { - if let Some(content) = tag.content() { - if let Ok(id) = EventId::from_hex(content) { - replies_to.push(id); - } - } - } - - let mentions = tokens - .filter_map(|token| match token { - Token::Nostr(nip21) => match nip21 { - Nip21::Pubkey(pubkey) => Some(pubkey), - Nip21::Profile(profile) => Some(profile.public_key), - _ => None, - }, - _ => None, - }) - .collect::>(); - - if let Ok(message) = Message::builder(event.id, event.pubkey) - .content(content) - .created_at(event.created_at) - .replies_to(replies_to) - .mentions(mentions) - .build() - { - messages.push(message); - } - } - - Ok(messages) + Ok(events) }) } - /// Emits a message event to the GPUI - /// - /// # Arguments - /// - /// * `event` - The Nostr event to emit - /// * `window` - The Window to emit the event to - /// * `cx` - The context for the room - /// - /// # Effects - /// - /// Processes the event and emits an Incoming to the UI when complete - pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context) { - // Extract all mentions from content - let mentions = extract_mentions(&event.content); - - // Extract reply_to if present - let mut replies_to = vec![]; - - for tag in event.tags.filter(TagKind::e()) { - if let Some(content) = tag.content() { - if let Ok(id) = EventId::from_hex(content) { - replies_to.push(id); - } - } - } - - for tag in event.tags.filter(TagKind::q()) { - if let Some(content) = tag.content() { - if let Ok(id) = EventId::from_hex(content) { - replies_to.push(id); - } - } - } - - if let Ok(message) = Message::builder(event.id, event.pubkey) - .content(event.content) - .created_at(event.created_at) - .replies_to(replies_to) - .mentions(mentions) - .build() - { - cx.emit(RoomSignal::NewMessage(message)); - } + /// Emits a new message signal to the current room + pub fn emit_message(&self, event: Event, cx: &mut Context) { + cx.emit(RoomSignal::NewMessage(Box::new(event))); } /// Emits a signal to refresh the current room's messages. pub fn emit_refresh(&mut self, cx: &mut Context) { cx.emit(RoomSignal::Refresh); - log::info!("refresh room: {}", self.id); } /// Creates a temporary message for optimistic updates /// - /// This constructs an unsigned message with the current user as the author, - /// extracts any mentions from the content, and packages it as a Message struct. - /// The message will have a generated ID but hasn't been published to relays. - /// - /// # Arguments - /// - /// * `content` - The message content text - /// * `cx` - The application context containing user profile information - /// - /// # Returns - /// - /// Returns `Some(Message)` containing the temporary message if the current user's profile is available, - /// or `None` if no account is found. + /// The event must not been published to relays. pub fn create_temp_message( &self, - public_key: PublicKey, + receiver: PublicKey, content: &str, - replies: Option<&Vec>, - ) -> Option { - let builder = EventBuilder::private_msg_rumor(public_key, content); + replies: &[EventId], + ) -> UnsignedEvent { + let builder = EventBuilder::private_msg_rumor(receiver, content); + let mut tags = vec![]; // Add event reference if it's present (replying to another event) - let mut refs = vec![]; - - if let Some(replies) = replies { - if replies.len() == 1 { - refs.push(Tag::event(replies[0].id)) - } else { - for message in replies.iter() { - refs.push(Tag::custom(TagKind::q(), vec![message.id])) - } + if replies.len() == 1 { + tags.push(Tag::event(replies[0])) + } else { + for id in replies.iter() { + tags.push(Tag::from_standardized(TagStandard::Quote { + event_id: id.to_owned(), + relay_url: None, + public_key: None, + })) } } - let mut event = if !refs.is_empty() { - builder.tags(refs).build(public_key) - } else { - builder.build(public_key) - }; - - // Create a unsigned event to convert to Coop Message + let mut event = builder.tags(tags).build(receiver); + // Ensure event ID is set event.ensure_id(); - // Extract all mentions from content - let mentions = extract_mentions(&event.content); - - // Extract reply_to if present - let mut replies_to = vec![]; - - for tag in event.tags.filter(TagKind::e()) { - if let Some(content) = tag.content() { - if let Ok(id) = EventId::from_hex(content) { - replies_to.push(id); - } - } - } - - for tag in event.tags.filter(TagKind::q()) { - if let Some(content) = tag.content() { - if let Ok(id) = EventId::from_hex(content) { - replies_to.push(id); - } - } - } - - Message::builder(event.id.unwrap(), public_key) - .content(event.content) - .created_at(event.created_at) - .replies_to(replies_to) - .mentions(mentions) - .build() - .ok() + event } /// Sends a message to all members in the background task @@ -554,12 +475,11 @@ impl Room { pub fn send_in_background( &self, content: &str, - replies: Option<&Vec>, + replies: Vec, backup: bool, cx: &App, - ) -> Task, Error>> { + ) -> Task, Error>> { let content = content.to_owned(); - let replies = replies.cloned(); let subject = self.subject.clone(); let picture = self.picture.clone(); let public_keys = self.members.clone(); @@ -569,8 +489,7 @@ impl Room { let signer = client.signer().await?; let public_key = signer.get_public_key().await?; - let mut reports = vec![]; - let mut tags: Vec = public_keys + let mut tags = public_keys .iter() .filter_map(|pubkey| { if pubkey != &public_key { @@ -579,16 +498,18 @@ impl Room { None } }) - .collect(); + .collect_vec(); // Add event reference if it's present (replying to another event) - if let Some(replies) = replies { - if replies.len() == 1 { - tags.push(Tag::event(replies[0].id)) - } else { - for message in replies.iter() { - tags.push(Tag::custom(TagKind::q(), vec![message.id])) - } + if replies.len() == 1 { + tags.push(Tag::event(replies[0])) + } else { + for id in replies.iter() { + tags.push(Tag::from_standardized(TagStandard::Quote { + event_id: id.to_owned(), + relay_url: None, + public_key: None, + })) } } @@ -608,43 +529,43 @@ impl Room { return Err(anyhow!("Something is wrong. Cannot get receivers list.")); }; + // Stored all send errors + let mut reports = vec![]; + for receiver in receivers.iter() { - if let Err(e) = client + match client .send_private_msg(*receiver, &content, tags.clone()) .await { - let metadata = client - .database() - .metadata(*receiver) - .await? - .unwrap_or_default(); - let profile = Profile::new(*receiver, metadata); - let report = SendError { - profile, - message: e.to_string().into(), - }; - - reports.push(report); + Ok(output) => { + reports.push(SendReport::output(*receiver, output)); + } + Err(e) => { + if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e { + reports.push(SendReport::nip17_relays_not_found(*receiver)); + } else { + reports.push(SendReport::error(*receiver, e.to_string())); + } + } } } - // Only send a backup message to current user if there are no issues when sending to others - if backup && reports.is_empty() { - if let Err(e) = client + // Only send a backup message to current user if sent successfully to others + if reports.iter().all(|r| r.is_sent_success()) && backup { + match client .send_private_msg(*current_user, &content, tags.clone()) .await { - let metadata = client - .database() - .metadata(*current_user) - .await? - .unwrap_or_default(); - let profile = Profile::new(*current_user, metadata); - let report = SendError { - profile, - message: e.to_string().into(), - }; - reports.push(report); + Ok(output) => { + reports.push(SendReport::output(*current_user, output)); + } + Err(e) => { + if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e { + reports.push(SendReport::nip17_relays_not_found(*current_user)); + } else { + reports.push(SendReport::error(*current_user, e.to_string())); + } + } } } @@ -652,19 +573,3 @@ impl Room { }) } } - -pub(crate) fn extract_mentions(content: &str) -> Vec { - let parser = NostrParser::new(); - let tokens = parser.parse(content); - - tokens - .filter_map(|token| match token { - Token::Nostr(nip21) => match nip21 { - Nip21::Pubkey(pubkey) => Some(pubkey), - Nip21::Profile(profile) => Some(profile.public_key), - _ => None, - }, - _ => None, - }) - .collect::>() -} diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 9f9ffc7..ed4fb01 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -61,6 +61,7 @@ pub enum IconName { Forward, Search, SearchFill, + Sent, Settings, SortAscending, SortDescending, @@ -133,6 +134,7 @@ impl IconName { Self::Forward => "icons/forward.svg", Self::Search => "icons/search.svg", Self::SearchFill => "icons/search-fill.svg", + Self::Sent => "icons/sent.svg", Self::Settings => "icons/settings.svg", Self::SortAscending => "icons/sort-ascending.svg", Self::SortDescending => "icons/sort-descending.svg", diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index fb92736..3fb9997 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -54,7 +54,7 @@ type CustomRangeTooltipFn = Option, &mut Window, &mut App) -> Option>>; #[derive(Default)] -pub struct RichText { +pub struct RenderedText { pub text: SharedString, pub highlights: Vec<(Range, Highlight)>, pub link_ranges: Vec>, @@ -63,7 +63,7 @@ pub struct RichText { custom_ranges_tooltip_fn: CustomRangeTooltipFn, } -impl RichText { +impl RenderedText { pub fn new(content: &str, cx: &App) -> Self { let mut text = String::new(); let mut highlights = Vec::new(); @@ -81,7 +81,7 @@ impl RichText { text.truncate(text.trim_end().len()); - RichText { + RenderedText { text: SharedString::from(text), link_urls: link_urls.into(), link_ranges, @@ -98,7 +98,7 @@ impl RichText { self.custom_ranges_tooltip_fn = Some(Arc::new(f)); } - pub fn element(&self, id: ElementId, window: &mut Window, cx: &App) -> AnyElement { + pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { let link_color = cx.theme().text_accent; InteractiveText::new( diff --git a/locales/app.yml b/locales/app.yml index 0e99f85..56e2c8b 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -287,7 +287,7 @@ compose: en: "Subject:" chat: - private_conversation_notice: + notice: en: "This conversation is private. Only members can see each other's messages." placeholder: en: "Message..." @@ -303,12 +303,18 @@ chat: en: "Change the subject of the conversation" replying_to_label: en: "Replying to:" - send_fail: + sent_to: + en: "Sent to:" + sent: + en: "• Sent" + sent_failed: en: "Failed to send message. Click to see details." - logs_title: - en: "Error Logs" - send_to_label: - en: "Send to:" + sent_success: + en: "Successfully" + reports: + en: "Sent Reports" + nip17_not_found: + en: "%{u} has not set up Messaging Relays, so they won't receive your message." sidebar: find_or_start_conversation: