diff --git a/assets/icons/forward.svg b/assets/icons/forward.svg new file mode 100644 index 0000000..98c932b --- /dev/null +++ b/assets/icons/forward.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/reply.svg b/assets/icons/reply.svg new file mode 100644 index 0000000..53da0cb --- /dev/null +++ b/assets/icons/reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chats/src/message.rs b/crates/chats/src/message.rs index 5ce4933..e3c9125 100644 --- a/crates/chats/src/message.rs +++ b/crates/chats/src/message.rs @@ -1,60 +1,152 @@ use chrono::{Local, TimeZone}; use gpui::SharedString; use nostr_sdk::prelude::*; +use std::{cell::RefCell, iter::IntoIterator, rc::Rc}; use crate::room::SendError; -/// # Message +/// Represents a message in the chat system. /// -/// Represents a message in the application. -/// -/// ## Fields -/// -/// - `id`: The unique identifier for the message -/// - `content`: The text content of the message -/// - `author`: Profile information about who created the message -/// - `mentions`: List of profiles mentioned in the message -/// - `created_at`: Timestamp when the message was created +/// Contains information about the message content, author, creation time, +/// mentions, replies, and any errors that occurred during sending. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { - pub id: EventId, - pub content: String, - pub author: Profile, + /// Unique identifier of the message (EventId from nostr_sdk) + pub id: Option, + /// Author profile information + pub author: Option, + /// The content/text of the message + pub content: SharedString, + /// When the message was created pub created_at: Timestamp, + /// List of mentioned profiles 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>, } -impl Message { - /// Creates a new message with the provided details - /// - /// # Arguments - /// - /// * `id` - Unique event identifier - /// * `content` - Message text content - /// * `author` - Profile of the message author - /// * `created_at` - When the message was created - /// - /// # Returns - /// - /// A new `Message` instance - pub fn new(id: EventId, content: String, author: Profile, created_at: Timestamp) -> Self { - Self { - id, - content, - author, - created_at, - mentions: vec![], - errors: None, - } +/// Builder pattern implementation for constructing Message objects. +#[derive(Debug, Default)] +pub struct MessageBuilder { + id: Option, + author: Option, + content: Option, + created_at: Option, + mentions: Vec, + replies_to: Option>, + errors: Option>, +} + +impl MessageBuilder { + /// Creates a new MessageBuilder with default values + pub fn new() -> Self { + Self::default() } - /// Formats the message timestamp as a human-readable relative time - /// - /// # Returns - /// - /// A formatted string like "Today at 12:30 PM", "Yesterday at 3:45 PM", - /// or a date and time for older messages + /// Sets the message ID + pub fn id(mut self, id: EventId) -> Self { + self.id = Some(id); + self + } + + /// Sets the message author + pub fn author(mut self, author: Profile) -> Self { + self.author = Some(author); + self + } + + /// Sets the message content + pub fn content(mut self, content: String) -> Self { + self.content = Some(content); + 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: Profile) -> 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 wrapped in an Rc> + pub fn build_rc(self) -> Result>, String> { + self.build().map(|m| Rc::new(RefCell::new(m))) + } + + /// Builds the message + pub fn build(self) -> Result { + Ok(Message { + id: self.id, + author: self.author, + content: self.content.ok_or("Content is required")?.into(), + 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() -> MessageBuilder { + MessageBuilder::new() + } + + /// Converts the message into an Rc> + pub fn into_rc(self) -> Rc> { + Rc::new(RefCell::new(self)) + } + + /// Builds a message from a builder and wraps it in Rc + pub fn build_rc(builder: MessageBuilder) -> Result>, String> { + builder.build().map(|m| Rc::new(RefCell::new(m))) + } + + /// 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) { chrono::LocalResult::Single(time) => time, @@ -75,89 +167,4 @@ impl Message { } .into() } - - /// Adds or replaces mentions in the message - /// - /// # Arguments - /// - /// * `mentions` - New list of mentioned profiles - /// - /// # Returns - /// - /// The same message with updated mentions - pub fn with_mentions(mut self, mentions: impl IntoIterator) -> Self { - self.mentions.extend(mentions); - self - } - - /// Adds or replaces errors in the message - /// - /// # Arguments - /// - /// * `errors` - New list of errors - /// - /// # Returns - /// - /// The same message with updated errors - pub fn with_errors(mut self, errors: Vec) -> Self { - self.errors = Some(errors); - self - } -} - -/// # RoomMessage -/// -/// Represents different types of messages that can appear in a room. -/// -/// ## Variants -/// -/// - `User`: A message sent by a user -/// - `System`: A message generated by the system -/// - `Announcement`: A special message type used for room announcements -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RoomMessage { - /// User message - User(Box), - /// System message - System(SharedString), - /// Only use for UI purposes. - /// Placeholder will be used for display room announcement - Announcement, -} - -impl RoomMessage { - /// Creates a new user message - /// - /// # Arguments - /// - /// * `message` - The message content - /// - /// # Returns - /// - /// A `RoomMessage::User` variant - pub fn user(message: Message) -> Self { - Self::User(Box::new(message)) - } - - /// Creates a new system message - /// - /// # Arguments - /// - /// * `content` - The system message content - /// - /// # Returns - /// - /// A `RoomMessage::System` variant - pub fn system(content: SharedString) -> Self { - Self::System(content) - } - - /// Creates a new announcement placeholder - /// - /// # Returns - /// - /// A `RoomMessage::Announcement` variant - pub fn announcement() -> Self { - Self::Announcement - } } diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index 5db3eca..7750f0f 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -11,7 +11,7 @@ use nostr_sdk::prelude::*; use crate::{ constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE}, - message::{Message, RoomMessage}, + message::Message, ChatRegistry, }; @@ -388,7 +388,7 @@ impl Room { /// /// A Task that resolves to Result, Error> containing /// all messages for this room - pub fn load_messages(&self, cx: &App) -> Task, Error>> { + pub fn load_messages(&self, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = Arc::clone(&self.members); let profiles: Vec = pubkeys @@ -421,11 +421,26 @@ impl Room { .collect::>(); for event in events.into_iter() { - let id = event.id; - let created_at = event.created_at; let content = event.content.clone(); let tokens = parser.parse(&content); let mut mentions = vec![]; + 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 author = profiles .iter() @@ -454,10 +469,17 @@ impl Room { ); } - let message = Message::new(id, content, author, created_at).with_mentions(mentions); - let room_message = RoomMessage::user(message); - - messages.push(room_message); + if let Ok(message) = Message::builder() + .id(event.id) + .content(content) + .author(author) + .created_at(event.created_at) + .replies_to(replies_to) + .mentions(mentions) + .build() + { + messages.push(message); + } } Ok(messages) @@ -477,11 +499,40 @@ impl Room { /// 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) { let author = ChatRegistry::get_global(cx).profile(&event.pubkey, cx); - let mentions = extract_mentions(&event.content, cx); - let message = - Message::new(event.id, event.content, author, event.created_at).with_mentions(mentions); - cx.emit(Incoming(message)); + // Extract all mentions from content + let mentions = extract_mentions(&event.content, cx); + + // 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() + .id(event.id) + .content(event.content) + .author(author) + .created_at(event.created_at) + .replies_to(replies_to) + .mentions(mentions) + .build() + { + cx.emit(Incoming(message)); + } } /// Creates a temporary message for optimistic updates @@ -499,22 +550,69 @@ impl Room { /// /// Returns `Some(Message)` containing the temporary message if the current user's profile is available, /// or `None` if no account is found. - pub fn create_temp_message(&self, content: &str, cx: &App) -> Option { - let profile = Account::get_global(cx).profile.clone()?; - let public_key = profile.public_key(); + pub fn create_temp_message( + &self, + content: &str, + replies: Option<&Vec>, + cx: &App, + ) -> Option { + let author = Account::get_global(cx).profile.clone()?; + let public_key = author.public_key(); let builder = EventBuilder::private_msg_rumor(public_key, content); + // 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.unwrap())) + } else { + for message in replies.iter() { + refs.push(Tag::custom(TagKind::q(), vec![message.id.unwrap()])) + } + } + } + + 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.build(public_key); event.ensure_id(); // Extract all mentions from content let mentions = extract_mentions(&event.content, cx); - Some( - Message::new(event.id.unwrap(), event.content, profile, event.created_at) - .with_mentions(mentions), - ) + // 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() + .id(event.id.unwrap()) + .content(event.content) + .author(author) + .created_at(event.created_at) + .replies_to(replies_to) + .mentions(mentions) + .build() + .ok() } /// Sends a message to all members in the background task @@ -528,8 +626,14 @@ impl Room { /// /// A Task that resolves to Result, Error> where the /// strings contain error messages for any failed sends - pub fn send_in_background(&self, msg: &str, cx: &App) -> Task, Error>> { - let content = msg.to_owned(); + pub fn send_in_background( + &self, + content: &str, + replies: Option<&Vec>, + cx: &App, + ) -> Task, Error>> { + let content = content.to_owned(); + let replies = replies.cloned(); let subject = self.subject.clone(); let picture = self.picture.clone(); let public_keys = Arc::clone(&self.members); @@ -551,6 +655,17 @@ impl Room { }) .collect(); + // 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.unwrap())) + } else { + for message in replies.iter() { + tags.push(Tag::custom(TagKind::q(), vec![message.id.unwrap()])) + } + } + } + // Add subject tag if it's present if let Some(subject) = subject { tags.push(Tag::from_standardized(TagStandard::Subject( diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index cf91182..e265a2f 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -1,10 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; use anyhow::{anyhow, Error}; use async_utility::task::spawn; use chats::{ - message::{Message, RoomMessage}, - room::Room, + message::Message, + room::{Room, SendError}, ChatRegistry, }; use common::{nip96_upload, profile::SharedProfile}; @@ -35,8 +35,6 @@ use ui::{ use crate::views::subject; -const DESC: &str = "This conversation is private. Only members can see each other's messages."; - #[derive(Clone, PartialEq, Eq, Deserialize)] pub struct ChangeSubject(pub String); @@ -56,11 +54,12 @@ pub struct Chat { focus_handle: FocusHandle, // Chat Room room: Entity, - messages: Entity>, + messages: Entity>>>, text_data: HashMap, list_state: ListState, // New Message input: Entity, + replies_to: Entity>>, // Media Attachment attaches: Entity>>, uploading: bool, @@ -70,17 +69,30 @@ pub struct Chat { impl Chat { pub fn new(id: &u64, room: Entity, window: &mut Window, cx: &mut App) -> Entity { - let messages = cx.new(|_| vec![RoomMessage::announcement()]); let attaches = cx.new(|_| None); + let replies_to = cx.new(|_| None); + + let messages = cx.new(|_| { + let message = Message::builder() + .content( + "This conversation is private. Only members can see each other's messages." + .into(), + ) + .build_rc() + .unwrap(); + + vec![message] + }); + let input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Message...") .multi_line() .prevent_new_line_on_enter() .rows(1) + .max_rows(20) .auto_grow() .clean_on_escape() - .max_rows(20) }); cx.new(|cx| { @@ -102,25 +114,13 @@ impl Chat { subscriptions.push( cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| { - let created_at = &incoming.0.created_at.to_string()[..5]; - let content = incoming.0.content.as_str(); - let author = incoming.0.author.public_key(); - // Check if the incoming message is the same as the new message created by optimistic update - if this.messages.read(cx).iter().any(|msg| { - if let RoomMessage::User(m) = msg { - created_at == &m.created_at.to_string()[..5] - && m.content == content - && m.author.public_key() == author - } else { - false - } - }) { + if this.prevent_duplicate_message(&incoming.0, cx) { return; } let old_len = this.messages.read(cx).len(); - let message = RoomMessage::user(incoming.0.clone()); + let message = incoming.0.clone().into_rc(); cx.update_entity(&this.messages, |this, cx| { this.extend(vec![message]); @@ -152,6 +152,7 @@ impl Chat { messages, list_state, input, + replies_to, attaches, subscriptions, } @@ -161,18 +162,18 @@ impl Chat { /// Load all messages belonging to this room pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context) { let room = self.room.read(cx); - let task = room.load_messages(cx); + let load_messages = room.load_messages(cx); cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(events) => { + match load_messages.await { + Ok(messages) => { this.update(cx, |this, cx| { let old_len = this.messages.read(cx).len(); - let new_len = events.len(); + let new_len = messages.len(); // Extend the messages list with the new events this.messages.update(cx, |this, cx| { - this.extend(events); + this.extend(messages.into_iter().map(|e| e.into_rc())); cx.notify(); }); @@ -216,21 +217,42 @@ impl Chat { content } + fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context) -> bool { + let min_timestamp = new_msg.created_at.as_u64().saturating_sub(2); + + self.messages.read(cx).iter().any(|existing| { + let existing = existing.borrow(); + // 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) + }) + } + fn send_message(&mut self, window: &mut Window, cx: &mut Context) { self.input.update(cx, |this, cx| { this.set_loading(true, cx); this.set_disabled(true, cx); }); + // Get the message which includes all attachments let content = self.message(cx); + // Get replies_to if it's present + let replies = self.replies_to.read(cx).as_ref(); + // Get the current room entity let room = self.room.read(cx); - let temp_message = room.create_temp_message(&content, cx); - let send_message = room.send_in_background(&content, cx); + // Create a temporary message for optimistic update + let temp_message = room.create_temp_message(&content, replies, cx); + // Create a task for sending the message in the background + let send_message = room.send_in_background(&content, replies, cx); if let Some(message) = temp_message { let id = message.id; // Optimistically update message list - self.push_user_message(message, cx); + self.insert_message(message, cx); + // Remove all replies + self.remove_all_replies(cx); // Reset the input state self.input.update(cx, |this, cx| { @@ -245,16 +267,10 @@ impl Chat { if !reports.is_empty() { this.update(cx, |this, cx| { this.messages.update(cx, |this, cx| { - if let Some(msg) = this.iter_mut().find(|msg| { - if let RoomMessage::User(m) = msg { - m.id == id - } else { - false - } + if let Some(msg) = id.and_then(|id| { + this.iter().find(|msg| msg.borrow().id == Some(id)).cloned() }) { - if let RoomMessage::User(this) = msg { - this.errors = Some(reports) - } + msg.borrow_mut().errors = Some(reports); cx.notify(); } }); @@ -267,9 +283,9 @@ impl Chat { } } - fn push_user_message(&self, message: Message, cx: &mut Context) { + fn insert_message(&self, message: Message, cx: &mut Context) { let old_len = self.messages.read(cx).len(); - let message = RoomMessage::user(message); + let message = message.into_rc(); cx.update_entity(&self.messages, |this, cx| { this.extend(vec![message]); @@ -279,17 +295,44 @@ impl Chat { self.list_state.splice(old_len..old_len, 1); } - #[allow(dead_code)] - fn push_system_message(&self, content: String, cx: &mut Context) { - let old_len = self.messages.read(cx).len(); - let message = RoomMessage::system(content.into()); + fn scroll_to(&self, id: EventId, cx: &Context) { + if let Some(ix) = self + .messages + .read(cx) + .iter() + .position(|m| m.borrow().id == Some(id)) + { + self.list_state.scroll_to_reveal_item(ix); + } + } - cx.update_entity(&self.messages, |this, cx| { - this.extend(vec![message]); + fn reply(&mut self, message: Message, cx: &mut Context) { + self.replies_to.update(cx, |this, cx| { + if let Some(replies) = this { + replies.push(message); + } else { + *this = Some(vec![message]) + } cx.notify(); }); + } - self.list_state.splice(old_len..old_len, 1); + 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 == Some(id)) { + replies.remove(ix); + cx.notify(); + } + } + }); + } + + fn remove_all_replies(&mut self, cx: &mut Context) { + self.replies_to.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); } fn upload_media(&mut self, window: &mut Window, cx: &mut Context) { @@ -380,6 +423,90 @@ impl Chat { cx.notify(); } + fn render_attach(&mut self, url: &Url, cx: &Context) -> impl IntoElement { + let url = url.clone(); + let path: SharedString = url.to_string().into(); + + div() + .id(path.clone()) + .relative() + .w_16() + .child( + img(format!( + "{}/?url={}&w=128&h=128&fit=cover&n=-1", + IMAGE_SERVICE, path + )) + .size_16() + .shadow_lg() + .rounded(cx.theme().radius) + .object_fit(ObjectFit::ScaleDown), + ) + .child( + div() + .absolute() + .top_neg_2() + .right_neg_2() + .size_4() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(red()) + .child(Icon::new(IconName::Close).size_2().text_color(white())), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.remove_media(&url, window, cx); + })) + } + + fn render_reply(&mut self, message: &Message, cx: &Context) -> impl IntoElement { + 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("Replying to:") + .child( + div() + .text_color(cx.theme().text_accent) + .child(message.author.as_ref().unwrap().shared_name()), + ), + ) + .child( + Button::new("remove-reply") + .icon(IconName::Close) + .xsmall() + .ghost() + .on_click({ + let id = message.id.unwrap(); + 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()), + ) + } + fn render_message( &mut self, ix: usize, @@ -387,160 +514,182 @@ impl Chat { cx: &mut Context, ) -> impl IntoElement { let Some(message) = self.messages.read(cx).get(ix) else { - return div().into_element(); + return div().id(ix); }; - match message { - RoomMessage::User(item) => self.render_user_msg(item, window, cx), - RoomMessage::System(content) => self.render_system_msg(content, cx), - RoomMessage::Announcement => self.render_announcement_msg(cx), - } - } + let message = message.borrow(); + + // Message without ID, Author probably the placeholder + let (Some(id), Some(author)) = (message.id, message.author.as_ref()) else { + return div() + .id(ix) + .group("") + .w_full() + .relative() + .flex() + .gap_3() + .px_3() + .py_2() + .w_full() + .h_32() + .flex() + .flex_col() + .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(message.content.clone()); + }; - fn render_user_msg(&mut self, item: &Message, window: &mut Window, cx: &Context) -> Div { let texts = self .text_data - .entry(item.id) - .or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions)); + .entry(id) + .or_insert_with(|| RichText::new(message.content.to_string(), &message.mentions)); div() + .id(ix) .group("") - .w_full() .relative() - .flex() - .gap_3() + .w_full() + .py_1() .px_3() - .py_2() - .hover(|this| this.bg(cx.theme().surface_background)) - .child( - div() - .absolute() - .left_0() - .top_0() - .w(px(2.)) - .h_full() - .bg(cx.theme().border_transparent) - .group_hover("", |this| this.bg(cx.theme().element_active)), - ) - .child(img(item.author.shared_avatar()).size_8().flex_shrink_0()) .child( div() .flex() - .flex_col() - .flex_initial() - .overflow_hidden() + .gap_3() + .child(img(author.shared_avatar()).size_8().flex_shrink_0()) .child( div() + .flex_1() .flex() - .items_baseline() - .gap_2() - .text_sm() + .flex_col() + .flex_initial() + .overflow_hidden() .child( div() - .font_semibold() - .text_color(cx.theme().text) - .child(item.author.shared_name()), + .flex() + .items_baseline() + .gap_2() + .text_sm() + .child( + div() + .font_semibold() + .text_color(cx.theme().text) + .child(author.shared_name()), + ) + .child( + div() + .text_color(cx.theme().text_placeholder) + .child(message.ago()), + ), ) - .child( - div() - .text_color(cx.theme().text_placeholder) - .child(item.ago()), - ), - ) - .child(texts.element("body".into(), window, cx)) - .when_some(item.errors.clone(), |this, errors| { - this.child( - div() - .id("") - .flex() - .items_center() - .gap_1() - .text_color(gpui::red()) - .text_xs() - .italic() - .child(Icon::new(IconName::Info).small()) - .child("Failed to send message. Click to see details.") - .on_click(move |_, window, cx| { - let errors = errors.clone(); + .when_some(message.replies_to.as_ref(), |this, replies| { + this.w_full().children({ + let mut items = vec![]; - window.open_modal(cx, move |this, _window, cx| { - this.title("Error Logs").child( - div().flex().flex_col().gap_2().px_3().pb_3().children( - errors.clone().into_iter().map(|error| { - div() - .text_sm() - .child( - div() - .flex() - .items_baseline() - .gap_1() - .text_color(cx.theme().text_muted) - .child("Send to:") - .child(error.profile.shared_name()), - ) - .child(error.message) - }), - ), - ) - }); - }), - ) - }), - ) - } + for (ix, id) in replies.iter().enumerate() { + if let Some(message) = self + .messages + .read(cx) + .iter() + .find(|msg| msg.borrow().id == Some(*id)) + .cloned() + { + let message = message.borrow(); - fn render_system_msg(&mut self, content: &SharedString, cx: &Context) -> Div { - div() - .group("") - .w_full() - .relative() - .flex() - .gap_3() - .px_3() - .py_2() - .items_center() - .child( - div() - .absolute() - .left_0() - .top_0() - .w(px(2.)) - .h_full() - .bg(cx.theme().border_transparent) - .group_hover("", |this| this.bg(red())), - ) - .child(img("brand/avatar.png").size_8().flex_shrink_0()) - .text_sm() - .text_color(red()) - .child(content.clone()) - } + 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( + message + .author + .as_ref() + .unwrap() + .shared_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 = message.id.unwrap(); + cx.listener(move |this, _, _, cx| { + this.scroll_to(id, cx) + }) + }), + ); + } + } - fn render_announcement_msg(&mut self, cx: &Context) -> Div { - div() - .group("") - .w_full() - .relative() - .flex() - .gap_3() - .px_3() - .py_2() - .w_full() - .h_32() - .flex() - .flex_col() - .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), + items + }) + }) + .child(texts.element("body".into(), window, cx)) + .when_some(message.errors.clone(), |this, errors| { + this.child( + div() + .id("") + .flex() + .items_center() + .gap_1() + .text_color(gpui::red()) + .text_xs() + .italic() + .child(Icon::new(IconName::Info).small()) + .child("Failed to send message. Click to see details.") + .on_click(move |_, window, cx| { + let errors = errors.clone(); + + window.open_modal(cx, move |this, _window, cx| { + this.title("Error Logs") + .child(message_errors(errors.clone(), cx)) + }); + }), + ) + }), + ), ) - .child(DESC) + .child(message_border(cx)) + .child(message_actions( + vec![Button::new("reply") + .icon(IconName::Reply) + .tooltip("Reply") + .small() + .ghost() + .on_click({ + let message = message.clone(); + cx.listener(move |this, _, _, cx| { + this.reply(message.clone(), cx); + }) + })], + cx, + )) + .hover(|this| this.bg(cx.theme().surface_background)) } } @@ -607,83 +756,115 @@ impl Render for Chat { .size_full() .child(list(self.list_state.clone()).flex_1()) .child( - div().flex_shrink_0().px_3().py_2().child( - div() - .flex() - .flex_col() - .when_some(self.attaches.read(cx).as_ref(), |this, attaches| { - this.gap_1p5().children(attaches.iter().map(|url| { - let url = url.clone(); - let path: SharedString = url.to_string().into(); + div() + .flex_shrink_0() + .w_full() + .relative() + .px_3() + .py_2() + .child( + 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(message, cx)); + } + + items + }) + }) + .child( div() - .id(path.clone()) - .relative() - .w_16() - .child( - img(format!( - "{}/?url={}&w=128&h=128&fit=cover&n=-1", - IMAGE_SERVICE, path - )) - .size_16() - .shadow_lg() - .rounded(cx.theme().radius) - .object_fit(ObjectFit::ScaleDown), - ) + .w_full() + .flex() + .items_end() + .gap_2p5() .child( div() - .absolute() - .top_neg_2() - .right_neg_2() - .size_4() .flex() .items_center() - .justify_center() - .rounded_full() - .bg(red()) + .gap_1() + .text_color(cx.theme().text_muted) .child( - Icon::new(IconName::Close) - .size_2() - .text_color(white()), + Button::new("upload") + .icon(Icon::new(IconName::Upload)) + .ghost() + .disabled(self.uploading) + .loading(self.uploading) + .on_click(cx.listener( + move |this, _, window, cx| { + this.upload_media(window, cx); + }, + )), + ) + .child( + EmojiPicker::new(self.input.downgrade()) + .icon(IconName::EmojiFill), ), ) - .on_click(cx.listener(move |this, _, window, cx| { - this.remove_media(&url, window, cx); - })) - })) - }) - .child( - div() - .w_full() - .flex() - .items_end() - .gap_2p5() - .child( - div() - .flex() - .items_center() - .gap_1() - .text_color(cx.theme().text_muted) - .child( - Button::new("upload") - .icon(Icon::new(IconName::Upload)) - .ghost() - .disabled(self.uploading) - .loading(self.uploading) - .on_click(cx.listener( - move |this, _, window, cx| { - this.upload_media(window, cx); - }, - )), - ) - .child( - EmojiPicker::new(self.input.downgrade()) - .icon(IconName::EmojiFill), - ), - ) - .child(TextInput::new(&self.input)), - ), - ), + .child(TextInput::new(&self.input)), + ), + ), ) } } + +fn message_border(cx: &App) -> Div { + 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 message_errors(errors: Vec, cx: &App) -> Div { + div() + .flex() + .flex_col() + .gap_2() + .px_3() + .pb_3() + .children(errors.into_iter().map(|error| { + div() + .text_sm() + .child( + div() + .flex() + .items_baseline() + .gap_1() + .text_color(cx.theme().text_muted) + .child("Send to:") + .child(error.profile.shared_name()), + ) + .child(error.message) + })) +} + +fn message_actions(buttons: Vec